Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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: 12 additions & 24 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,17 @@ 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("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 create OIDC credential for npm registry: %s", 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.

This log message is misleading: the OIDC credential was created successfully; the failure here is parsing maybeUrl for use as a map key. Also, the log omits the parse error and always prints registry even when url was provided (which can result in an empty/incorrect value). Log the actual URL string and the parse error so it’s actionable.

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

@microblag microblag Mar 26, 2026

Choose a reason for hiding this comment

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

i improved this error message

}

npmCred := npmRegistryCredentials{
Expand Down Expand Up @@ -86,20 +84,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
105 changes: 105 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 @@ -40,6 +43,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 +108,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