Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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)
}
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

OIDC credential registration no longer considers the credential's host field (or cred.Host()), only url/registry. This is a behavior change from the previous implementation and will break OIDC for npm registries configured via host only. Consider falling back to cred.Host() (and/or cred.GetString("host")) when url and registry are empty, while still preferring the full URL+path when available.

Suggested change
}
}
if maybeUrl == "" {
maybeUrl = cred.GetString("host")
if maybeUrl == "" {
maybeUrl = cred.Host()
}
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

now trying host, url, registry (in that order). but not using the .Host() method as it just returns the hostname part if a url is specified

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)
for registry, cred := range h.oidcCredentials {
if strings.HasPrefix(targetUrl, registry) {
Copy link
Copy Markdown
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
}
}
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()

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))
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",
},
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

In this test case, the two added CodeArtifact credentials use the url field but do not set up OIDC env vars, so CreateOIDCCredential returns nil and these entries fall through as static npm credentials with an empty registry/token. They currently have no effect on the assertions and make the fixture harder to understand. Consider either (a) using the expected registry field and asserting behavior, or (b) moving these credentials into the OIDC-specific test where OIDC is configured.

Suggested change
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",
},

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

these were leftover from an earlier attempt at testing, have now been removed.

}
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")

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