diff --git a/AGENTS.md b/AGENTS.md index e1402be0eb..b9aa97aa4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,23 @@ Prefer `make` targets at repo root: - **BDD tests**: run `cd tests-bdd && go test ./...` (requires Docker; feature files are `tests-bdd/features/*.feature`). - **Integration tests** may require the compose stack; follow module README(s) under `service/`. - **README tests**: verify code examples in documentation compile and work correctly. +- **Cross-SDK e2e (xtest)**: the `opentdf/tests` repo runs the platform against the go/java/js SDKs. Use it to validate cross-language behavior (e.g. DPoP, TDF interop) that unit tests can't cover. + +### Running xtest on a branch + +xtest lives in `opentdf/tests` and is triggered with `gh workflow run`. Push your branch first, then point each `*-ref` input at the branch to test (use the default branch for components you didn't change). Example — testing a platform + otdfctl branch against the standard java/web SDK branches: + +```bash +gh workflow run xtest.yml \ + --repo opentdf/tests \ + --ref main \ + -f platform-ref=my-platform-branch \ + -f otdfctl-ref=my-platform-branch \ + -f java-ref=main \ + -f js-ref=main +``` + +The command prints the run URL. Poll it with `gh run view --repo opentdf/tests`, and read a job's logs with `gh run view --job= --repo opentdf/tests --log`. When checking a specific feature, confirm its tests actually ran and were not `SKIPPED` (grep the log for the test file, e.g. `test_dpop.py`). ## Commit & Pull Request Guidelines diff --git a/otdfctl/cmd/root.go b/otdfctl/cmd/root.go index 89ab6f9fcb..c277b4e13d 100644 --- a/otdfctl/cmd/root.go +++ b/otdfctl/cmd/root.go @@ -32,6 +32,9 @@ type version struct { BuildTime string `json:"build_time"` SDKVersion string `json:"sdk_version"` SchemaVersion string `json:"schema_version"` + // SupportedFeatures adds sdk.SupportedFeatures() to `--version --json`. + // This allows integrators (and opentdf/tests/xtest) to detect optional, experimental, or removed capabilities. + SupportedFeatures []string `json:"supported_features"` } func init() { @@ -40,12 +43,13 @@ func init() { if c.Flags.GetOptionalBool("version") { v := version{ - AppName: config.AppName, - Version: config.Version, - CommitSha: config.CommitSha, - BuildTime: config.BuildTime, - SDKVersion: sdk.Version, - SchemaVersion: sdk.TDFSpecVersion, + AppName: config.AppName, + Version: config.Version, + CommitSha: config.CommitSha, + BuildTime: config.BuildTime, + SDKVersion: sdk.Version, + SchemaVersion: sdk.TDFSpecVersion, + SupportedFeatures: sdk.SupportedFeatures(), } version := fmt.Sprintf("%s version %s (%s) %s", config.AppName, config.Version, config.BuildTime, config.CommitSha) diff --git a/otdfctl/pkg/auth/auth.go b/otdfctl/pkg/auth/auth.go index bd505d88ab..dadafa4947 100644 --- a/otdfctl/pkg/auth/auth.go +++ b/otdfctl/pkg/auth/auth.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net" + "net/http" "net/url" "os" "strconv" @@ -184,7 +185,9 @@ func ValidateProfileAuthCredentials(ctx context.Context, profile *profiles.Otdfc case "": return ErrProfileCredentialsNotFound case profiles.AuthTypeClientCredentials: - _, err := GetTokenWithClientCreds(ctx, profile.GetEndpoint(), c.ClientID, c.ClientSecret, profile.GetTLSNoVerify(), c.Scopes) + // Validation exercises the DPoP-bound path so it succeeds against a + // DPoP-enforcing token endpoint; the token is discarded. + _, err := GetTokenWithClientCredsDPoP(ctx, profile.GetEndpoint(), c.ClientID, c.ClientSecret, profile.GetTLSNoVerify(), c.Scopes) if err != nil { return err } @@ -204,6 +207,8 @@ func GetTokenWithProfile(ctx context.Context, profile *profiles.OtdfctlProfileSt switch c.AuthType { case profiles.AuthTypeClientCredentials: + // print or reuse path: return a plain bearer token (not DPoP sender-constrained) + // so it stays usable outside otdfctl. See DSPX-3998. return GetTokenWithClientCreds(ctx, profile.GetEndpoint(), c.ClientID, c.ClientSecret, profile.GetTLSNoVerify(), c.Scopes) case profiles.AuthTypeAccessToken: return buildToken(&c), nil @@ -212,12 +217,30 @@ func GetTokenWithProfile(ctx context.Context, profile *profiles.OtdfctlProfileSt } } -// Uses the OAuth2 client credentials flow to obtain a token. +// GetTokenWithClientCreds uses the OAuth2 client credentials flow to obtain a +// bearer token with no sender constraint. func GetTokenWithClientCreds(ctx context.Context, endpoint string, clientID string, clientSecret string, tlsNoVerify bool, scopes []string) (*oauth2.Token, error) { + return getTokenWithClientCreds(ctx, endpoint, clientID, clientSecret, tlsNoVerify, scopes, false) +} + +// GetTokenWithClientCredsDPoP uses sender-constrained tokens, which are not currently exportable. +func GetTokenWithClientCredsDPoP(ctx context.Context, endpoint string, clientID string, clientSecret string, tlsNoVerify bool, scopes []string) (*oauth2.Token, error) { + return getTokenWithClientCreds(ctx, endpoint, clientID, clientSecret, tlsNoVerify, scopes, true) +} + +func getTokenWithClientCreds(ctx context.Context, endpoint string, clientID string, clientSecret string, tlsNoVerify bool, scopes []string, dpop bool) (*oauth2.Token, error) { + httpClient := utils.NewHTTPClient(tlsNoVerify) + if dpop { + var err error + httpClient, err = sdk.NewDPoPValidationHTTPClient(httpClient) + if err != nil { + return nil, err + } + } rp, err := newOidcRelyingParty(ctx, endpoint, tlsNoVerify, oidcClientCredentials{ clientID: clientID, clientSecret: clientSecret, - }) + }, httpClient) if err != nil { return nil, err } @@ -337,14 +360,14 @@ func RevokeAccessToken(ctx context.Context, endpoint, clientID, refreshToken str rp, err := newOidcRelyingParty(ctx, endpoint, tlsNoVerify, oidcClientCredentials{ clientID: clientID, isPublic: true, - }) + }, utils.NewHTTPClient(tlsNoVerify)) if err != nil { return err } return oidcrp.RevokeToken(ctx, rp, refreshToken, "refresh_token") } -func newOidcRelyingParty(ctx context.Context, endpoint string, tlsNoVerify bool, clientCreds oidcClientCredentials) (oidcrp.RelyingParty, error) { +func newOidcRelyingParty(ctx context.Context, endpoint string, tlsNoVerify bool, clientCreds oidcClientCredentials, httpClient *http.Client) (oidcrp.RelyingParty, error) { if clientCreds.clientID == "" { return nil, errors.New("client ID is required") } @@ -367,6 +390,6 @@ func newOidcRelyingParty(ctx context.Context, endpoint string, tlsNoVerify bool, clientCreds.clientSecret, "", nil, - oidcrp.WithHTTPClient(utils.NewHTTPClient(tlsNoVerify)), + oidcrp.WithHTTPClient(httpClient), ) } diff --git a/sdk/auth/dpop_transport.go b/sdk/auth/dpop_transport.go new file mode 100644 index 0000000000..11468828a8 --- /dev/null +++ b/sdk/auth/dpop_transport.go @@ -0,0 +1,465 @@ +package auth + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +// DPoPTransport wraps each go standard net/http RoundTripper request with DPoP (RFC 9449) +// proof tokens. These proofs are for both token endpoint (IdP, etc) calls and +// resource (i.e. KAS or policy service, for the SDK) endpoint calls, +// handling server-issued nonces with automatic retry. +type DPoPTransport struct { + // Base is the underlying transport. If nil, http.DefaultTransport is used. + Base http.RoundTripper + + // DPoPKey is the private key used to sign DPoP proofs. + DPoPKey jwk.Key + + // TokenSource provides access tokens for resource requests. + // For resource requests (any URL other than TokenEndpoint), the transport + // sets Authorization: DPoP and includes the ath claim binding the + // proof to the access token. Requests to TokenEndpoint get neither. + // + // When TokenSource also implements AccessTokenCredentialSource and reports a + // non-DPoP scheme for a resource request, the transport instead sets + // Authorization: Bearer and sends no DPoP proof, matching the + // credential interceptor so a bearer token source is not forced onto DPoP. + TokenSource AccessTokenSource + + // TokenEndpoint is the OAuth token endpoint URL. + // Requests to this endpoint are treated as token requests + // and do not include the ath claim. + // + // TokenEndpoint must not be mutated after the transport is first used: + // isTokenEndpointRequest caches the parsed URL (and NewDPoPHTTPClient + // pre-parses it at construction), so a later change would not take effect + // and would race with the cached read. + TokenEndpoint string + + // tokenFetchTimeout bounds the internal access-token fetch performed while + // adding the ath claim to resource requests. It mirrors the configured + // client's Timeout so a hung IdP cannot stall the request indefinitely. + tokenFetchTimeout time.Duration + + nonceOnce sync.Once + nonceMu sync.RWMutex + nonceCache map[string]string + cachedTokenURL *url.URL + cachedTokenURLStr string +} + +var _ http.RoundTripper = (*DPoPTransport)(nil) + +// RoundTrip implements http.RoundTripper, adding DPoP proofs to requests. +func (t *DPoPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if t.DPoPKey == nil { + return nil, errors.New("DPoP transport has no signing key") + } + + base := t.Base + if base == nil { + base = http.DefaultTransport + } + + // NewDPoPHTTPClient initializes the cache; this Once covers a directly + // constructed transport without paying a write-lock on every request. + t.nonceOnce.Do(t.initNonceCache) + + // Avoid modifying the original + req2 := cloneRequest(req) + + // Buffer the body and install GetBody on the clone so a DPoP-Nonce retry + // can replay it. ConnectRPC/gRPC clients set Body and ContentLength but + // not GetBody, so without this the retry path would send an empty body + // against a non-zero ContentLength and net/http would abort the request. + if err := bufferRequestBody(req2); err != nil { + return nil, err + } + + isTokenRequest := t.isTokenEndpointRequest(req2.URL) + + origin := getOrigin(req2.URL) + nonce := t.getCachedNonce(origin) + + if err := t.addDPoPProof(req2, base, nonce, isTokenRequest); err != nil { + return nil, fmt.Errorf("failed to add DPoP proof: %w", err) + } + + resp, err := base.RoundTrip(req2) + if err != nil { + return resp, err + } + + // Handle DPoP-Nonce challenge (RFC 9449 §8). + if resp.StatusCode == http.StatusUnauthorized || + (resp.StatusCode == http.StatusBadRequest && resp.Header.Get("DPoP-Nonce") != "") { + retryResp, retried, err := t.retryWithNonce(req2, base, resp, origin, nonce, isTokenRequest) + if err != nil { + return nil, err + } + if retried { + resp = retryResp + } + } + + // Handle DPoP-Nonce updates (RFC 9449 §8.2). + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + if newNonce := resp.Header.Get("DPoP-Nonce"); newNonce != "" { + t.setCachedNonce(origin, newNonce) + } + } + + return resp, nil +} + +// retryWithNonce handles a DPoP-Nonce server challenge. It returns the retried +// response and true when a retry was performed, or the original response and +// false when no retry was needed. +// +// A retry happens once per request whenever the server supplies a DPoP-Nonce +// that differs from the one we already sent (RFC 9449 §8). Requiring a *different* +// nonce both covers the initial challenge (we sent none) and a server that rotates +// its nonce after previously accepting one, while preventing a retry loop when the +// server keeps returning the same nonce we just used. The single retry is returned +// as-is even if it is itself a 401. +func (t *DPoPTransport) retryWithNonce( + req *http.Request, base http.RoundTripper, + resp *http.Response, origin, nonce string, isTokenRequest bool, +) (*http.Response, bool, error) { + newNonce := resp.Header.Get("DPoP-Nonce") + if newNonce == "" || newNonce == nonce { + return resp, false, nil + } + + t.setCachedNonce(origin, newNonce) + + // A one-shot body (streaming / unknown length) was consumed by the first + // attempt and cannot be replayed; cache the nonce for the next request but + // return the 401 rather than resending an empty body. + if req.Body != nil && req.Body != http.NoBody && req.GetBody == nil { + return resp, false, nil + } + + resp.Body.Close() + + req3 := cloneRequest(req) + if req.GetBody != nil { + body, err := req.GetBody() + if err != nil { + return nil, false, fmt.Errorf("failed to reset request body for retry: %w", err) + } + req3.Body = body + } + + if err := t.addDPoPProof(req3, base, newNonce, isTokenRequest); err != nil { + return nil, false, fmt.Errorf("failed to add DPoP proof with nonce: %w", err) + } + + retryResp, err := base.RoundTrip(req3) + return retryResp, true, err +} + +// addDPoPProof generates and adds DPoP proof to the request headers. +// +// For a resource request it first resolves the access token and its scheme. A +// bearer token source (one whose AccessTokenCredentialSource reports a non-DPoP +// scheme) short-circuits to Authorization: Bearer with no proof, mirroring the +// credential interceptor so it is not forced onto DPoP. Otherwise the proof is +// bound to the token via the ath claim and Authorization: DPoP is set. +func (t *DPoPTransport) addDPoPProof(req *http.Request, base http.RoundTripper, nonce string, isTokenRequest bool) error { + // Resolve the resource-request credential up front so a bearer token source + // short-circuits before any proof is built. Token-endpoint requests skip this: + // they always carry a proof (no ath) regardless of the eventual token scheme. + var credential AccessTokenCredential + if !isTokenRequest && t.TokenSource != nil { + var err error + credential, err = t.resourceCredential(req.Context(), base) + if err != nil { + return fmt.Errorf("failed to get access token: %w", err) + } + if credential.Type != TokenTypeDPoP { + // A bearer token is not sender-constrained: send no ath and no proof. + // Drop any inherited DPoP header so a bearer request never carries a + // stale proof on a retry clone. + req.Header.Del("DPoP") + req.Header.Set("Authorization", "Bearer "+string(credential.Token)) + return nil + } + } + + // Normalize the htu (RFC 9449 HTTP URI Normalization) + htu := normalizeURI(req.URL) + + // Build base proof claims + builder := jwt.NewBuilder(). + Claim("jti", uuid.NewString()). + Claim("htm", req.Method). + Claim("htu", htu). + IssuedAt(time.Now()) + + // Add nonce if provided + if nonce != "" { + builder = builder.Claim("nonce", nonce) + } + + // For resource requests (not token endpoint), bind the proof to the access + // token via the ath claim. + accessToken := string(credential.Token) + if !isTokenRequest && t.TokenSource != nil { + // Calculate ath = base64url(SHA-256(access_token)) + h := sha256.New() + h.Write([]byte(accessToken)) + ath := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + builder = builder.Claim("ath", ath) + } + + token, err := builder.Build() + if err != nil { + return fmt.Errorf("failed to build DPoP token: %w", err) + } + + publicKey, err := t.DPoPKey.PublicKey() + if err != nil { + return fmt.Errorf("failed to get public key: %w", err) + } + + headers := jws.NewHeaders() + if err := headers.Set(jws.JWKKey, publicKey); err != nil { + return fmt.Errorf("failed to set jwk header: %w", err) + } + if err := headers.Set(jws.TypeKey, "dpop+jwt"); err != nil { + return fmt.Errorf("failed to set typ header: %w", err) + } + if err := headers.Set(jws.AlgorithmKey, t.DPoPKey.Algorithm()); err != nil { + return fmt.Errorf("failed to set alg header: %w", err) + } + + signedToken, err := jwt.Sign(token, jwt.WithKey(t.DPoPKey.Algorithm(), t.DPoPKey, jws.WithProtectedHeaders(headers))) + if err != nil { + return fmt.Errorf("failed to sign DPoP token: %w", err) + } + + req.Header.Set("DPoP", string(signedToken)) + + // For resource requests, set Authorization header + if !isTokenRequest && accessToken != "" { + req.Header.Set("Authorization", "DPoP "+accessToken) + } + + return nil +} + +// resourceCredential resolves the access token and its authentication scheme for +// a resource request. When TokenSource implements AccessTokenCredentialSource the +// IdP-granted scheme is honored, so a bearer token source yields TokenTypeBearer. +// Otherwise the token is treated as DPoP sender-constrained, preserving the SDK's +// original transport behavior and matching the credential interceptor's default. +func (t *DPoPTransport) resourceCredential(ctx context.Context, base http.RoundTripper) (AccessTokenCredential, error) { + client := &http.Client{Transport: base, Timeout: t.tokenFetchTimeout} + if cs, ok := t.TokenSource.(AccessTokenCredentialSource); ok { + return cs.AccessTokenCredential(ctx, client) + } + token, err := t.TokenSource.AccessToken(ctx, client) + if err != nil { + return AccessTokenCredential{}, err + } + return AccessTokenCredential{Token: token, Type: TokenTypeDPoP}, nil +} + +// isTokenEndpointRequest checks if the URL matches the configured token endpoint. +func (t *DPoPTransport) isTokenEndpointRequest(u *url.URL) bool { + if t.TokenEndpoint == "" { + return false + } + + t.nonceMu.RLock() + cachedURL := t.cachedTokenURL + cachedStr := t.cachedTokenURLStr + t.nonceMu.RUnlock() + + if cachedStr != t.TokenEndpoint { + t.nonceMu.Lock() + if t.cachedTokenURLStr != t.TokenEndpoint { + parsed, err := url.Parse(t.TokenEndpoint) + if err == nil { + t.cachedTokenURL = parsed + t.cachedTokenURLStr = t.TokenEndpoint + } else { + t.cachedTokenURL = nil + t.cachedTokenURLStr = "" + } + } + cachedURL = t.cachedTokenURL + t.nonceMu.Unlock() + } + + if cachedURL == nil { + return false + } + + return normalizeURI(u) == normalizeURI(cachedURL) +} + +// normalizedHostPort returns the URL host lowercased with the scheme's default +// port (80 for http, 443 for https) removed. IPv6 literals keep their brackets. +func normalizedHostPort(u *url.URL) string { + scheme := strings.ToLower(u.Scheme) + host := strings.ToLower(u.Hostname()) + if strings.Contains(host, ":") { + host = "[" + host + "]" // re-bracket IPv6 literal stripped by Hostname() + } + + port := u.Port() + if port == "" || + (scheme == "http" && port == "80") || + (scheme == "https" && port == "443") { + return host + } + + return host + ":" + port +} + +// normalizeURI normalizes the URI per RFC 9449 HTTP URI Normalization: +// - Lowercase scheme and host +// - Remove default ports (80 for http, 443 for https) +// - Normalize an empty HTTP path to "/" +// - Strip query and fragment +// +// The path uses EscapedPath so percent-encoded reserved bytes (e.g. %2F) are +// preserved verbatim in the htu claim; u.Path would decode them and change the URI. +func normalizeURI(u *url.URL) string { + escapedPath := u.EscapedPath() + if escapedPath == "" { + escapedPath = "/" + } + return fmt.Sprintf("%s://%s%s", strings.ToLower(u.Scheme), normalizedHostPort(u), escapedPath) +} + +// getOrigin returns the origin (scheme://host:port) from a URL, normalized to +// lowercase with the scheme's default port removed. +func getOrigin(u *url.URL) string { + return fmt.Sprintf("%s://%s", strings.ToLower(u.Scheme), normalizedHostPort(u)) +} + +// initNonceCache lazily allocates the per-origin nonce cache. It is idempotent +// and safe to call once via nonceOnce even when the constructor already set it. +func (t *DPoPTransport) initNonceCache() { + t.nonceMu.Lock() + defer t.nonceMu.Unlock() + if t.nonceCache == nil { + t.nonceCache = make(map[string]string) + } +} + +// getCachedNonce retrieves the cached nonce for an origin. +func (t *DPoPTransport) getCachedNonce(origin string) string { + t.nonceMu.RLock() + defer t.nonceMu.RUnlock() + return t.nonceCache[origin] +} + +// setCachedNonce stores a nonce for an origin. +func (t *DPoPTransport) setCachedNonce(origin, nonce string) { + t.nonceMu.Lock() + defer t.nonceMu.Unlock() + t.nonceCache[origin] = nonce +} + +// cloneRequest creates a shallow clone of the request. +func cloneRequest(req *http.Request) *http.Request { + req2 := req.Clone(req.Context()) + // Clone headers to avoid modifying the original + req2.Header = req.Header.Clone() + return req2 +} + +// bufferRequestBody reads req.Body into SDK-owned memory and replaces both Body +// and GetBody on req so the body can be replayed safely on retry. +// +// This transport is intended for bounded internal RPC request bodies. Go also +// treats ContentLength == 0 with a non-nil body as unknown, but such bodies are +// intentionally buffered to EOF here so nonce retries remain transparent. +// Callers must not pass an unbounded body in that form. ConnectRPC streaming +// requests use ContentLength < 0 and are left untouched, so they cannot be +// retried after a nonce challenge. +func bufferRequestBody(req *http.Request) error { + if req.Body == nil || req.Body == http.NoBody || req.ContentLength < 0 { + return nil + } + buf := bytes.NewBuffer(make([]byte, 0, req.ContentLength)) + _, readErr := buf.ReadFrom(req.Body) + closeErr := req.Body.Close() + if readErr != nil { + return fmt.Errorf("buffering DPoP request body: %w", readErr) + } + if closeErr != nil { + return fmt.Errorf("closing DPoP request body: %w", closeErr) + } + data := buf.Bytes() + req.Body = io.NopCloser(bytes.NewReader(data)) + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + } + return nil +} + +// NewDPoPHTTPClient creates a new HTTP client with DPoP transport wrapping. +// The client will automatically add DPoP proofs to all requests. +// +// It returns an error when tokenEndpoint is non-empty but cannot be parsed: an +// unparseable endpoint would otherwise make token-endpoint requests silently +// misclassified as resource requests (adding an ath claim and Authorization +// header to the token exchange itself). +func NewDPoPHTTPClient(baseClient *http.Client, dpopKey jwk.Key, tokenSource AccessTokenSource, tokenEndpoint string) (*http.Client, error) { + if baseClient == nil { + baseClient = http.DefaultClient + } + + transport := baseClient.Transport + if transport == nil { + transport = http.DefaultTransport + } + + dpopTransport := &DPoPTransport{ + Base: transport, + DPoPKey: dpopKey, + TokenSource: tokenSource, + TokenEndpoint: tokenEndpoint, + tokenFetchTimeout: baseClient.Timeout, + nonceCache: make(map[string]string), + } + + // Validate and cache the parsed endpoint up front so isTokenEndpointRequest + // never has to swallow a parse error at request time. + if tokenEndpoint != "" { + parsed, err := url.Parse(tokenEndpoint) + if err != nil { + return nil, fmt.Errorf("invalid DPoP token endpoint %q: %w", tokenEndpoint, err) + } + dpopTransport.cachedTokenURL = parsed + dpopTransport.cachedTokenURLStr = tokenEndpoint + } + + return &http.Client{ + Transport: dpopTransport, + CheckRedirect: baseClient.CheckRedirect, + Jar: baseClient.Jar, + Timeout: baseClient.Timeout, + }, nil +} diff --git a/sdk/auth/dpop_transport_test.go b/sdk/auth/dpop_transport_test.go new file mode 100644 index 0000000000..77caf657d7 --- /dev/null +++ b/sdk/auth/dpop_transport_test.go @@ -0,0 +1,1086 @@ +package auth + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/opentdf/platform/protocol/go/kas" + "github.com/opentdf/platform/protocol/go/kas/kasconnect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockTokenSource implements AccessTokenSource for testing +type mockTokenSource struct { + token string + err error +} + +func (m *mockTokenSource) AccessToken(_ context.Context, _ *http.Client) (AccessToken, error) { + if m.err != nil { + return "", m.err + } + return AccessToken(m.token), nil +} + +func (m *mockTokenSource) MakeToken(_ func(jwk.Key) ([]byte, error)) ([]byte, error) { + // Not used in transport tests + return nil, nil +} + +// mockCredentialTokenSource additionally implements AccessTokenCredentialSource so +// tests can exercise the transport's handling of a caller-declared token scheme +// (Bearer vs. DPoP), matching the SDK's real IdP token sources. +type mockCredentialTokenSource struct { + mockTokenSource + tokenType TokenType +} + +func (m *mockCredentialTokenSource) AccessTokenCredential(_ context.Context, _ *http.Client) (AccessTokenCredential, error) { + if m.err != nil { + return AccessTokenCredential{}, m.err + } + return AccessTokenCredential{Token: AccessToken(m.token), Type: m.tokenType}, nil +} + +// generateTestKey returns the SDK's production-default signing key (ES256/P-256) +// so the primary tests exercise the same proof path shipped by default. RSA and +// the other supported families are covered by generateTestKeyForAlg. +func generateTestKey(t *testing.T) jwk.Key { + t.Helper() + return generateTestKeyForAlg(t, jwa.ES256) +} + +// generateTestKeyForAlg generates a signing key for the given JWS algorithm so +// tests can exercise the EC (ES*) proof path in addition to RSA. +func generateTestKeyForAlg(t *testing.T, alg jwa.SignatureAlgorithm) jwk.Key { + t.Helper() + + var raw any + var err error + switch alg { //nolint:exhaustive // test helper supports only the algorithms it needs; default rejects the rest + case jwa.RS256: + raw, err = rsa.GenerateKey(rand.Reader, 2048) + case jwa.ES256: + raw, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case jwa.ES384: + raw, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + default: + t.Fatalf("unsupported test algorithm %q", alg) + } + require.NoError(t, err, "failed to generate raw key") + + key, err := jwk.FromRaw(raw) + require.NoError(t, err, "failed to create JWK") + require.NoError(t, key.Set(jwk.AlgorithmKey, alg), "failed to set algorithm") + + return key +} + +// parseDPoPProof verifies a proof against key and returns the parsed token. The +// verification algorithm is taken from the key so EC (ES*) proofs are exercised +// as well as RSA. It uses assert (not require) and returns ok=false on failure: +// it runs inside httptest server goroutines, where require's FailNow would call +// runtime.Goexit on the wrong goroutine instead of failing the test cleanly. +func parseDPoPProof(t *testing.T, proofStr string, key jwk.Key) (jwt.Token, bool) { + t.Helper() + + token, err := jwt.Parse([]byte(proofStr), jwt.WithKey(key.Algorithm(), key)) + if !assert.NoError(t, err, "failed to parse DPoP proof") { + return nil, false + } + + return token, true +} + +func TestDPoPTransport_AddsProofToRequests(t *testing.T) { + // Run the full proof path against both the production-default EC key and RSA + // so a regression in either signing family is caught by a primary test. + for _, alg := range []jwa.SignatureAlgorithm{jwa.RS256, jwa.ES256} { + t.Run(alg.String(), func(t *testing.T) { + testDPoPAddsProofToRequests(t, generateTestKeyForAlg(t, alg)) + }) + } +} + +func testDPoPAddsProofToRequests(t *testing.T, key jwk.Key) { + t.Helper() + ts := &mockTokenSource{token: "test-access-token"} + + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + + // Verify DPoP header exists + dpopHeader := r.Header.Get("DPoP") + if !assert.NotEmpty(t, dpopHeader, "DPoP header not present") { + return + } + + // Verify Authorization header + authHeader := r.Header.Get("Authorization") + assert.True(t, strings.HasPrefix(authHeader, "DPoP "), "Authorization header = %q, want prefix 'DPoP '", authHeader) + + // Parse and verify the proof + publicKey, err := key.PublicKey() + if !assert.NoError(t, err, "failed to get public key") { + return + } + + token, ok := parseDPoPProof(t, dpopHeader, publicKey) + if !ok { + return + } + + // Check htm claim + htm, ok := token.Get("htm") + assert.True(t, ok && htm == "GET", "htm claim = %v, want 'GET'", htm) + + // Check htu claim (should be normalized) + htu, ok := token.Get("htu") + if assert.True(t, ok, "htu claim missing") { + htuStr, isStr := htu.(string) + assert.True(t, isStr, "htu claim not a string: %v", htu) + assert.NotEmpty(t, htuStr, "htu claim is empty") + } + + // Check ath claim (access token hash) + if ath, athOK := token.Get("ath"); assert.True(t, athOK, "ath claim missing") { + expectedHash := sha256.Sum256([]byte("test-access-token")) + expectedATH := base64.RawURLEncoding.EncodeToString(expectedHash[:]) + assert.Equal(t, expectedATH, ath, "ath claim") + } + + // Check jti claim + jti, jtiOK := token.Get("jti") + assert.True(t, jtiOK && jti != "", "jti claim missing or empty") + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + transport := &DPoPTransport{ + Base: http.DefaultTransport, + DPoPKey: key, + TokenSource: ts, + } + + client := &http.Client{Transport: transport} + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + require.NoError(t, err, "failed to create request") + + resp, err := client.Do(req) + require.NoError(t, err, "request failed") + defer resp.Body.Close() + + assert.True(t, called, "server handler was not called") +} + +// TestDPoPTransport_ProofAlgorithms exercises proof signing and verification for +// each supported asymmetric family, not just RSA. ES* is a distinct jwx signing +// path and ES256/P-256 is the SDK default, so a regression there would otherwise +// ship untested. +func TestDPoPTransport_ProofAlgorithms(t *testing.T) { + for _, alg := range []jwa.SignatureAlgorithm{jwa.RS256, jwa.ES256, jwa.ES384} { + t.Run(alg.String(), func(t *testing.T) { + key := generateTestKeyForAlg(t, alg) + ts := &mockTokenSource{token: "test-access-token"} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + publicKey, err := key.PublicKey() + if !assert.NoError(t, err, "failed to get public key") { + return + } + // Parsing verifies the signature with the EC/RSA public key, so a + // successful parse proves the proof was correctly signed for alg. + token, ok := parseDPoPProof(t, r.Header.Get("DPoP"), publicKey) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, hasATH := token.Get("ath") + assert.True(t, hasATH, "resource request proof should carry ath") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + transport := &DPoPTransport{Base: http.DefaultTransport, DPoPKey: key, TokenSource: ts} + client := &http.Client{Transport: transport} + + resp, err := client.Do(mustReq(t, server.URL)) + require.NoError(t, err, "request failed") + defer resp.Body.Close() + assert.Equalf(t, http.StatusOK, resp.StatusCode, "final status for %s", alg) + }) + } +} + +func TestDPoPTransport_NonceRetry(t *testing.T) { + // Exercise the nonce challenge/retry path on both signing families: the retry + // re-signs the proof, so an EC- or RSA-specific regression there would ship + // untested if only one algorithm ran. + for _, alg := range []jwa.SignatureAlgorithm{jwa.RS256, jwa.ES256} { + t.Run(alg.String(), func(t *testing.T) { + testDPoPNonceRetry(t, generateTestKeyForAlg(t, alg)) + }) + } +} + +func testDPoPNonceRetry(t *testing.T, key jwk.Key) { + t.Helper() + ts := &mockTokenSource{token: "test-token"} + + callCount := 0 + nonce := "test-nonce-12345" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + + dpopHeader := r.Header.Get("DPoP") + if !assert.NotEmpty(t, dpopHeader, "DPoP header not present") { + w.WriteHeader(http.StatusBadRequest) + return + } + + publicKey, err := key.PublicKey() + if !assert.NoError(t, err, "failed to get public key") { + return + } + + token, parsed := parseDPoPProof(t, dpopHeader, publicKey) + if !parsed { + return + } + + if callCount == 1 { + // First request should not have nonce + _, ok := token.Get("nonce") + assert.False(t, ok, "first request should not have nonce claim") + + // Send 401 with nonce challenge + w.Header().Set("DPoP-Nonce", nonce) + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Second request should have the nonce + if nonceVal, ok := token.Get("nonce"); assert.True(t, ok, "second request missing nonce claim") { + assert.Equal(t, nonce, nonceVal, "nonce claim") + } + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + transport := &DPoPTransport{ + Base: http.DefaultTransport, + DPoPKey: key, + TokenSource: ts, + } + + client := &http.Client{Transport: transport} + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + require.NoError(t, err, "failed to create request") + + resp, err := client.Do(req) + require.NoError(t, err, "request failed") + defer resp.Body.Close() + + assert.Equal(t, 2, callCount, "expected 2 calls (initial + retry)") + assert.Equal(t, http.StatusOK, resp.StatusCode, "final status") +} + +// TestDPoPTransport_NonceRetryReplaysBodyWithoutGetBody reproduces the failure +// path that ConnectRPC/gRPC clients hit: they set req.Body and ContentLength +// but never set req.GetBody. The first round trip consumes the body; without +// buffering, the nonce retry sends ContentLength=N with an empty body and the +// HTTP/1.x transport aborts with "ContentLength=N with Body length 0". +func TestDPoPTransport_NonceRetryReplaysBodyWithoutGetBody(t *testing.T) { + key := generateTestKey(t) + ts := &mockTokenSource{token: "test-token"} + + const expectedBody = `{"foo":"bar"}` + nonce := "test-nonce-12345" + var receivedBodies []string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if !assert.NoError(t, err, "call %d: read body", len(receivedBodies)+1) { + w.WriteHeader(http.StatusInternalServerError) + return + } + receivedBodies = append(receivedBodies, string(body)) + + if len(receivedBodies) == 1 { + w.Header().Set("DPoP-Nonce", nonce) + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + transport := &DPoPTransport{ + Base: http.DefaultTransport, + DPoPKey: key, + TokenSource: ts, + } + + client := &http.Client{Transport: transport} + + req, err := http.NewRequest(http.MethodPost, server.URL, nil) + require.NoError(t, err, "create request") + bodyBytes := []byte(expectedBody) + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + req.ContentLength = int64(len(bodyBytes)) + // GetBody intentionally NOT set — mirrors ConnectRPC/gRPC generated clients. + + resp, err := client.Do(req) + require.NoError(t, err, "request failed") + defer resp.Body.Close() + + require.Len(t, receivedBodies, 2, "expected 2 calls (initial + retry)") + for i, got := range receivedBodies { + assert.JSONEqf(t, expectedBody, got, "call %d body", i+1) + } + assert.Equal(t, http.StatusOK, resp.StatusCode, "final status") +} + +// TestDPoPTransport_NonceRetryReplaysConnectUnaryBody is the end-to-end +// regression for the bug that broke every body-bearing otdfctl/SDK call when +// the platform enables the DPoP-Nonce challenge (RFC 9449 §8): the nonce +// retry would re-issue the request with an exhausted body, and net/http +// would abort with "ContentLength=N with Body length 0". This exercises a +// real Connect-go unary client (the production code path), not a hand-built +// http.Request. +func TestDPoPTransport_NonceRetryReplaysConnectUnaryBody(t *testing.T) { + key := generateTestKey(t) + ts := &mockTokenSource{token: "test-token"} + + const nonce = "test-nonce-12345" + var ( + mu sync.Mutex + receivedBodies [][]byte + ) + + mux := http.NewServeMux() + mux.HandleFunc("/kas.AccessService/PublicKey", func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if !assert.NoError(t, err, "read body") { + w.WriteHeader(http.StatusInternalServerError) + return + } + + mu.Lock() + receivedBodies = append(receivedBodies, append([]byte(nil), body...)) + callNum := len(receivedBodies) + mu.Unlock() + + if callNum == 1 { + w.Header().Set("DPoP-Nonce", nonce) + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", r.Header.Get("Content-Type")) + w.WriteHeader(http.StatusOK) + }) + server := httptest.NewServer(mux) + defer server.Close() + + httpClient := &http.Client{Transport: &DPoPTransport{ + Base: http.DefaultTransport, + DPoPKey: key, + TokenSource: ts, + }} + + client := kasconnect.NewAccessServiceClient(httpClient, server.URL) + + // A non-trivial body — mirrors what otdfctl sends for any unary RPC with + // payload (e.g. policy attributes value key assign, KAS Rewrap). + resp, err := client.PublicKey(context.Background(), connect.NewRequest(&kas.PublicKeyRequest{ + Algorithm: "rsa:2048", + Fmt: "pem", + })) + require.NoError(t, err, "unary call failed") + require.NotNil(t, resp, "nil response") + + mu.Lock() + defer mu.Unlock() + + require.Len(t, receivedBodies, 2, "expected 2 calls (initial + retry)") + require.NotEmpty(t, receivedBodies[0], "first call body was empty — Connect-go did not send a payload") + assert.Equal(t, receivedBodies[0], receivedBodies[1], "retry body differs from initial body") +} + +func TestDPoPTransport_URINormalization(t *testing.T) { + tests := []struct { + name string + url string + expected string + }{ + { + name: "https default port", + url: "https://example.com:443/path", + expected: "https://example.com/path", + }, + { + name: "http default port", + url: "http://example.com:80/path", + expected: "http://example.com/path", + }, + { + name: "https non-default port", + url: "https://example.com:8443/path", + expected: "https://example.com:8443/path", + }, + { + name: "uppercase scheme and host", + url: "HTTPS://EXAMPLE.COM/Path", + expected: "https://example.com/Path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key := generateTestKey(t) + ts := &mockTokenSource{token: "test-token"} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + dpopHeader := r.Header.Get("DPoP") + publicKey, err := key.PublicKey() + if !assert.NoError(t, err, "failed to get public key") { + return + } + + token, ok := parseDPoPProof(t, dpopHeader, publicKey) + if !ok { + return + } + + htu, ok := token.Get("htu") + if !assert.True(t, ok, "htu claim missing") { + return + } + + // The htu should have normalized the URL + htuStr, isStr := htu.(string) + if !assert.Truef(t, isStr, "htu claim is not a string: %T", htu) { + return + } + assert.Contains(t, htuStr, "/path", "htu = %s, want to contain normalized path", htuStr) + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + transport := &DPoPTransport{ + Base: http.DefaultTransport, + DPoPKey: key, + TokenSource: ts, + } + + client := &http.Client{Transport: transport} + + // Use the server URL but replace path + testURL := server.URL + "/path" + req, err := http.NewRequest(http.MethodGet, testURL, nil) + require.NoError(t, err, "failed to create request") + + resp, err := client.Do(req) + require.NoError(t, err, "request failed") + resp.Body.Close() + }) + } +} + +func TestDPoPTransport_TokenEndpointNoATH(t *testing.T) { + key := generateTestKey(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + dpopHeader := r.Header.Get("DPoP") + if !assert.NotEmpty(t, dpopHeader, "DPoP header not present") { + w.WriteHeader(http.StatusBadRequest) + return + } + + publicKey, err := key.PublicKey() + if !assert.NoError(t, err, "failed to get public key") { + return + } + + token, ok := parseDPoPProof(t, dpopHeader, publicKey) + if !ok { + return + } + + // Token endpoint requests should NOT have ath claim + _, hasATH := token.Get("ath") + assert.False(t, hasATH, "token endpoint request should not have ath claim") + + // Should not have Authorization header for token endpoint + assert.Empty(t, r.Header.Get("Authorization"), "token endpoint should not have Authorization header") + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + transport := &DPoPTransport{ + Base: http.DefaultTransport, + DPoPKey: key, + TokenSource: &mockTokenSource{token: "test-token"}, + TokenEndpoint: server.URL, + } + + client := &http.Client{Transport: transport} + req, err := http.NewRequest(http.MethodPost, server.URL, nil) + require.NoError(t, err, "failed to create request") + + resp, err := client.Do(req) + require.NoError(t, err, "request failed") + defer resp.Body.Close() +} + +// TestDPoPTransport_BearerTokenSourceSkipsProof verifies that a token source +// reporting a Bearer scheme (via AccessTokenCredentialSource) is honored on a +// resource request: the transport sends Authorization: Bearer and no DPoP proof, +// matching the credential interceptor rather than forcing the bearer token onto +// DPoP (which a bearer-token resource server would reject). +func TestDPoPTransport_BearerTokenSourceSkipsProof(t *testing.T) { + key := generateTestKey(t) + ts := &mockCredentialTokenSource{ + mockTokenSource: mockTokenSource{token: "bearer-access-token"}, + tokenType: TokenTypeBearer, + } + + var called int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&called, 1) + assert.Empty(t, r.Header.Get("DPoP"), "bearer request must not carry a DPoP proof") + assert.Equal(t, "Bearer bearer-access-token", r.Header.Get("Authorization"), "Authorization header") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + transport := &DPoPTransport{Base: http.DefaultTransport, DPoPKey: key, TokenSource: ts} + client := &http.Client{Transport: transport} + + resp, err := client.Do(mustReq(t, server.URL)) + require.NoError(t, err, "request failed") + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "final status") + assert.Equal(t, int32(1), atomic.LoadInt32(&called), "server should be called exactly once") +} + +// TestDPoPTransport_DPoPCredentialSourceSignsProof verifies that a credential +// source reporting the DPoP scheme keeps the full sender-constrained behavior: +// a proof with an ath claim and Authorization: DPoP. +func TestDPoPTransport_DPoPCredentialSourceSignsProof(t *testing.T) { + key := generateTestKey(t) + ts := &mockCredentialTokenSource{ + mockTokenSource: mockTokenSource{token: "dpop-access-token"}, + tokenType: TokenTypeDPoP, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.True(t, strings.HasPrefix(r.Header.Get("Authorization"), "DPoP "), "Authorization = %q, want DPoP scheme", r.Header.Get("Authorization")) + + publicKey, err := key.PublicKey() + if !assert.NoError(t, err, "failed to get public key") { + return + } + token, ok := parseDPoPProof(t, r.Header.Get("DPoP"), publicKey) + if !ok { + return + } + if ath, athOK := token.Get("ath"); assert.True(t, athOK, "DPoP request proof should carry ath") { + expected := sha256.Sum256([]byte("dpop-access-token")) + assert.Equal(t, base64.RawURLEncoding.EncodeToString(expected[:]), ath, "ath claim") + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + transport := &DPoPTransport{Base: http.DefaultTransport, DPoPKey: key, TokenSource: ts} + client := &http.Client{Transport: transport} + + resp, err := client.Do(mustReq(t, server.URL)) + require.NoError(t, err, "request failed") + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "final status") +} + +// TestDPoPTransport_BearerStripsInheritedProof verifies the bearer path drops any +// DPoP header already present on the request, so a retry clone (which inherits the +// prior attempt's headers) can never send a bearer token alongside a stale proof. +func TestDPoPTransport_BearerStripsInheritedProof(t *testing.T) { + key := generateTestKey(t) + transport := &DPoPTransport{ + Base: http.DefaultTransport, + DPoPKey: key, + TokenSource: &mockCredentialTokenSource{ + mockTokenSource: mockTokenSource{token: "bearer-access-token"}, + tokenType: TokenTypeBearer, + }, + } + + req, err := http.NewRequest(http.MethodGet, "https://example.com/resource", nil) + require.NoError(t, err, "create request") + req.Header.Set("DPoP", "stale-proof") + + require.NoError(t, transport.addDPoPProof(req, http.DefaultTransport, "", false), "addDPoPProof") + assert.Empty(t, req.Header.Get("DPoP"), "inherited DPoP proof should be stripped for a bearer request") + assert.Equal(t, "Bearer bearer-access-token", req.Header.Get("Authorization"), "Authorization header") +} + +func mustReq(t *testing.T, rawURL string) *http.Request { + t.Helper() + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + require.NoError(t, err, "create request") + return req +} + +// TestNormalizeURI exercises the RFC 9449 HTTP URI normalization directly so that +// default-port stripping, scheme/host lowercasing, and query/fragment removal are +// each asserted (the integration test only checks the path substring). +func TestNormalizeURI(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + {"https default port stripped", "https://example.com:443/path", "https://example.com/path"}, + {"http default port stripped", "http://example.com:80/path", "http://example.com/path"}, + {"https non-default port kept", "https://example.com:8443/path", "https://example.com:8443/path"}, + {"http non-default port kept", "http://example.com:8080/path", "http://example.com:8080/path"}, + {"scheme and host lowercased, path preserved", "HTTPS://EXAMPLE.COM/Path", "https://example.com/Path"}, + {"escaped reserved path preserved", "https://example.com/a%2Fb", "https://example.com/a%2Fb"}, + {"query and fragment dropped", "https://example.com/p?a=b#frag", "https://example.com/p"}, + {"empty path normalized to slash", "https://example.com", "https://example.com/"}, + {"uppercase host with default port", "HTTPS://EXAMPLE.COM:443/Path", "https://example.com/Path"}, + {"ipv6 default port stripped", "https://[::1]:443/path", "https://[::1]/path"}, + {"ipv6 non-default port kept", "https://[::1]:8443/path", "https://[::1]:8443/path"}, + {"ipv6 literal ending in 443 kept", "https://[fe80::443]/path", "https://[fe80::443]/path"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := url.Parse(tt.url) + require.NoErrorf(t, err, "parse %q", tt.url) + assert.Equalf(t, tt.want, normalizeURI(u), "normalizeURI(%q)", tt.url) + }) + } +} + +// TestGetOrigin exercises origin extraction, ensuring default ports are stripped +// (so a URL with an explicit :443 shares a nonce-cache key with one without) and +// IPv6 literals keep their brackets. +func TestGetOrigin(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + {"no port", "https://example.com/path", "https://example.com"}, + {"default https port stripped", "https://example.com:443/path", "https://example.com"}, + {"default http port stripped", "http://example.com:80/path", "http://example.com"}, + {"non-default port kept", "https://example.com:8443/path", "https://example.com:8443"}, + {"scheme and host lowercased", "HTTPS://EXAMPLE.COM:443/p", "https://example.com"}, + {"ipv6 default port stripped", "https://[::1]:443/path", "https://[::1]"}, + {"ipv6 non-default port kept", "https://[::1]:8443/path", "https://[::1]:8443"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := url.Parse(tt.url) + require.NoErrorf(t, err, "parse %q", tt.url) + assert.Equalf(t, tt.want, getOrigin(u), "getOrigin(%q)", tt.url) + }) + } +} + +// TestIsTokenEndpointRequest_PortNormalization confirms token-endpoint detection +// treats an explicit default port as equivalent to no port. +func TestIsTokenEndpointRequest_PortNormalization(t *testing.T) { + transport := &DPoPTransport{TokenEndpoint: "https://example.com/token"} + + withPort, err := url.Parse("https://example.com:443/token") + require.NoError(t, err, "parse url with port") + assert.True(t, transport.isTokenEndpointRequest(withPort), "explicit :443 should match configured endpoint") + + other, err := url.Parse("https://example.com/other") + require.NoError(t, err, "parse url with different path") + assert.False(t, transport.isTokenEndpointRequest(other), "different path should not match") +} + +// TestDPoPTransport_TokenSourceErrorAborts verifies that a token-fetch failure +// aborts the request with an error and never reaches the network — DPoP auth must +// fail closed rather than send a proof bound to no access token. +func TestDPoPTransport_TokenSourceErrorAborts(t *testing.T) { + key := generateTestKey(t) + ts := &mockTokenSource{err: errors.New("token fetch failed")} + + var called int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&called, 1) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + transport := &DPoPTransport{Base: http.DefaultTransport, DPoPKey: key, TokenSource: ts} + client := &http.Client{Transport: transport} + + resp, err := client.Do(mustReq(t, server.URL)) + if err == nil { + resp.Body.Close() + require.Fail(t, "expected error when token source fails, got nil") + } + assert.Zero(t, atomic.LoadInt32(&called), "server should not be called when token fetch fails") +} + +// TestDPoPTransport_NoRetryOnPlain401 verifies that a 401 without a DPoP-Nonce +// header (a genuine auth failure) is propagated unchanged and not retried. +func TestDPoPTransport_NoRetryOnPlain401(t *testing.T) { + key := generateTestKey(t) + var callCount int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + transport := &DPoPTransport{Base: http.DefaultTransport, DPoPKey: key, TokenSource: &mockTokenSource{token: "t"}} + client := &http.Client{Transport: transport} + + resp, err := client.Do(mustReq(t, server.URL)) + require.NoError(t, err, "request failed") + defer resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "status") + assert.Equal(t, int32(1), atomic.LoadInt32(&callCount), "expected exactly 1 call (no retry)") +} + +// TestDPoPTransport_TokenEndpointNonceRetryOn400 verifies the RFC 9449 §8 case +// for the authorization server: the token endpoint challenges a missing nonce +// with 400 (not 401) plus a DPoP-Nonce header, and the transport must retry once +// with the supplied nonce. TokenEndpoint is set so the request is treated as a +// token request (no ath claim), mirroring the otdfctl validation flow. +func TestDPoPTransport_TokenEndpointNonceRetryOn400(t *testing.T) { + key := generateTestKey(t) + const nonce = "as-issued-nonce" + + var callCount int32 + var serverURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&callCount, 1) + + publicKey, err := key.PublicKey() + if !assert.NoError(t, err, "failed to get public key") { + return + } + token, parsed := parseDPoPProof(t, r.Header.Get("DPoP"), publicKey) + if !parsed { + return + } + // Token-endpoint requests never carry an ath claim. + _, hasATH := token.Get("ath") + assert.False(t, hasATH, "token endpoint proof must not carry ath") + + if n == 1 { + _, ok := token.Get("nonce") + assert.False(t, ok, "first request should not carry a nonce") + w.Header().Set("DPoP-Nonce", nonce) + w.WriteHeader(http.StatusBadRequest) + return + } + if nonceVal, ok := token.Get("nonce"); assert.True(t, ok, "retry missing nonce claim") { + assert.Equal(t, nonce, nonceVal, "nonce claim") + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + serverURL = server.URL + + transport := &DPoPTransport{Base: http.DefaultTransport, DPoPKey: key, TokenEndpoint: serverURL} + client := &http.Client{Transport: transport} + + resp, err := client.Do(mustReq(t, serverURL)) + require.NoError(t, err, "request failed") + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "final status") + assert.Equal(t, int32(2), atomic.LoadInt32(&callCount), "expected 2 calls (initial 400 + retry)") +} + +// TestDPoPTransport_NilKeyErrors verifies the zero-value / direct-construction +// guard: RoundTrip returns an error instead of nil-panicking when DPoPKey is nil. +func TestDPoPTransport_NilKeyErrors(t *testing.T) { + transport := &DPoPTransport{Base: http.DefaultTransport} + client := &http.Client{Transport: transport} + + _, err := client.Do(mustReq(t, "http://example.com")) + require.Error(t, err, "expected an error when the DPoP key is nil") +} + +// TestNewDPoPHTTPClient_InvalidTokenEndpoint verifies the constructor rejects an +// unparseable token endpoint rather than silently misclassifying token requests. +func TestNewDPoPHTTPClient_InvalidTokenEndpoint(t *testing.T) { + key := generateTestKey(t) + + _, err := NewDPoPHTTPClient(nil, key, nil, "://missing-scheme") + require.Error(t, err, "expected an error for an unparseable token endpoint") +} + +// TestDPoPTransport_NoRetryOnPlain400 verifies that a 400 without a DPoP-Nonce +// header (an ordinary bad request) is propagated unchanged and not retried. +func TestDPoPTransport_NoRetryOnPlain400(t *testing.T) { + key := generateTestKey(t) + var callCount int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusBadRequest) + })) + defer server.Close() + + transport := &DPoPTransport{Base: http.DefaultTransport, DPoPKey: key, TokenSource: &mockTokenSource{token: "t"}} + client := &http.Client{Transport: transport} + + resp, err := client.Do(mustReq(t, server.URL)) + require.NoError(t, err, "request failed") + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "status") + assert.Equal(t, int32(1), atomic.LoadInt32(&callCount), "expected exactly 1 call (no retry)") +} + +// slowClientTokenSource fetches from url using the client the transport supplies, +// so a test can assert the transport applies its configured timeout to the +// internal access-token fetch. +type slowClientTokenSource struct{ url string } + +func (s *slowClientTokenSource) AccessToken(_ context.Context, client *http.Client) (AccessToken, error) { + resp, err := client.Get(s.url) + if err != nil { + return "", err + } + defer resp.Body.Close() + return AccessToken("token"), nil +} + +func (s *slowClientTokenSource) MakeToken(_ func(jwk.Key) ([]byte, error)) ([]byte, error) { + return nil, nil +} + +// TestDPoPTransport_TokenFetchHonorsClientTimeout verifies that NewDPoPHTTPClient +// propagates the base client's Timeout to the internal access-token fetch, so a +// hung IdP cannot stall a resource request indefinitely. +func TestDPoPTransport_TokenFetchHonorsClientTimeout(t *testing.T) { + key := generateTestKey(t) + + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(500 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer slow.Close() + + resource := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer resource.Close() + + base := &http.Client{Timeout: 50 * time.Millisecond} + client, err := NewDPoPHTTPClient(base, key, &slowClientTokenSource{url: slow.URL}, "") + require.NoError(t, err, "failed to build DPoP client") + + _, err = client.Do(mustReq(t, resource.URL)) + require.Error(t, err, "expected the token fetch to hit the configured timeout") +} + +// TestDPoPTransport_NoRetryWhenNonceUnchanged verifies the loop-prevention guard: +// when the server returns a 401 echoing the nonce the client already sent, the +// transport does not retry. +func TestDPoPTransport_NoRetryWhenNonceUnchanged(t *testing.T) { + key := generateTestKey(t) + const nonce = "stable-nonce" + var callCount int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + n := atomic.AddInt32(&callCount, 1) + w.Header().Set("DPoP-Nonce", nonce) + if n == 1 { + // Prime the client's nonce cache via a successful response. + w.WriteHeader(http.StatusOK) + return + } + // Second request already carries this nonce; returning it again must not retry. + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + transport := &DPoPTransport{Base: http.DefaultTransport, DPoPKey: key, TokenSource: &mockTokenSource{token: "t"}} + client := &http.Client{Transport: transport} + + resp1, err := client.Do(mustReq(t, server.URL)) + require.NoError(t, err, "first request failed") + resp1.Body.Close() + + resp2, err := client.Do(mustReq(t, server.URL)) + require.NoError(t, err, "second request failed") + resp2.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp2.StatusCode, "status") + assert.Equal(t, int32(2), atomic.LoadInt32(&callCount), "expected 2 calls (no retry on unchanged nonce)") +} + +// TestDPoPTransport_RetryOnRotatedNonce verifies that when the server rotates its +// nonce (returns a 401 with a nonce different from the cached one), the transport +// retries with the fresh nonce — the RFC 9449 §8 case the original guard missed. +func TestDPoPTransport_RetryOnRotatedNonce(t *testing.T) { + key := generateTestKey(t) + const ( + nonce1 = "nonce-1" + nonce2 = "nonce-2" + ) + var callCount int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch atomic.AddInt32(&callCount, 1) { + case 1: + w.Header().Set("DPoP-Nonce", nonce1) // prime cache via success + w.WriteHeader(http.StatusOK) + case 2: + w.Header().Set("DPoP-Nonce", nonce2) // rotate with a challenge + w.WriteHeader(http.StatusUnauthorized) + default: + pub, err := key.PublicKey() + assert.NoError(t, err, "public key") + tok, ok := parseDPoPProof(t, r.Header.Get("DPoP"), pub) + if !ok { + return + } + got, _ := tok.Get("nonce") + assert.Equalf(t, nonce2, got, "retry nonce") + w.WriteHeader(http.StatusOK) + } + })) + defer server.Close() + + transport := &DPoPTransport{Base: http.DefaultTransport, DPoPKey: key, TokenSource: &mockTokenSource{token: "t"}} + client := &http.Client{Transport: transport} + + resp1, err := client.Do(mustReq(t, server.URL)) + require.NoError(t, err, "first request failed") + resp1.Body.Close() + + resp2, err := client.Do(mustReq(t, server.URL)) + require.NoError(t, err, "second request failed") + resp2.Body.Close() + assert.Equal(t, http.StatusOK, resp2.StatusCode, "status after rotated-nonce retry") + assert.Equal(t, int32(3), atomic.LoadInt32(&callCount), "expected 3 calls (prime + challenge + retry)") +} + +// TestDPoPTransport_CachesNonceFromRetrySuccess verifies that a nonce the server +// rotates onto the successful *retry* response is cached and reused by the next +// request. An early return on the retry path would drop it, forcing a fresh +// 401/retry round-trip every time. +func TestDPoPTransport_CachesNonceFromRetrySuccess(t *testing.T) { + key := generateTestKey(t) + const ( + challengeNonce = "challenge-nonce" + rotatedNonce = "rotated-on-success" + ) + nonceOf := func(r *http.Request) string { + pub, err := key.PublicKey() + // assert (not require) because this runs on the httptest server goroutine, + // where t.FailNow via require is unsafe. + assert.NoError(t, err, "public key") //nolint:testifylint // require unsafe off the test goroutine + tok, ok := parseDPoPProof(t, r.Header.Get("DPoP"), pub) + if !ok { + return "" + } + got, _ := tok.Get("nonce") + s, _ := got.(string) + return s + } + var callCount int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch atomic.AddInt32(&callCount, 1) { + case 1: + // Initial request carries no nonce; challenge for one. + w.Header().Set("DPoP-Nonce", challengeNonce) + w.WriteHeader(http.StatusUnauthorized) + case 2: + // Retry carries the challenge nonce; succeed but rotate the nonce. + assert.Equal(t, challengeNonce, nonceOf(r), "retry nonce") + w.Header().Set("DPoP-Nonce", rotatedNonce) + w.WriteHeader(http.StatusOK) + default: + // Next request must reuse the nonce rotated on the successful retry. + assert.Equal(t, rotatedNonce, nonceOf(r), "next-request nonce") + w.WriteHeader(http.StatusOK) + } + })) + defer server.Close() + + transport := &DPoPTransport{Base: http.DefaultTransport, DPoPKey: key, TokenSource: &mockTokenSource{token: "t"}} + client := &http.Client{Transport: transport} + + resp1, err := client.Do(mustReq(t, server.URL)) + require.NoError(t, err, "first request failed") + resp1.Body.Close() + assert.Equal(t, http.StatusOK, resp1.StatusCode, "status after challenge retry") + + resp2, err := client.Do(mustReq(t, server.URL)) + require.NoError(t, err, "second request failed") + resp2.Body.Close() + assert.Equal(t, http.StatusOK, resp2.StatusCode, "status") + assert.Equal(t, int32(3), atomic.LoadInt32(&callCount), "expected 3 calls (challenge + retry + reuse)") +} + +// TestDPoPTransport_ConcurrentRequests drives many requests through one shared +// transport so the race detector covers the lazy nonce-map init and nonce cache. +func TestDPoPTransport_ConcurrentRequests(t *testing.T) { + key := generateTestKey(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("DPoP") == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("DPoP-Nonce", "rotating") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + transport := &DPoPTransport{Base: http.DefaultTransport, DPoPKey: key, TokenSource: &mockTokenSource{token: "t"}} + client := &http.Client{Transport: transport} + + const n = 16 + var wg sync.WaitGroup + errs := make(chan error, n) + for range n { + wg.Go(func() { + resp, err := client.Do(mustReq(t, server.URL)) + if err != nil { + errs <- err + return + } + resp.Body.Close() + }) + } + wg.Wait() + close(errs) + for err := range errs { + assert.NoError(t, err, "concurrent request failed") + } +} diff --git a/sdk/build_idp_token_source_test.go b/sdk/build_idp_token_source_test.go new file mode 100644 index 0000000000..b749bd7b4e --- /dev/null +++ b/sdk/build_idp_token_source_test.go @@ -0,0 +1,34 @@ +package sdk + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBuildIDPTokenSource_DPoPWithoutCredentialsErrors verifies that explicitly +// configuring a DPoP option without any credentials fails loudly rather than +// silently returning an uncredentialed (and therefore un-DPoP-bound) client. +func TestBuildIDPTokenSource_DPoPWithoutCredentialsErrors(t *testing.T) { + c := &config{} + WithDPoPAlgorithm(ES256)(c) + + ts, key, err := buildIDPTokenSource(c) + require.Error(t, err, "expected an error when DPoP is configured without credentials") + assert.Contains(t, err.Error(), "no client credentials") + assert.Nil(t, ts, "token source should be nil on error") + assert.Nil(t, key, "dpop key should be nil on error") +} + +// TestBuildIDPTokenSource_NoDPoPNoCredentialsIsUncredentialed verifies the +// legitimate uncredentialed case (e.g. consuming the well-known configuration) +// still returns a nil token source without error when no DPoP option is set. +func TestBuildIDPTokenSource_NoDPoPNoCredentialsIsUncredentialed(t *testing.T) { + c := &config{} + + ts, key, err := buildIDPTokenSource(c) + require.NoError(t, err) + assert.Nil(t, ts, "uncredentialed client should have a nil token source") + assert.Nil(t, key, "uncredentialed client should have a nil dpop key") +} diff --git a/sdk/dpop_key.go b/sdk/dpop_key.go new file mode 100644 index 0000000000..3c5197d4b7 --- /dev/null +++ b/sdk/dpop_key.go @@ -0,0 +1,272 @@ +package sdk + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "errors" + "fmt" + "slices" + "strings" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" +) + +// validDPoPAlgs is the single source of truth for the JWS algorithms permitted +// for DPoP proofs (RFC 9449 §4.2, restricted to the asymmetric families we +// support). generateDPoPKeyForAlg, validateDPoPKey, and the PEM algorithm +// override all consult it via SigningAlgorithm.Valid so the set cannot drift. +var validDPoPAlgs = []SigningAlgorithm{ES256, ES384, ES512, RS256, RS384, RS512} + +// Valid reports whether a is a SigningAlgorithm supported for DPoP proofs. +func (a SigningAlgorithm) Valid() bool { + return slices.Contains(validDPoPAlgs, a) +} + +// dpopAllowedAlgs is the human-readable allow-list used in error messages, +// derived from validDPoPAlgs so it can never drift from the enforced set. +var dpopAllowedAlgs = func() string { + names := make([]string, len(validDPoPAlgs)) + for i, a := range validDPoPAlgs { + names[i] = string(a) + } + return strings.Join(names, ", ") +}() + +// generateDPoPKeyForAlg generates an ephemeral DPoP private key for the given algorithm. +// Supported algorithms: ES256, ES384, ES512, RS256, RS384, RS512. +func generateDPoPKeyForAlg(alg SigningAlgorithm) (jwk.Key, error) { + switch alg { + case ES256: + return generateECDSAKey(elliptic.P256(), jwa.ES256) + case ES384: + return generateECDSAKey(elliptic.P384(), jwa.ES384) + case ES512: + return generateECDSAKey(elliptic.P521(), jwa.ES512) + case RS256: + return generateRSAKey(jwa.RS256) + case RS384: + return generateRSAKey(jwa.RS384) + case RS512: + return generateRSAKey(jwa.RS512) + default: + return nil, fmt.Errorf("unsupported DPoP algorithm %q; allowed: %s", alg, dpopAllowedAlgs) + } +} + +func generateECDSAKey(curve elliptic.Curve, alg jwa.SignatureAlgorithm) (jwk.Key, error) { + rawKey, err := ecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate ECDSA key: %w", err) + } + key, err := jwk.FromRaw(rawKey) + if err != nil { + return nil, fmt.Errorf("failed to create JWK from ECDSA key: %w", err) + } + if err := key.Set(jwk.AlgorithmKey, alg); err != nil { + return nil, fmt.Errorf("failed to set algorithm on ECDSA JWK: %w", err) + } + return key, nil +} + +func generateRSAKey(alg jwa.SignatureAlgorithm) (jwk.Key, error) { + const rsaBits = 2048 + rawKey, err := rsa.GenerateKey(rand.Reader, rsaBits) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA key: %w", err) + } + key, err := jwk.FromRaw(rawKey) + if err != nil { + return nil, fmt.Errorf("failed to create JWK from RSA key: %w", err) + } + if err := key.Set(jwk.AlgorithmKey, alg); err != nil { + return nil, fmt.Errorf("failed to set algorithm on RSA JWK: %w", err) + } + return key, nil +} + +// loadDPoPKeyFromPEM parses a PEM-encoded private key and returns it as a jwk.Key. +// The DPoP algorithm is inferred from the key type when the key does not already +// carry one: +// - EC P-256 → ES256, P-384 → ES384, P-521 → ES512 +// - RSA → RS256 +func loadDPoPKeyFromPEM(pemBytes []byte) (jwk.Key, error) { + key, err := jwk.ParseKey(pemBytes, jwk.WithPEM(true)) + if err != nil { + return nil, fmt.Errorf("failed to parse DPoP key PEM: %w", err) + } + + // A public-only PEM cannot sign proofs; reject it here with a clear message + // rather than letting inference or signing fail later. + if isPriv, err := jwk.IsPrivateKey(key); err != nil { + return nil, fmt.Errorf("failed to inspect DPoP key PEM: %w", err) + } else if !isPriv { + return nil, errors.New("DPoP key PEM must contain private signing material; a public-only key cannot sign proofs") + } + + // Infer algorithm when not already set in the PEM + if key.Algorithm() == jwa.NoSignature || key.Algorithm().String() == "" { + alg, err := inferDPoPAlgorithm(key) + if err != nil { + return nil, err + } + if err := key.Set(jwk.AlgorithmKey, alg); err != nil { + return nil, fmt.Errorf("failed to set inferred algorithm on DPoP JWK: %w", err) + } + } + + return key, nil +} + +func inferDPoPAlgorithm(key jwk.Key) (jwa.SignatureAlgorithm, error) { + switch key.KeyType() { //nolint:exhaustive // only EC and RSA are valid for DPoP (RFC 9449 §4.2) + case jwa.EC: + var rawKey ecdsa.PrivateKey + if err := key.Raw(&rawKey); err != nil { + return "", fmt.Errorf("failed to get raw EC key for algorithm inference: %w", err) + } + return ecCurveToDPoPAlg(rawKey.Curve) + case jwa.RSA: + return jwa.RS256, nil + default: + return "", fmt.Errorf("unsupported key type %q for DPoP; only EC and RSA keys are supported", key.KeyType()) + } +} + +// ecCurveToDPoPAlg maps an EC curve to its RFC 7518 ECDSA algorithm +// (P-256→ES256, P-384→ES384, P-521→ES512). +func ecCurveToDPoPAlg(curve elliptic.Curve) (jwa.SignatureAlgorithm, error) { + switch curve { + case elliptic.P256(): + return jwa.ES256, nil + case elliptic.P384(): + return jwa.ES384, nil + case elliptic.P521(): + return jwa.ES512, nil + default: + return "", errors.New("unsupported EC curve for DPoP") + } +} + +// validateDPoPKey ensures a resolved DPoP JWK can actually sign proofs, catching +// misconfiguration at resolution time instead of when the first proof is signed. +// It checks, in order: a supported algorithm is set, the key carries private +// signing material, the algorithm family matches the key type (ES* → EC, RS* → RSA), +// and — for EC keys — the curve matches the ES algorithm (P-256↔ES256, etc.). +func validateDPoPKey(key jwk.Key) error { + alg := key.Algorithm() + if alg == nil || alg.String() == "" { + return errors.New("DPoP JWK is missing required Algorithm field; set it with key.Set(jwk.AlgorithmKey, ...)") + } + algStr := alg.String() + if !SigningAlgorithm(algStr).Valid() { + return fmt.Errorf("unsupported DPoP JWK algorithm %q; allowed: %s", algStr, dpopAllowedAlgs) + } + + isPriv, err := jwk.IsPrivateKey(key) + if err != nil { + return fmt.Errorf("failed to inspect DPoP JWK: %w", err) + } + if !isPriv { + return errors.New("DPoP JWK must contain private signing material; a public-only key cannot sign proofs") + } + + switch { + case strings.HasPrefix(algStr, "ES"): + if key.KeyType() != jwa.EC { + return fmt.Errorf("DPoP algorithm %q requires an EC key, got key type %q", algStr, key.KeyType()) + } + var rawKey ecdsa.PrivateKey + if err := key.Raw(&rawKey); err != nil { + return fmt.Errorf("failed to read EC key for DPoP validation: %w", err) + } + wantAlg, err := ecCurveToDPoPAlg(rawKey.Curve) + if err != nil { + return err + } + if wantAlg.String() != algStr { + return fmt.Errorf("DPoP algorithm %q does not match EC key curve (expected %q for this curve)", algStr, wantAlg.String()) + } + case strings.HasPrefix(algStr, "RS"): + if key.KeyType() != jwa.RSA { + return fmt.Errorf("DPoP algorithm %q requires an RSA key, got key type %q", algStr, key.KeyType()) + } + } + return nil +} + +// resolveDPoPKey returns the jwk.Key to use for DPoP based on the config, using a +// single fixed priority: +// +// dpopJWK (WithDPoPJWK) → validate algorithm, return +// dpopKeyPEM (WithDPoPKeyPEM) → load from PEM, apply optional algorithm override +// dpopAlgorithm (WithDPoPAlgorithm) → generate a fresh ephemeral key +// dpopKey (WithSessionSignerRSA) → convert the RSA key pair to a JWK +// none configured → (nil, nil) +// +// The function is pure: it does not mutate the config. Because the dpopAlgorithm +// branch generates a new ephemeral key on every call, callers MUST resolve once +// and share the result between the token source and the DPoP transport. +// +// A (nil, nil) return means no DPoP key is configured; callers auto-generate a +// default ephemeral ES256/P-256 key in that case. +func resolveDPoPKey(c *config) (jwk.Key, error) { + key, err := selectDPoPKey(c) + if err != nil || key == nil { + return key, err + } + // Validate every resolved key uniformly so callers get a clear error at + // resolution time regardless of how the key was supplied (JWK, PEM, alg, or + // the RSA key pair), rather than a signing failure on the first proof. + if err := validateDPoPKey(key); err != nil { + return nil, err + } + return key, nil +} + +// selectDPoPKey picks the DPoP key from the config by fixed priority without +// validating it (see resolveDPoPKey). A (nil, nil) return means none configured. +// +//nolint:nilnil // nil key signals "no DPoP key configured" — not an error condition +func selectDPoPKey(c *config) (jwk.Key, error) { + switch { + case c.dpopJWK != nil: + return c.dpopJWK, nil + case len(c.dpopKeyPEM) > 0: + key, err := loadDPoPKeyFromPEM(c.dpopKeyPEM) + if err != nil { + return nil, fmt.Errorf("failed to load DPoP key from PEM: %w", err) + } + if c.dpopAlgorithm != "" { + // Restrict the override to the DPoP allow-list; jwa.Accept alone would + // admit any JWX algorithm (e.g. HS256, none). + if !c.dpopAlgorithm.Valid() { + return nil, fmt.Errorf("invalid DPoP algorithm override %q; allowed: %s", c.dpopAlgorithm, dpopAllowedAlgs) + } + var algVal jwa.SignatureAlgorithm + if err := algVal.Accept(string(c.dpopAlgorithm)); err != nil { + return nil, fmt.Errorf("invalid DPoP algorithm override %q: %w", c.dpopAlgorithm, err) + } + if err := key.Set(jwk.AlgorithmKey, algVal); err != nil { + return nil, fmt.Errorf("failed to apply DPoP algorithm override: %w", err) + } + } + return key, nil + case c.dpopAlgorithm != "": + key, err := generateDPoPKeyForAlg(c.dpopAlgorithm) + if err != nil { + return nil, fmt.Errorf("failed to generate DPoP key: %w", err) + } + return key, nil + case c.dpopKey != nil: + key, err := getDPoPJWK(c.dpopKey) + if err != nil { + return nil, fmt.Errorf("failed to create DPoP JWK: %w", err) + } + return key, nil + default: + return nil, nil + } +} diff --git a/sdk/dpop_key_test.go b/sdk/dpop_key_test.go new file mode 100644 index 0000000000..e7c1958026 --- /dev/null +++ b/sdk/dpop_key_test.go @@ -0,0 +1,279 @@ +package sdk + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "testing" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/opentdf/platform/lib/ocrypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// jwkToPEMForTest converts a jwk.Key to PEM for round-trip testing. +func jwkToPEMForTest(t *testing.T, key interface{ Raw(any) error }) []byte { + t.Helper() + var raw any + require.NoError(t, key.Raw(&raw), "failed to get raw key") + der, err := x509.MarshalPKCS8PrivateKey(raw) + require.NoError(t, err, "failed to marshal key to PKCS8") + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) +} + +// publicKeyPEMForTest extracts the public half of a private jwk.Key and encodes +// it as a PKIX public-key PEM (used to verify public-only keys are rejected). +func publicKeyPEMForTest(t *testing.T, key interface{ Raw(any) error }) []byte { + t.Helper() + var raw any + require.NoError(t, key.Raw(&raw), "failed to get raw key") + var pub any + switch k := raw.(type) { + case *rsa.PrivateKey: + pub = &k.PublicKey + case *ecdsa.PrivateKey: + pub = &k.PublicKey + default: + t.Fatalf("unsupported key type %T", raw) + } + der, err := x509.MarshalPKIXPublicKey(pub) + require.NoError(t, err, "failed to marshal public key") + return pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) +} + +func TestGenerateDPoPKeyForAlg_EC(t *testing.T) { + tests := []struct { + alg SigningAlgorithm + wantAlg jwa.SignatureAlgorithm + curve elliptic.Curve + }{ + {ES256, jwa.ES256, elliptic.P256()}, + {ES384, jwa.ES384, elliptic.P384()}, + {ES512, jwa.ES512, elliptic.P521()}, + } + + for _, tt := range tests { + t.Run(string(tt.alg), func(t *testing.T) { + key, err := generateDPoPKeyForAlg(tt.alg) + require.NoErrorf(t, err, "generateDPoPKeyForAlg(%q)", tt.alg) + assert.Equal(t, tt.wantAlg, key.Algorithm(), "algorithm") + var rawKey *ecdsa.PrivateKey + require.NoError(t, key.Raw(&rawKey), "failed to get raw EC key") + assert.Equal(t, tt.curve, rawKey.Curve, "curve") + }) + } +} + +func TestGenerateDPoPKeyForAlg_RSA(t *testing.T) { + tests := []struct { + alg SigningAlgorithm + wantAlg jwa.SignatureAlgorithm + }{ + {RS256, jwa.RS256}, + {RS384, jwa.RS384}, + {RS512, jwa.RS512}, + } + + for _, tt := range tests { + t.Run(string(tt.alg), func(t *testing.T) { + key, err := generateDPoPKeyForAlg(tt.alg) + require.NoErrorf(t, err, "generateDPoPKeyForAlg(%q)", tt.alg) + assert.Equal(t, tt.wantAlg, key.Algorithm(), "algorithm") + var rawKey *rsa.PrivateKey + require.NoError(t, key.Raw(&rawKey), "failed to get raw RSA key") + }) + } +} + +func TestGenerateDPoPKeyForAlg_Invalid(t *testing.T) { + for _, alg := range []SigningAlgorithm{"INVALID", "", "HS256", "PS256"} { + t.Run(string(alg), func(t *testing.T) { + _, err := generateDPoPKeyForAlg(alg) + assert.Errorf(t, err, "expected error for alg %q", alg) + }) + } +} + +func TestLoadDPoPKeyFromPEM_RSA(t *testing.T) { + generated, err := generateDPoPKeyForAlg(RS256) + require.NoError(t, err, "failed to generate RSA test key") + pemBytes := jwkToPEMForTest(t, generated) + + loaded, err := loadDPoPKeyFromPEM(pemBytes) + require.NoError(t, err, "loadDPoPKeyFromPEM") + assert.Equal(t, jwa.RS256, loaded.Algorithm(), "algorithm") +} + +func TestLoadDPoPKeyFromPEM_EC(t *testing.T) { + tests := []struct { + alg SigningAlgorithm + wantAlg jwa.SignatureAlgorithm + }{ + {ES256, jwa.ES256}, + {ES384, jwa.ES384}, + {ES512, jwa.ES512}, + } + + for _, tt := range tests { + t.Run(string(tt.alg), func(t *testing.T) { + generated, err := generateDPoPKeyForAlg(tt.alg) + require.NoError(t, err, "failed to generate EC test key") + pemBytes := jwkToPEMForTest(t, generated) + + loaded, err := loadDPoPKeyFromPEM(pemBytes) + require.NoError(t, err, "loadDPoPKeyFromPEM") + assert.Equal(t, tt.wantAlg, loaded.Algorithm(), "algorithm") + }) + } +} + +func TestLoadDPoPKeyFromPEM_InvalidPEM(t *testing.T) { + _, err := loadDPoPKeyFromPEM([]byte("not valid PEM")) + assert.Error(t, err, "expected error for invalid PEM") +} + +func TestLoadDPoPKeyFromPEM_PublicKeyRejected(t *testing.T) { + for _, alg := range []SigningAlgorithm{RS256, ES256} { + t.Run(string(alg), func(t *testing.T) { + generated, err := generateDPoPKeyForAlg(alg) + require.NoError(t, err, "generate test key") + pubPEM := publicKeyPEMForTest(t, generated) + + _, err = loadDPoPKeyFromPEM(pubPEM) + require.Error(t, err, "expected error for public-only PEM") + assert.Contains(t, err.Error(), "private", "error should mention missing private material") + }) + } +} + +func TestResolveDPoPKey(t *testing.T) { + ecKey, err := generateDPoPKeyForAlg(ES256) + require.NoError(t, err, "generate EC key") + ecPEM := jwkToPEMForTest(t, ecKey) + + t.Run("empty config returns nil sentinel", func(t *testing.T) { + key, err := resolveDPoPKey(&config{}) + require.NoError(t, err) + assert.Nil(t, key, "expected nil key for empty config") + }) + + t.Run("preset JWK validated and returned", func(t *testing.T) { + key, err := resolveDPoPKey(&config{dpopJWK: ecKey}) + require.NoError(t, err) + require.NotNil(t, key, "expected key") + }) + + t.Run("PEM path resolves without mutating config", func(t *testing.T) { + c := &config{dpopKeyPEM: ecPEM} + key, err := resolveDPoPKey(c) + require.NoError(t, err) + assert.Equal(t, jwa.ES256, key.Algorithm(), "alg") + assert.Nil(t, c.dpopJWK, "resolveDPoPKey must be pure and not cache into dpopJWK") + }) + + t.Run("PEM with algorithm override", func(t *testing.T) { + rsaKey, err := generateDPoPKeyForAlg(RS256) + require.NoError(t, err, "generate RSA key") + c := &config{dpopKeyPEM: jwkToPEMForTest(t, rsaKey), dpopAlgorithm: RS512} + key, err := resolveDPoPKey(c) + require.NoError(t, err) + assert.Equal(t, jwa.RS512, key.Algorithm(), "alg override") + }) + + t.Run("generate from algorithm", func(t *testing.T) { + key, err := resolveDPoPKey(&config{dpopAlgorithm: ES384}) + require.NoError(t, err) + assert.Equal(t, jwa.ES384, key.Algorithm(), "alg") + }) + + t.Run("RSA key pair resolves to RS256 JWK", func(t *testing.T) { + rsaKeyPair, err := ocrypto.NewRSAKeyPair(dpopKeySize) + require.NoError(t, err, "generate RSA key pair") + key, err := resolveDPoPKey(&config{dpopKey: &rsaKeyPair}) + require.NoError(t, err) + require.NotNil(t, key, "expected key for RSA key pair") + assert.Equal(t, jwa.RS256, key.Algorithm(), "alg") + }) +} + +func TestValidateDPoPKey(t *testing.T) { + rsaJWK := func(t *testing.T) jwk.Key { + t.Helper() + raw, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "generate RSA key") + k, err := jwk.FromRaw(raw) + require.NoError(t, err, "jwk.FromRaw") + return k + } + + t.Run("missing algorithm errors", func(t *testing.T) { + k := rsaJWK(t) + _, err := resolveDPoPKey(&config{dpopJWK: k}) + assert.Error(t, err, "expected error for JWK without algorithm") + }) + + t.Run("unsupported algorithm errors", func(t *testing.T) { + k := rsaJWK(t) + require.NoError(t, k.Set(jwk.AlgorithmKey, jwa.HS256), "set alg") + _, err := resolveDPoPKey(&config{dpopJWK: k}) + assert.Error(t, err, "expected error for unsupported algorithm") + }) + + t.Run("public-only JWK rejected", func(t *testing.T) { + raw, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "generate RSA key") + k, err := jwk.FromRaw(&raw.PublicKey) + require.NoError(t, err, "jwk.FromRaw public") + require.NoError(t, k.Set(jwk.AlgorithmKey, jwa.RS256), "set alg") + _, err = resolveDPoPKey(&config{dpopJWK: k}) + require.Error(t, err, "expected error for public-only JWK") + assert.Contains(t, err.Error(), "private", "error should mention missing private material") + }) + + t.Run("RSA key with EC algorithm rejected", func(t *testing.T) { + k := rsaJWK(t) + require.NoError(t, k.Set(jwk.AlgorithmKey, jwa.ES256), "set alg") + _, err := resolveDPoPKey(&config{dpopJWK: k}) + require.Error(t, err, "expected error for RSA key labeled ES256") + assert.Contains(t, err.Error(), "EC key", "error should mention EC key requirement") + }) + + t.Run("EC key with RSA algorithm rejected", func(t *testing.T) { + raw, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err, "generate EC key") + k, err := jwk.FromRaw(raw) + require.NoError(t, err, "jwk.FromRaw") + require.NoError(t, k.Set(jwk.AlgorithmKey, jwa.RS256), "set alg") + _, err = resolveDPoPKey(&config{dpopJWK: k}) + require.Error(t, err, "expected error for EC key labeled RS256") + assert.Contains(t, err.Error(), "RSA key", "error should mention RSA key requirement") + }) + + t.Run("RSA PEM overridden to ES256 rejected", func(t *testing.T) { + raw, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "generate RSA key") + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(raw), + }) + _, err = resolveDPoPKey(&config{dpopKeyPEM: pemBytes, dpopAlgorithm: "ES256"}) + require.Error(t, err, "expected error for RSA PEM overridden to ES256") + assert.Contains(t, err.Error(), "EC key", "error should mention EC key requirement") + }) + + t.Run("EC curve/algorithm mismatch rejected", func(t *testing.T) { + raw, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err, "generate P-256 key") + k, err := jwk.FromRaw(raw) + require.NoError(t, err, "jwk.FromRaw") + require.NoError(t, k.Set(jwk.AlgorithmKey, jwa.ES512), "set mismatched alg") + _, err = resolveDPoPKey(&config{dpopJWK: k}) + require.Error(t, err, "expected error for P-256 key labeled ES512") + assert.Contains(t, err.Error(), "curve", "error should mention curve mismatch") + }) +} diff --git a/sdk/dpop_validation_client_test.go b/sdk/dpop_validation_client_test.go new file mode 100644 index 0000000000..0dae5f270f --- /dev/null +++ b/sdk/dpop_validation_client_test.go @@ -0,0 +1,58 @@ +package sdk + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestNewDPoPValidationHTTPClient verifies the helper otdfctl uses to make a +// DPoP-bound token-endpoint request during credential validation: the request +// carries a DPoP proof header whether or not a key is explicitly configured, +// matching the DPoP-on default the credentialed SDK client applies. +func TestNewDPoPValidationHTTPClient(t *testing.T) { + assertAddsDPoPProof := func(t *testing.T, opts ...Option) { + t.Helper() + var gotDPoP, gotAuthz string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotDPoP = r.Header.Get("DPoP") + gotAuthz = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := NewDPoPValidationHTTPClient(http.DefaultClient, opts...) + require.NoError(t, err, "NewDPoPValidationHTTPClient") + + resp, err := client.Do(mustGet(t, server.URL)) + require.NoError(t, err, "request failed") + resp.Body.Close() + + assert.NotEmpty(t, gotDPoP, "expected a DPoP proof header on the token request") + // Token-endpoint requests bind via htu only: no ath claim / Authorization header. + assert.Empty(t, gotAuthz, "token-endpoint request must not carry an Authorization header") + } + + t.Run("adds DPoP proof when algorithm configured", func(t *testing.T) { + assertAddsDPoPProof(t, WithDPoPAlgorithm(ES256)) + }) + + t.Run("adds DPoP proof by default when no DPoP configured", func(t *testing.T) { + assertAddsDPoPProof(t) + }) + + t.Run("propagates invalid key configuration as an error", func(t *testing.T) { + _, err := NewDPoPValidationHTTPClient(http.DefaultClient, WithDPoPKeyPEM([]byte("not a pem"))) + assert.Error(t, err, "expected error for invalid DPoP key PEM") + }) +} + +func mustGet(t *testing.T, url string) *http.Request { + t.Helper() + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err, "new request") + return req +} diff --git a/sdk/idp_access_token_source.go b/sdk/idp_access_token_source.go index ef45a29760..176c4bb54c 100644 --- a/sdk/idp_access_token_source.go +++ b/sdk/idp_access_token_source.go @@ -23,9 +23,9 @@ func getNewDPoPKey(dpopKeyPair *ocrypto.RsaKeyPair) (string, jwk.Key, *ocrypto.A if err != nil { return "", nil, nil, fmt.Errorf("error getting dpop of key: %w", err) } - dpopPublicKeyPEM, err := dpopKeyPair.PrivateKeyInPemFormat() + dpopPublicKeyPEM, err := dpopKeyPair.PublicKeyInPemFormat() if err != nil { - return "", nil, nil, fmt.Errorf("error getting dpop of key: %w", err) + return "", nil, nil, fmt.Errorf("error getting dpop public key: %w", err) } dpopKey, err := jwk.ParseKey([]byte(dpopPrivateKeyPEM), jwk.WithPEM(true)) @@ -121,3 +121,23 @@ func (t *IDPAccessTokenSource) AccessTokenCredential(_ context.Context, client * func (t *IDPAccessTokenSource) MakeToken(tokenMaker func(jwk.Key) ([]byte, error)) ([]byte, error) { return tokenMaker(t.dpopKey) } + +// newIDPAccessTokenSourceFromJWK creates an IDPAccessTokenSource using a pre-built JWK key. +func newIDPAccessTokenSourceFromJWK( + credentials oauth.ClientCredentials, + idpTokenEndpoint string, + scopes []string, + key jwk.Key, +) (*IDPAccessTokenSource, error) { + endpoint, err := url.Parse(idpTokenEndpoint) + if err != nil { + return nil, fmt.Errorf("invalid url [%s]: %w", idpTokenEndpoint, err) + } + return &IDPAccessTokenSource{ + credentials: credentials, + idpTokenEndpoint: *endpoint, + scopes: scopes, + dpopKey: key, + tokenMutex: &sync.Mutex{}, + }, nil +} diff --git a/sdk/idp_cert_exchange.go b/sdk/idp_cert_exchange.go index 5fec5182b6..e87b7ea415 100644 --- a/sdk/idp_cert_exchange.go +++ b/sdk/idp_cert_exchange.go @@ -69,3 +69,21 @@ func (c *CertExchangeTokenSource) AccessTokenCredential(ctx context.Context, _ * func (c *CertExchangeTokenSource) MakeToken(tokenMaker func(jwk.Key) ([]byte, error)) ([]byte, error) { return tokenMaker(c.key) } + +// newCertExchangeTokenSourceFromJWK creates a CertExchangeTokenSource using a pre-built JWK key. +func newCertExchangeTokenSourceFromJWK( + logger *slog.Logger, + info oauth.CertExchangeInfo, + credentials oauth.ClientCredentials, + idpTokenEndpoint string, + key jwk.Key, +) (auth.AccessTokenSource, error) { + return &CertExchangeTokenSource{ + logger: logger, + info: info, + IdpEndpoint: idpTokenEndpoint, + credentials: credentials, + tokenMutex: &sync.Mutex{}, + key: key, + }, nil +} diff --git a/sdk/idp_oauth_access_token_source.go b/sdk/idp_oauth_access_token_source.go index 98011ef02f..d13dec4ff9 100644 --- a/sdk/idp_oauth_access_token_source.go +++ b/sdk/idp_oauth_access_token_source.go @@ -29,7 +29,7 @@ func NewOAuthAccessTokenSource( } tokenSource := OAuthAccessTokenSource{ - source: source, + source: cachingTokenSource(source), scopes: scopes, asymDecryption: *asymDecryption, dpopKey: dpopKey, @@ -39,6 +39,15 @@ func NewOAuthAccessTokenSource( return &tokenSource, nil } +// cachingTokenSource wraps a token source so that a valid token is reused across +// calls instead of being re-fetched. AccessToken is on the request hot path +// (once per gRPC/Connect call, plus once more to compute the DPoP ath claim), so +// an uncached source would risk an IdP round-trip on every request. Wrapping an +// already-caching source is harmless. +func cachingTokenSource(source oauth2.TokenSource) oauth2.TokenSource { + return oauth2.ReuseTokenSource(nil, source) +} + // AccessToken use a pointer receiver so that the token state is shared func (t *OAuthAccessTokenSource) AccessToken(ctx context.Context, client *http.Client) (auth.AccessToken, error) { credential, err := t.AccessTokenCredential(ctx, client) @@ -67,3 +76,12 @@ func (t *OAuthAccessTokenSource) AccessTokenCredential(_ context.Context, _ *htt func (t *OAuthAccessTokenSource) MakeToken(tokenMaker func(jwk.Key) ([]byte, error)) ([]byte, error) { return tokenMaker(t.dpopKey) } + +// newOAuthAccessTokenSourceFromJWK creates an OAuthAccessTokenSource using a pre-built JWK key. +func newOAuthAccessTokenSourceFromJWK(source oauth2.TokenSource, scopes []string, key jwk.Key) *OAuthAccessTokenSource { + return &OAuthAccessTokenSource{ + source: cachingTokenSource(source), + scopes: scopes, + dpopKey: key, + } +} diff --git a/sdk/idp_oauth_access_token_source_test.go b/sdk/idp_oauth_access_token_source_test.go index 72233760cf..a1742a6493 100644 --- a/sdk/idp_oauth_access_token_source_test.go +++ b/sdk/idp_oauth_access_token_source_test.go @@ -26,11 +26,15 @@ func TestNewOAuthAccessTokenSource_Success(t *testing.T) { // Sanity Checks require.NoError(t, err) assert.NotNil(t, tokenSource) - assert.Equal(t, mockSource, tokenSource.source) assert.Equal(t, mockScopes, tokenSource.scopes) // DPoP values assert.Equal(t, asymDecryption, &tokenSource.asymDecryption) assert.Equal(t, dpopPublicKeyPEM, tokenSource.dpopPEM) + // Guard the fix that switched dpopPEM to public material: comparing against + // getNewDPoPKey alone is tautological, so assert it is genuinely a public key + // block — a regression to the private PEM would leak signing material. + assert.Contains(t, tokenSource.dpopPEM, "PUBLIC KEY", "dpopPEM must hold the public key") + assert.NotContains(t, tokenSource.dpopPEM, "PRIVATE KEY", "dpopPEM must not hold private key material") assert.Equal(t, dpopKey, tokenSource.dpopKey) // Interface checks credential, err := tokenSource.AccessTokenCredential(t.Context(), nil) @@ -68,7 +72,6 @@ func TestNewOAuthAccessTokenSource_ExpiredToken(t *testing.T) { // Sanity Checks require.NoError(t, err) assert.NotNil(t, tokenSource) - assert.Equal(t, mockSource, tokenSource.source) // Interface checks tok, err := tokenSource.AccessToken(t.Context(), nil) require.Error(t, err) @@ -88,13 +91,42 @@ func TestNewOAuthAccessTokenSource_InvalidTokenSource(t *testing.T) { // Sanity Checks require.NoError(t, err) assert.NotNil(t, tokenSource) - assert.Equal(t, mockSource, tokenSource.source) // Interface checks tok, err := tokenSource.AccessToken(t.Context(), nil) require.Error(t, err) assert.Empty(t, tok) } +// countingTokenSource records how many times the underlying source is queried, +// so tests can assert that valid tokens are cached rather than re-fetched. +type countingTokenSource struct { + calls int + tok *oauth2.Token +} + +func (c *countingTokenSource) Token() (*oauth2.Token, error) { + c.calls++ + return c.tok, nil +} + +func TestNewOAuthAccessTokenSource_CachesValidToken(t *testing.T) { + counting := &countingTokenSource{ + tok: &oauth2.Token{AccessToken: "mockToken", Expiry: time.Now().Add(time.Hour)}, + } + mockKey, _ := ocrypto.NewRSAKeyPair(dpopKeySize) + + tokenSource, err := NewOAuthAccessTokenSource(counting, []string{"scope1"}, &mockKey) + require.NoError(t, err) + + for range 3 { + tok, err := tokenSource.AccessToken(t.Context(), nil) + require.NoError(t, err) + assert.Equal(t, auth.AccessToken("mockToken"), tok) + } + + assert.Equal(t, 1, counting.calls, "valid token should be fetched once and reused") +} + func TestNewOAuthAccessTokenSource_InvalidKey(t *testing.T) { // Expected mockSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "mockToken"}) diff --git a/sdk/idp_token_exchange_token_source.go b/sdk/idp_token_exchange_token_source.go index 0968a1fe8b..e1604078fb 100644 --- a/sdk/idp_token_exchange_token_source.go +++ b/sdk/idp_token_exchange_token_source.go @@ -60,3 +60,23 @@ func (i *IDPTokenExchangeTokenSource) AccessTokenCredential(ctx context.Context, func (i *IDPTokenExchangeTokenSource) MakeToken(keyMaker func(jwk.Key) ([]byte, error)) ([]byte, error) { return i.IDPAccessTokenSource.MakeToken(keyMaker) } + +// newIDPTokenExchangeTokenSourceFromJWK creates an IDPTokenExchangeTokenSource using a pre-built JWK key. +func newIDPTokenExchangeTokenSourceFromJWK( + logger *slog.Logger, + exchangeInfo oauth.TokenExchangeInfo, + credentials oauth.ClientCredentials, + idpTokenEndpoint string, + scopes []string, + key jwk.Key, +) (*IDPTokenExchangeTokenSource, error) { + idpSource, err := newIDPAccessTokenSourceFromJWK(credentials, idpTokenEndpoint, scopes, key) + if err != nil { + return nil, err + } + return &IDPTokenExchangeTokenSource{ + logger: logger, + IDPAccessTokenSource: *idpSource, + TokenExchangeInfo: exchangeInfo, + }, nil +} diff --git a/sdk/options.go b/sdk/options.go index ba63bb092a..a9ffc5cf34 100644 --- a/sdk/options.go +++ b/sdk/options.go @@ -7,6 +7,7 @@ import ( "net/http" "connectrpc.com/connect" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/opentdf/platform/lib/ocrypto" "github.com/opentdf/platform/sdk/auth" "github.com/opentdf/platform/sdk/auth/oauth" @@ -16,6 +17,19 @@ import ( type Option func(*config) +// SigningAlgorithm identifies a JWS signing algorithm (RFC 7518 §3.1). +type SigningAlgorithm string + +// Supported JWS signing algorithms for DPoP proof tokens (RFC 9449 §4.2). +const ( + ES256 SigningAlgorithm = "ES256" + ES384 SigningAlgorithm = "ES384" + ES512 SigningAlgorithm = "ES512" + RS256 SigningAlgorithm = "RS256" + RS384 SigningAlgorithm = "RS384" + RS512 SigningAlgorithm = "RS512" +) + type ConnectRPCConnection struct { Client *http.Client Endpoint string @@ -35,6 +49,9 @@ type config struct { certExchange *oauth.CertExchangeInfo kasSessionKey *ocrypto.RsaKeyPair dpopKey *ocrypto.RsaKeyPair + dpopJWK jwk.Key + dpopAlgorithm SigningAlgorithm + dpopKeyPEM []byte ipc bool tdfFeatures tdfFeatures customAccessTokenSource auth.AccessTokenSource @@ -228,3 +245,30 @@ func WithLogger(logger *slog.Logger) Option { c.logger = logger } } + +// WithDPoPAlgorithm enables DPoP with an ephemeral key generated for the given algorithm. +// Supported: ES256, ES384, ES512, RS256, RS384, RS512. +// When no DPoP key is otherwise configured, the SDK auto-generates an ephemeral ES256 (P-256) key by default; ES256 is the +// recommended choice. +func WithDPoPAlgorithm(alg SigningAlgorithm) Option { + return func(c *config) { + c.dpopAlgorithm = alg + } +} + +// WithDPoPKeyPEM enables DPoP using a PEM-encoded private key. Algorithm is inferred +// from the key type unless also overridden via WithDPoPAlgorithm. +func WithDPoPKeyPEM(pemBytes []byte) Option { + return func(c *config) { + c.dpopKeyPEM = pemBytes + } +} + +// WithDPoPJWK enables DPoP using a pre-built JWK private key. The JWK must have its +// Algorithm field set. This is the lowest-level DPoP key injection; prefer +// WithDPoPAlgorithm or WithDPoPKeyPEM for most use cases. +func WithDPoPJWK(key jwk.Key) Option { + return func(c *config) { + c.dpopJWK = key + } +} diff --git a/sdk/sdk.go b/sdk/sdk.go index c9c02adbac..fb720e75b8 100644 --- a/sdk/sdk.go +++ b/sdk/sdk.go @@ -16,6 +16,7 @@ import ( "sync" "connectrpc.com/connect" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/opentdf/platform/lib/ocrypto" "github.com/opentdf/platform/protocol/go/policy" "github.com/opentdf/platform/protocol/go/wellknownconfiguration" @@ -197,12 +198,30 @@ func New(platformEndpoint string, opts ...Option) (*SDK, error) { // Add request ID interceptor uci = append(uci, audit.MetadataAddingConnectInterceptor()) - accessTokenSource, err := buildIDPTokenSource(cfg) + accessTokenSource, dpopKey, err := buildIDPTokenSource(cfg) if err != nil { return nil, err } - if accessTokenSource != nil { - interceptor := auth.NewTokenAddingInterceptorWithClient(accessTokenSource, cfg.httpClient) + + // Wrap HTTP client with DPoP transport for resource requests. The DPoP key is + // resolved once in buildIDPTokenSource and returned here so the transport signs + // proofs with the same key the token source binds tokens to. + httpClient := cfg.httpClient + dpopHandledByTransport := false + if accessTokenSource != nil && dpopKey != nil { + httpClient, err = auth.NewDPoPHTTPClient(cfg.httpClient, dpopKey, accessTokenSource, cfg.tokenEndpoint) + if err != nil { + return nil, err + } + dpopHandledByTransport = true + } + + // When the DPoP transport is active it sets both the Authorization and DPoP + // headers (with a correctly normalized htu) and handles DPoP-Nonce retries, so + // the credential interceptor would only overwrite those headers with a weaker + // proof. Add the interceptor only when the transport is not handling DPoP. + if accessTokenSource != nil && !dpopHandledByTransport { + interceptor := auth.NewTokenAddingInterceptorWithClient(accessTokenSource, httpClient) uci = append(uci, interceptor.AddCredentialsConnect()) } @@ -210,7 +229,7 @@ func New(platformEndpoint string, opts ...Option) (*SDK, error) { if cfg.coreConn != nil { platformConn = cfg.coreConn } else { - platformConn = &ConnectRPCConnection{Endpoint: platformEndpoint, Client: cfg.httpClient, Options: append(cfg.extraClientOptions, connect.WithInterceptors(uci...))} + platformConn = &ConnectRPCConnection{Endpoint: platformEndpoint, Client: httpClient, Options: append(cfg.extraClientOptions, connect.WithInterceptors(uci...))} } if cfg.entityResolutionConn != nil { @@ -251,55 +270,118 @@ func IsPlatformEndpointMalformed(e string) bool { return false } -func buildIDPTokenSource(c *config) (auth.AccessTokenSource, error) { +func getDPoPJWK(dpopKey *ocrypto.RsaKeyPair) (jwk.Key, error) { + dpopPrivateKeyPEM, err := dpopKey.PrivateKeyInPemFormat() + if err != nil { + return nil, fmt.Errorf("error getting dpop private key: %w", err) + } + + key, err := jwk.ParseKey([]byte(dpopPrivateKeyPEM), jwk.WithPEM(true)) + if err != nil { + return nil, fmt.Errorf("error creating JWK: %w", err) + } + + if err := key.Set(jwk.AlgorithmKey, "RS256"); err != nil { + return nil, fmt.Errorf("error setting key algorithm: %w", err) + } + + return key, nil +} + +// NewDPoPValidationHTTPClient wraps base so its requests carry a DPoP proof signed +// with the key resolved from opts (the same WithDPoP* options passed to New). It is +// intended for token-endpoint calls made outside the SDK's own connection (e.g. a +// CLI pre-flight credential check): the proof binds the request via htu but carries +// no ath claim or Authorization header, and DPoP-Nonce challenges are retried. +// +// When no DPoP key is configured in opts a fresh ephemeral ES256 key is generated, +// mirroring the default the real SDK client applies in buildIDPTokenSource. This +// keeps the pre-flight validation token request consistent with the credentialed +// client so a DPoP-enforcing token endpoint accepts both; the throwaway validation +// token is never reused, so an ephemeral key is fine. +func NewDPoPValidationHTTPClient(base *http.Client, opts ...Option) (*http.Client, error) { + c := &config{} + for _, o := range opts { + o(c) + } + key, err := resolveDPoPKey(c) + if err != nil { + return nil, fmt.Errorf("failed to resolve DPoP key: %w", err) + } + if key == nil { + key, err = generateDPoPKeyForAlg(ES256) + if err != nil { + return nil, fmt.Errorf("failed to generate default DPoP key: %w", err) + } + } + return auth.NewDPoPHTTPClient(base, key, nil, "") +} + +// buildIDPTokenSource builds the access token source and resolves the DPoP key +// once, returning it so the caller can give the DPoP transport the same key the +// token source binds to. The returned key is nil when DPoP is not in effect +// (no credentials, or no key configured for a custom token source). +func buildIDPTokenSource(c *config) (auth.AccessTokenSource, jwk.Key, error) { if c.customAccessTokenSource != nil { - return c.customAccessTokenSource, nil + // A custom token source manages its own credentials, but a configured DPoP + // key still drives the resource-request transport. + dpopKey, err := resolveDPoPKey(c) + if err != nil { + return nil, nil, fmt.Errorf("failed to resolve DPoP key: %w", err) + } + return c.customAccessTokenSource, dpopKey, nil + } + + // Surface a conflicting exchange configuration before the uncredentialed + // fast-path below, so the misconfiguration is reported instead of silently + // producing a nil (uncredentialed) token source. + if c.certExchange != nil && c.tokenExchange != nil { + return nil, nil, errors.New("cannot do both token exchange and certificate exchange") } // There are uses for uncredentialed clients (i.e. consuming the well-known configuration). if c.clientCredentials == nil && c.oauthAccessTokenSource == nil { - return nil, nil //nolint:nilnil // not having credentials is not an error + // DPoP only takes effect once requests are credentialed. If the caller + // explicitly configured a DPoP key but supplied no credentials, fail loudly + // rather than silently returning an unbound client and downgrading the + // caller's expected security posture. + if c.dpopJWK != nil || len(c.dpopKeyPEM) > 0 || c.dpopAlgorithm != "" || c.dpopKey != nil { + return nil, nil, errors.New("DPoP configured (WithDPoP*) but no client credentials or OAuth token source supplied") + } + return nil, nil, nil } - if c.certExchange != nil && c.tokenExchange != nil { - return nil, errors.New("cannot do both token exchange and certificate exchange") + dpopKey, err := resolveDPoPKey(c) + if err != nil { + return nil, nil, fmt.Errorf("failed to resolve DPoP key: %w", err) } - if c.dpopKey == nil { - rsaKeyPair, err := ocrypto.NewRSAKeyPair(dpopKeySize) + // No DPoP key configured: auto-generate a default ephemeral ES256/P-256 key. + if dpopKey == nil { + dpopKey, err = generateDPoPKeyForAlg(ES256) if err != nil { - return nil, fmt.Errorf("could not generate RSA Key: %w", err) + return nil, nil, fmt.Errorf("failed to generate default DPoP key: %w", err) } - c.dpopKey = &rsaKeyPair } - var ts auth.AccessTokenSource - var err error + ts, err := buildIDPTokenSourceFromJWK(c, dpopKey) + if err != nil { + return nil, nil, err + } + return ts, dpopKey, nil +} +func buildIDPTokenSourceFromJWK(c *config, key jwk.Key) (auth.AccessTokenSource, error) { switch { case c.oauthAccessTokenSource != nil: - ts, err = NewOAuthAccessTokenSource(c.oauthAccessTokenSource, c.scopes, c.dpopKey) + return newOAuthAccessTokenSourceFromJWK(c.oauthAccessTokenSource, c.scopes, key), nil case c.certExchange != nil: - ts, err = NewCertExchangeTokenSource(c.logger, *c.certExchange, *c.clientCredentials, c.tokenEndpoint, c.dpopKey) + return newCertExchangeTokenSourceFromJWK(c.logger, *c.certExchange, *c.clientCredentials, c.tokenEndpoint, key) case c.tokenExchange != nil: - ts, err = NewIDPTokenExchangeTokenSource( - c.logger, - *c.tokenExchange, - *c.clientCredentials, - c.tokenEndpoint, - c.scopes, - c.dpopKey, - ) + return newIDPTokenExchangeTokenSourceFromJWK(c.logger, *c.tokenExchange, *c.clientCredentials, c.tokenEndpoint, c.scopes, key) default: - ts, err = NewIDPAccessTokenSource( - *c.clientCredentials, - c.tokenEndpoint, - c.scopes, - c.dpopKey, - ) + return newIDPAccessTokenSourceFromJWK(*c.clientCredentials, c.tokenEndpoint, c.scopes, key) } - - return ts, err } func (s SDK) Close() error { diff --git a/sdk/version.go b/sdk/version.go index c58543501a..445fbe09ca 100644 --- a/sdk/version.go +++ b/sdk/version.go @@ -9,3 +9,18 @@ const ( // The three-part semantic version number of this SDK Version = "0.27.0" // x-release-please-version ) + +// SupportedFeatures returns a list of optional features supported by this SDK build. +// Used by xtest integration harness for feature detection. +// +// These strings are part of the stable API surface. The xtest harness silently +// SKIPs (rather than fails) tests gated on an unknown feature string, so removing +// or renaming a feature here must be coordinated with opentdf/tests before merging +// to avoid quietly disabling coverage. +func SupportedFeatures() []string { + return []string{ + "dpop", // RFC 9449 DPoP (Demonstrating Proof-of-Possession) + "dpop_nonce_challenge", // RFC 9449 §8 server-issued DPoP-Nonce challenge/retry + "connectrpc", // Connect RPC protocol support + } +} diff --git a/service/internal/auth/authn.go b/service/internal/auth/authn.go index 196a50546b..fa5b2c3ac9 100644 --- a/service/internal/auth/authn.go +++ b/service/internal/auth/authn.go @@ -79,6 +79,8 @@ const ( refreshInterval = 15 * time.Minute dpopJWTType = "dpop+jwt" dpopNonceBytes = 16 + httpScheme = "http" + httpsScheme = "https" casbinAuthzConfiguredGroupsClaimKey = "configured_groups_claim" ActionRead = "read" ActionWrite = "write" @@ -388,13 +390,50 @@ func (e *DPoPProofError) Error() string { return e.err.Error() } func (e *DPoPProofError) Unwrap() error { return e.err } +// originFromHost builds a normalized scheme://host origin from a request's Host +// header. It parses with Hostname()/Port() rather than trimming a ":443"/":80" +// suffix so it handles IPv6 literals and hosts that merely end in the default +// port, and lowercases the host to match the SDK's htu normalization. +func originFromHost(host string, secure bool) string { + scheme := httpScheme + if secure { + scheme = httpsScheme + } + u, err := url.Parse(scheme + "://" + host) + if err != nil { + // Fall back to the raw value, but surface the failure: a DPoP htu match is + // an exact string comparison, so an unnormalized origin here is a likely + // cause of an otherwise inexplicable proof rejection. + slog.Debug("dpop: failed to parse origin from host; using unnormalized value", + slog.String("host", host), + slog.String("error", err.Error())) + return scheme + "://" + host + } + h := strings.ToLower(u.Hostname()) + if strings.Contains(h, ":") { + h = "[" + h + "]" // re-bracket IPv6 literal stripped by Hostname() + } + if port := u.Port(); port != "" && + !(scheme == httpsScheme && port == "443") && //nolint:staticcheck // QF1001: written as it would be understood (don't append port if the port is expected for its scheme) + !(scheme == httpScheme && port == "80") { //nolint:staticcheck // QF1001: as above + h += ":" + port + } + return scheme + "://" + h +} + func normalizeURL(o string, u *url.URL) string { - // Currently this does not do a full normatlization ou, err := url.Parse(o) if err != nil { + slog.Debug("dpop: failed to parse origin for normalization; using request url", + slog.String("origin", o), + slog.String("error", err.Error())) return u.String() } ou.Path = u.Path + ou.RawPath = u.RawPath + ou.RawQuery = "" + ou.ForceQuery = false + ou.Fragment = "" return ou.String() } @@ -417,19 +456,29 @@ func matchHTU(received string, acceptable []string, strict bool) bool { if strict { return false } + receivedPath := normalizeEscapedPath(u.EscapedPath()) for _, a := range acceptable { au, err := url.Parse(a) if err != nil { continue } - if au.Path == u.Path { + if normalizeEscapedPath(au.EscapedPath()) == receivedPath { return true } } return false } - // Full URL: must match one of the acceptable URIs exactly. - return slices.Contains(acceptable, received) + normalizedReceived, err := normalizeDPoPURI(received) + if err != nil { + return false + } + for _, a := range acceptable { + normalizedAcceptable, err := normalizeDPoPURI(a) + if err == nil && normalizedAcceptable == normalizedReceived { + return true + } + } + return false } // verifyTokenHandler is a http handler that verifies the token @@ -454,12 +503,7 @@ func (a Authentication) MuxHandler(handler http.Handler) http.Handler { } origin := r.Header.Get("Origin") if origin == "" { - origin = r.Host - if r.TLS != nil { - origin = "https://" + strings.TrimSuffix(origin, ":443") - } else { - origin = "http://" + strings.TrimSuffix(origin, ":80") - } + origin = originFromHost(r.Host, r.TLS != nil) } ri := receiverInfo{ u: []string{normalizeURL(origin, r.URL)}, @@ -607,10 +651,15 @@ func (a Authentication) ConnectAuthNInterceptor() connect.UnaryInterceptorFunc { procedure := req.Spec().Procedure host := req.Header().Get("Host") + // Build the acceptable htu values with the same normalization the SDK + // applies when signing its proof (lowercased host, default ports + // stripped) so the exact-string htu comparison in matchHTU succeeds. + // Both schemes are offered because the interceptor cannot observe the + // wire scheme here. ri := receiverInfo{ u: []string{ - "http://" + host + procedure, - "https://" + host + procedure, + originFromHost(host, false) + procedure, + originFromHost(host, true) + procedure, }, m: []string{req.HTTPMethod()}, } diff --git a/service/internal/auth/authn_test.go b/service/internal/auth/authn_test.go index b9a7c3d369..67989fdd83 100644 --- a/service/internal/auth/authn_test.go +++ b/service/internal/auth/authn_test.go @@ -248,6 +248,7 @@ func TestNormalizeUrl(t *testing.T) { {"http://localhost", "/", "http://localhost/"}, {"https://localhost", "/somewhere", "https://localhost/somewhere"}, {"http://localhost", "", "http://localhost"}, + {"http://localhost", "/a%2Fb", "http://localhost/a%2Fb"}, } { t.Run(tt.origin+tt.path, func(t *testing.T) { u, err := url.Parse(tt.path) @@ -258,6 +259,28 @@ func TestNormalizeUrl(t *testing.T) { } } +func TestOriginFromHost(t *testing.T) { + for _, tt := range []struct { + name string + host string + secure bool + out string + }{ + {"https default port stripped", "example.com:443", true, "https://example.com"}, + {"http default port stripped", "example.com:80", false, "http://example.com"}, + {"https non-default port kept", "example.com:8443", true, "https://example.com:8443"}, + {"http no port", "example.com", false, "http://example.com"}, + {"host lowercased", "EXAMPLE.COM:443", true, "https://example.com"}, + {"ipv6 default port stripped", "[::1]:443", true, "https://[::1]"}, + {"ipv6 non-default port kept", "[::1]:8443", true, "https://[::1]:8443"}, + {"ipv6 literal ending in 443 kept", "[fe80::443]", true, "https://[fe80::443]"}, + } { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.out, originFromHost(tt.host, tt.secure)) + }) + } +} + func TestPermissionDeniedDecisionLogAttrs(t *testing.T) { tok := jwt.New() require.NoError(t, tok.Set(jwt.SubjectKey, "client-subject")) @@ -1207,6 +1230,45 @@ func (s *AuthSuite) Test_ConnectAuthNInterceptor_PropagatesHTTPMethod() { } } +// Test_ConnectAuthNInterceptor_NormalizesHostForHTU verifies that the Connect +// interceptor builds its acceptable htu values from a normalized Host (lowercased, +// default ports stripped) via originFromHost, so the exact-string htu comparison +// matches what the SDK signs. A mixed-case host with an explicit :443 is the +// regression case: without normalization the server would reject a valid proof. +func (s *AuthSuite) Test_ConnectAuthNInterceptor_NormalizesHostForHTU() { + tok := jwt.New() + + var capturedU []string + s.auth._testCheckTokenFunc = func(ctx context.Context, _ []string, ri receiverInfo, _ []string) (jwt.Token, context.Context, error) { + capturedU = ri.u + return tok, ctx, nil + } + s.T().Cleanup(func() { s.auth._testCheckTokenFunc = nil }) + + interceptor := s.auth.ConnectAuthNInterceptor() + called := false + next := func(_ context.Context, _ connect.AnyRequest) (connect.AnyResponse, error) { + called = true + return connect.NewResponse(&kas.RewrapResponse{}), nil + } + req := &authnTestRequest{ + Request: connect.NewRequest(&kas.RewrapRequest{}), + procedure: "/kas.AccessService/Rewrap", + httpMethod: http.MethodPost, + } + req.Header().Set("Authorization", "DPoP test") + req.Header().Set("Host", "EXAMPLE.com:443") + + _, err := interceptor(next)(s.T().Context(), req) + s.Require().NoError(err) + s.True(called) + // Host lowercased; :443 stripped for https (default) but kept for http (non-default). + s.Equal([]string{ + "http://example.com:443/kas.AccessService/Rewrap", + "https://example.com/kas.AccessService/Rewrap", + }, capturedU) +} + func TestMatchHTU(t *testing.T) { full := []string{ "http://localhost:8080/svc/Method", @@ -1225,9 +1287,12 @@ func TestMatchHTU(t *testing.T) { {"loose/path-only match", "/svc/Method", full, false, true}, {"loose/path-only match against path-only acceptable", "/svc/Method", pathOnly, false, true}, {"loose/path-only mismatch", "/svc/Other", full, false, false}, + {"loose/path-only percent normalization", "/svc/%4dethod", full, false, true}, + {"loose/path-only reserved encoding preserved", "/svc%2FMethod", full, false, false}, // Loose mode: full URL accepted when it matches exactly {"loose/full http match", "http://localhost:8080/svc/Method", full, false, true}, {"loose/full https match", "https://localhost:8080/svc/Method", full, false, true}, + {"loose/full syntax normalization", "HTTP://LOCALHOST:8080/svc/%4dethod", full, false, true}, {"loose/full wrong path", "http://localhost:8080/svc/Other", full, false, false}, {"loose/full wrong host", "http://other:8080/svc/Method", full, false, false}, // Strict mode: path-only htu always rejected @@ -1236,6 +1301,12 @@ func TestMatchHTU(t *testing.T) { // Strict mode: full URL accepted when it matches exactly {"strict/full http match", "http://localhost:8080/svc/Method", full, true, true}, {"strict/full https match", "https://localhost:8080/svc/Method", full, true, true}, + {"strict/empty path normalized", "https://localhost:8080", []string{"https://localhost:8080/"}, true, true}, + {"strict/default port normalized", "HTTPS://LOCALHOST:443/svc/Method", []string{"https://localhost/svc/Method"}, true, true}, + {"strict/percent hex normalized", "https://localhost:8080/svc%2fMethod", []string{"https://localhost:8080/svc%2FMethod"}, true, true}, + {"strict/unreserved percent decoded", "https://localhost:8080/svc/%4dethod", full, true, true}, + {"strict/dot segments removed", "https://localhost:8080/a/../svc/Method", full, true, true}, + {"strict/reserved encoding differs", "https://localhost:8080/svc%2FMethod", full, true, false}, {"strict/full wrong path", "http://localhost:8080/svc/Other", full, true, false}, } diff --git a/service/internal/auth/dpop_uri.go b/service/internal/auth/dpop_uri.go new file mode 100644 index 0000000000..a2b68c542e --- /dev/null +++ b/service/internal/auth/dpop_uri.go @@ -0,0 +1,129 @@ +package auth + +import ( + "fmt" + "net/url" + "strings" +) + +// normalizeDPoPURI applies the syntax- and scheme-based normalization that +// RFC 9449 recommends before comparing htu values. +func normalizeDPoPURI(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil { + return "", err + } + scheme := strings.ToLower(u.Scheme) + if scheme != httpScheme && scheme != httpsScheme { + return "", fmt.Errorf("unsupported DPoP URI scheme %q", u.Scheme) + } + return originFromHost(u.Host, scheme == httpsScheme) + normalizeEscapedPath(u.EscapedPath()), nil +} + +func normalizeEscapedPath(escapedPath string) string { + if escapedPath == "" { + escapedPath = "/" + } + return removeDotSegments(normalizePercentEncoding(escapedPath)) +} + +func normalizePercentEncoding(s string) string { + const ( + upperHex = "0123456789ABCDEF" + hexDigitBits = 4 + hexDigitMask = 1<= len(s) { + b.WriteByte(s[i]) + continue + } + hi, hiOK := hexValue(s[i+1]) + lo, loOK := hexValue(s[i+2]) + if !hiOK || !loOK { + b.WriteByte(s[i]) + continue + } + decoded := hi<>hexDigitBits]) + b.WriteByte(upperHex[decoded&hexDigitMask]) + } + i += 2 + } + return b.String() +} + +func hexValue(c byte) (byte, bool) { + const hexAlphaOffset = 10 + switch { + case c >= '0' && c <= '9': + return c - '0', true + case c >= 'a' && c <= 'f': + return c - 'a' + hexAlphaOffset, true + case c >= 'A' && c <= 'F': + return c - 'A' + hexAlphaOffset, true + default: + return 0, false + } +} + +func isUnreserved(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || + c == '-' || c == '.' || c == '_' || c == '~' +} + +// removeDotSegments implements RFC 3986 section 5.2.4 without collapsing +// repeated slashes, which are significant path data. +func removeDotSegments(input string) string { + var output strings.Builder + output.Grow(len(input)) + trimLast := func() { + trimmed := trimLastPathSegment(output.String()) + output.Reset() + output.WriteString(trimmed) + } + for input != "" { + switch { + case strings.HasPrefix(input, "../"): + input = input[3:] + case strings.HasPrefix(input, "./"): + input = input[2:] + case strings.HasPrefix(input, "/./"): + input = input[2:] + case input == "/.": + input = "/" + case strings.HasPrefix(input, "/../"): + input = input[3:] + trimLast() + case input == "/..": + input = "/" + trimLast() + case input == "." || input == "..": + input = "" + default: + n := strings.IndexByte(input[1:], '/') + if n < 0 { + output.WriteString(input) + input = "" + } else { + n++ + output.WriteString(input[:n]) + input = input[n:] + } + } + } + return output.String() +} + +func trimLastPathSegment(path string) string { + if i := strings.LastIndexByte(path, '/'); i >= 0 { + return path[:i] + } + return "" +}