Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 27 additions & 9 deletions internal/handlers/npm_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package handlers
import (
"fmt"
"net/http"
"net/url"
"strings"
"sync"

Expand Down Expand Up @@ -46,16 +47,22 @@ func NewNPMRegistryHandler(creds config.Credentials) *NPMRegistryHandler {

oidcCredential, _ := oidc.CreateOIDCCredential(cred)
if oidcCredential != nil {
host := cred.Host()
if host == "" && registry != "" {
var myUrl string
maybeUrl := cred.GetString("url")
if maybeUrl == "" && registry != "" {
regURL, err := helpers.ParseURLLax(registry)
if err == nil {
host = regURL.Hostname()
myUrl = fmt.Sprintf("%s/%s", regURL.Host, regURL.Path)
}
} else {
parsedUrl, err := helpers.ParseURLLax(maybeUrl)
if err == nil {
myUrl = fmt.Sprintf("%s/%s", parsedUrl.Host, parsedUrl.Path)
}
}
if host != "" {
handler.oidcCredentials[host] = oidcCredential
logging.RequestLogf(nil, "registered %s OIDC credentials for npm registry: %s", oidcCredential.Provider(), host)
if myUrl != "" {
handler.oidcCredentials[myUrl] = oidcCredential
logging.RequestLogf(nil, "registered %s OIDC credentials for npm registry: %s", oidcCredential.Provider(), myUrl)
}
continue
}
Expand All @@ -73,6 +80,17 @@ func NewNPMRegistryHandler(creds config.Credentials) *NPMRegistryHandler {
return &handler
}

// GetOIDCCredential returns oidc credential for url
func (h *NPMRegistryHandler) GetOIDCCredential(url url.URL) (*oidc.OIDCCredential, bool) {
targetUrl := fmt.Sprintf("%s/%s", url.Host, url.Path)
Comment on lines +84 to +85
Comment on lines +83 to +85
for registry, cred := range h.oidcCredentials {
if strings.HasPrefix(targetUrl, registry) {
Comment on lines +86 to +87
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems incredibly unlikely, someone would have to explicitly configure this on one side only, not sure why they would do that

return cred, true
}
}
Comment on lines +85 to +90
return nil, false
}

// HandleRequest adds auth to an npm registry request
func (h *NPMRegistryHandler) HandleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
if req.URL.Scheme != "https" || !helpers.MethodPermitted(req, "GET", "HEAD") {
Expand All @@ -87,16 +105,16 @@ func (h *NPMRegistryHandler) HandleRequest(req *http.Request, ctx *goproxy.Proxy

// Try OIDC credentials first
h.mutex.RLock()
oidcCred, hasOIDC := h.oidcCredentials[reqHost]
oidcCred, hasOIDC := h.GetOIDCCredential(*req.URL)
h.mutex.RUnlock()
Comment on lines 106 to 109

if hasOIDC {
token, err := oidc.GetOrRefreshOIDCToken(oidcCred, req.Context())
if err != nil {
logging.RequestLogf(ctx, "* failed to get token via OIDC for %s: %v", reqHost, err)
logging.RequestLogf(ctx, "* failed to get token via OIDC for %s: %v", req.URL, err)
// Fall through to try static credentials
} else {
logging.RequestLogf(ctx, "* authenticating npm registry request with OIDC token (host: %s)", reqHost)
logging.RequestLogf(ctx, "* authenticating npm registry request with OIDC token (host: %s)", req.URL)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
Comment on lines +114 to 118
return req, nil
}
Expand Down
108 changes: 108 additions & 0 deletions internal/handlers/npm_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ package handlers

import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"

"github.com/dependabot/proxy/internal/config"
"github.com/jarcoal/httpmock"
"github.com/stretchr/testify/assert"
)

func TestNPMRegistryHandler(t *testing.T) {
Expand Down Expand Up @@ -40,6 +44,24 @@ func TestNPMRegistryHandler(t *testing.T) {
"url": "https://example.org:443/reg-path",
"token": privateRegToken,
},
config.Credential{
"type": "npm_registry",
"url": "https://mydomain-123456789123.d.codeartifact.us-east-1.amazonaws.com/npm/my-registry-1/",
"aws-region": "us-east-1",
"account-id": "123456789123",
"role-name": "my-registry-role-1",
"domain": "mydomain",
"domain-owner": "123456789123",
},
config.Credential{
"type": "npm_registry",
"url": "https://mydomain-123456789123.d.codeartifact.us-east-1.amazonaws.com/npm/my-registry-2/",
"aws-region": "us-east-1",
"account-id": "123456789123",
"role-name": "my-registry-role-2",
"domain": "mydomain",
"domain-owner": "123456789123",
},
}
handler := NewNPMRegistryHandler(credentials)

Expand Down Expand Up @@ -87,3 +109,89 @@ func TestNPMRegistryHandler(t *testing.T) {
req = handleRequestAndClose(handler, req, nil)
assertHasBasicAuth(t, req, nexusUser, nexusPassword, "azure devops case insensitive registry request")
}

func TestNPMRegistryHandler_OIDC_MultipleRegistriesSameHost(t *testing.T) {
// Setup environment for OIDC
os.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "http://oidc-url")
os.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "oidc-token")
defer os.Unsetenv("ACTIONS_ID_TOKEN_REQUEST_URL")
defer os.Unsetenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
Comment on lines +115 to +118

httpmock.Activate()
defer httpmock.DeactivateAndReset()

// Mock OIDC token endpoint
httpmock.RegisterResponder("GET", "http://oidc-url",
httpmock.NewStringResponder(200, `{"value": "github-jwt"}`))

// Mock AWS STS AssumeRoleWithWebIdentity
httpmock.RegisterResponder("POST", "https://sts.amazonaws.com",
func(req *http.Request) (*http.Response, error) {
roleArn := req.FormValue("RoleArn")

// We need to return an XML response for AWS STS
xmlResp := fmt.Sprintf(`
<AssumeRoleWithWebIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
<AssumeRoleWithWebIdentityResult>
<Credentials>
<AccessKeyId>AKIA%s</AccessKeyId>
<SecretAccessKey>secret-%s</SecretAccessKey>
<SessionToken>session-%s</SessionToken>
<Expiration>2026-03-19T17:07:00Z</Expiration>
</Credentials>
</AssumeRoleWithWebIdentityResult>
</AssumeRoleWithWebIdentityResponse>`, roleArn, roleArn, roleArn)
return httpmock.NewStringResponse(200, xmlResp), nil
})

// Mock AWS CodeArtifact GetAuthorizationToken
httpmock.RegisterResponder("POST", "https://codeartifact.us-east-1.amazonaws.com/v1/authorization-token",
func(req *http.Request) (*http.Response, error) {
sessionToken := req.Header.Get("X-Amz-Security-Token")
// The session token contains the role ARN in our mock
token := "final-token-for-" + sessionToken
return httpmock.NewJsonResponse(200, map[string]any{
"authorizationToken": token,
"expiration": 3600,
})
})

host := "mydomain-123456789000.d.codeartifact.us-east-1.amazonaws.com"
reg1Url := fmt.Sprintf("https://%s/npm/registry1/", host)
reg2Url := fmt.Sprintf("https://%s/npm/registry2/", host)

credentials := config.Credentials{
config.Credential{
"type": "npm_registry",
"registry": reg1Url,
"aws-region": "us-east-1",
"account-id": "123456789012",
"role-name": "Role1",
"domain": "mydomain",
"domain-owner": "123456789012",
},
config.Credential{
"type": "npm_registry",
"registry": reg2Url,
"aws-region": "us-east-1",
"account-id": "123456789012",
"role-name": "Role2",
"domain": "mydomain",
"domain-owner": "123456789012",
},
}

handler := NewNPMRegistryHandler(credentials)

// Test request to registry 1
req1 := httptest.NewRequest("GET", reg1Url+"some-package", nil)
handleRequestAndClose(handler, req1, nil)
// Expectation: it should use Role1
assert.Equal(t, "Bearer final-token-for-session-arn:aws:iam::123456789012:role/Role1", req1.Header.Get("Authorization"), "Registry 1 should use Role 1")

// Test request to registry 2
req2 := httptest.NewRequest("GET", reg2Url+"some-package", nil)
handleRequestAndClose(handler, req2, nil)
// Expectation: it should use Role2
assert.Equal(t, "Bearer final-token-for-session-arn:aws:iam::123456789012:role/Role2", req2.Header.Get("Authorization"), "Registry 2 should use Role 2")
}
Loading