Skip to content
Closed
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
37 changes: 14 additions & 23 deletions internal/handlers/npm_registry.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package handlers

import (
"fmt"
"net/http"
"strings"
"sync"
Expand Down Expand Up @@ -46,18 +45,20 @@ func NewNPMRegistryHandler(creds config.Credentials) *NPMRegistryHandler {

oidcCredential, _ := oidc.CreateOIDCCredential(cred)
if oidcCredential != nil {
host := cred.Host()
if host == "" && registry != "" {
regURL, err := helpers.ParseURLLax(registry)
if err == nil {
host = regURL.Hostname()
maybeUrl := cred.GetString("host")
if maybeUrl == "" {
maybeUrl = cred.GetString("url")
if maybeUrl == "" {
maybeUrl = registry
}
}
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)
parsedUrl, err := helpers.ParseURLLax(maybeUrl)
if err == nil {
handler.oidcCredentials[parsedUrl.String()] = oidcCredential
logging.RequestLogf(nil, "registered %s OIDC credentials for npm registry: %s", oidcCredential.Provider(), parsedUrl.String())
continue
}
continue
logging.RequestLogf(nil, "failed to register OIDC credential for npm registry: %s", registry)
}

npmCred := npmRegistryCredentials{
Expand Down Expand Up @@ -86,20 +87,10 @@ func (h *NPMRegistryHandler) HandleRequest(req *http.Request, ctx *goproxy.Proxy
}

// Try OIDC credentials first
h.mutex.RLock()
oidcCred, hasOIDC := h.oidcCredentials[reqHost]
h.mutex.RUnlock()
authed := oidc.TryAuthOIDCRequestWithPrefix(&h.mutex, h.oidcCredentials, req, ctx)

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)
// Fall through to try static credentials
} else {
logging.RequestLogf(ctx, "* authenticating npm registry request with OIDC token (host: %s)", reqHost)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
return req, nil
}
if authed {
return req, nil
Comment on lines 89 to +93
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.

TryAuthOIDCRequestWithPrefix stops at the first matching map entry; because Go map iteration order is randomized, behavior becomes non-deterministic if multiple OIDC keys match the same request (e.g., one credential configured for the host root and another for a more specific path prefix on that host). To ensure the most specific registry wins (and avoid intermittent “wrong token” selection), consider selecting the longest/most-specific matching key rather than breaking on the first match.

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.

I'm not going to touch this as it would have broader impacts on other repo types that I don't have the knowledge to reason about the implications of. I don't think having a repo as a sub-path of another repo is going to be valid in many situations so this is unlikely to impact anyone in the real world. I think it should be changed for correctness if it's not going to break any of the other repo types.

}

// Fall back to static credentials
Expand Down
87 changes: 87 additions & 0 deletions internal/handlers/npm_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package handlers

import (
"fmt"
"net/http"
"net/http/httptest"
"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 @@ -87,3 +90,87 @@ 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
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "http://oidc-url")
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "oidc-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")
}
6 changes: 3 additions & 3 deletions internal/handlers/oidc_handling_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) {
},
urlMocks: []mockHttpRequest{},
expectedLogLines: []string{
"registered aws OIDC credentials for npm registry: npm.example.com",
"registered aws OIDC credentials for npm registry: https://npm.example.com",
},
urlsToAuthenticate: []string{
"https://npm.example.com/some-package",
Expand All @@ -581,7 +581,7 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) {
},
urlMocks: []mockHttpRequest{},
expectedLogLines: []string{
"registered azure OIDC credentials for npm registry: npm.example.com",
"registered azure OIDC credentials for npm registry: https://npm.example.com",
},
urlsToAuthenticate: []string{
"https://npm.example.com/some-package",
Expand All @@ -602,7 +602,7 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) {
},
urlMocks: []mockHttpRequest{},
expectedLogLines: []string{
"registered jfrog OIDC credentials for npm registry: jfrog.example.com",
"registered jfrog OIDC credentials for npm registry: https://jfrog.example.com",
},
urlsToAuthenticate: []string{
"https://jfrog.example.com/some-package",
Expand Down
Loading