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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 111 additions & 22 deletions client/begin_authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import (
"context"
"crypto"
"encoding/json"
"fmt"
"net/http"
Expand All @@ -12,6 +13,7 @@

"github.com/idfoundry/fapigo/extension"
"github.com/idfoundry/fapigo/internal/clientassertion"
"github.com/idfoundry/fapigo/internal/dpop"
"github.com/idfoundry/fapigo/internal/jose"
"github.com/idfoundry/fapigo/internal/par"
"github.com/idfoundry/fapigo/internal/pkce"
Expand Down Expand Up @@ -78,11 +80,6 @@
return AuthorizationSession{}, newError(ErrorInternal, "failed to derive PKCE challenge", err)
}

dpopThumbprint, err := c.dpopKeyThumbprint(ctx)
if err != nil {
return AuthorizationSession{}, newError(ErrorInternal, "failed to compute DPoP key thumbprint", err)
}

now := c.deps.Clock.Now()
params := map[string]string{
// client_id (RFC 6749 §4.1.1) is a required authorization-request
Expand All @@ -98,29 +95,22 @@
"nonce": nonce,
"code_challenge": challenge,
"code_challenge_method": "S256",
// dpop_jkt (RFC 9449 §10) commits the authorization code to this
// client's DPoP key at PAR time, rather than leaving that
// binding to whichever key first shows up at the token endpoint
// — closing an authorization-code-injection window and matching
// the key this client will actually present with in
// ExchangeCode.
"dpop_jkt": dpopThumbprint,
}
if len(req.ACRValues) > 0 {
params["acr_values"] = strings.Join(req.ACRValues, " ")
}

formParams, buildErr := c.buildPushedRequestForm(ctx, now, params, req.Extensions)
if buildErr != nil {
return AuthorizationSession{}, buildErr
var (
body []byte
parErr *Error
)
if c.cfg.PARDPoPBinding == PARDPoPBindingJKT {
body, parErr = c.pushAuthorizationRequestWithJKT(ctx, params, req.Extensions)
} else {
body, parErr = c.pushAuthorizationRequestWithDPoPProof(ctx, params, req.Extensions)
}

body, status, _, err := c.postForm(ctx, c.cfg.Endpoints.PushedAuthorizationRequest.String(), par.EncodeForm(formParams), nil)
if err != nil {
return AuthorizationSession{}, newError(ErrorInternal, "pushed authorization request failed", err)
}
if status != http.StatusCreated && status != http.StatusOK {
return AuthorizationSession{}, parErrorFromResponse(body)
if parErr != nil {
return AuthorizationSession{}, parErr
}

result, err := par.DecodeResult(body)
Expand Down Expand Up @@ -174,11 +164,110 @@
return thumbprint.String(), nil
}

// pushAuthorizationRequestWithJKT implements PARDPoPBindingJKT: declares
// this client's DPoP key via the plain "dpop_jkt" parameter (RFC 9449
// §10) — committing the authorization code to it, rather than leaving
// that binding to whichever key first shows up at the token endpoint —
// without proving possession of it until ExchangeCode presents a proof
// with the matching key.
func (c *Client) pushAuthorizationRequestWithJKT(ctx context.Context, params map[string]string, extensions extension.Values) ([]byte, *Error) {
dpopThumbprint, err := c.dpopKeyThumbprint(ctx)
if err != nil {
return nil, newError(ErrorInternal, "failed to compute DPoP key thumbprint", err)
}
params["dpop_jkt"] = dpopThumbprint

formParams, buildErr := c.buildPushedRequestForm(ctx, c.deps.Clock.Now(), params, extensions)
if buildErr != nil {
return nil, buildErr
}
body, status, _, err := c.postForm(ctx, c.cfg.Endpoints.PushedAuthorizationRequest.String(), par.EncodeForm(formParams), nil)
if err != nil {
return nil, newError(ErrorInternal, "pushed authorization request failed", err)

Check failure on line 186 in client/begin_authorization.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "pushed authorization request failed" 3 times.

See more on https://sonarcloud.io/project/issues?id=IDFoundry_FAPIgo&issues=AaBEUIFJMD5aIOx79xHl&open=AaBEUIFJMD5aIOx79xHl&pullRequest=169
}
if status != http.StatusCreated && status != http.StatusOK {
return nil, parErrorFromResponse(body)
}
return body, nil
}

// pushAuthorizationRequestWithDPoPProof implements PARDPoPBindingProof
// (the default): binds the authorization code to this client's DPoP key
// by presenting an actual proof at PAR — RFC 9449 §10.1's recommended
// mechanism — instead of the plain dpop_jkt parameter, retrying once on
// a use_dpop_nonce challenge. This mirrors sendTokenRequest's identical
// mechanic for the token endpoint: an authorization server that
// nonce-challenges DPoP proofs it verifies can now challenge this one
// too, since PAR is presenting one for the first time. A client
// assertion is exactly as single-use as a DPoP proof, so the retry
// rebuilds the whole form, not just the proof — same reasoning
// sendTokenRequest's own doc comment gives for the token endpoint.
func (c *Client) pushAuthorizationRequestWithDPoPProof(ctx context.Context, params map[string]string, extensions extension.Values) ([]byte, *Error) {
dpopSigner, _, err := c.newSigner(ctx, keys.DPoPProofSigning, c.cfg.Algorithms.DPoP)
if err != nil {
return nil, newError(ErrorInternal, "failed to resolve DPoP signing key", err)
}
parURL := c.cfg.Endpoints.PushedAuthorizationRequest.URL()

buildParForm := func() ([]byte, error) {
formParams, buildErr := c.buildPushedRequestForm(ctx, c.deps.Clock.Now(), params, extensions)
if buildErr != nil {
return nil, buildErr
}
return par.EncodeForm(formParams), nil
}
form, buildErr := buildParForm()
if buildErr != nil {
return nil, newError(ErrorInternal, "failed to build pushed authorization request", buildErr)
}

body, status, header, err := c.postParRequestWithDPoP(ctx, dpopSigner, &parURL, form, "")
if err != nil {
return nil, newError(ErrorInternal, "pushed authorization request failed", err)
}
if status == http.StatusCreated || status == http.StatusOK {
return body, nil
}

nonce := header.Get("DPoP-Nonce")
if nonce == "" || !isDPoPNonceError(body) {
return nil, parErrorFromResponse(body)
}
retryForm, buildErr := buildParForm()
if buildErr != nil {
return nil, newError(ErrorInternal, "failed to build pushed authorization request", buildErr)
}
body, status, _, err = c.postParRequestWithDPoP(ctx, dpopSigner, &parURL, retryForm, nonce)
if err != nil {
return nil, newError(ErrorInternal, "pushed authorization request failed", err)
}
if status != http.StatusCreated && status != http.StatusOK {
return nil, parErrorFromResponse(body)
}
return body, nil
}

// postParRequestWithDPoP signs a fresh DPoP proof (new iat and jti) for
// the pushed authorization request — no AccessToken/ath, since none
// exists yet at PAR time, matching how server/par.go's own dpop.Verify
// call never expects one either — and posts form to it.
func (c *Client) postParRequestWithDPoP(ctx context.Context, dpopSigner crypto.Signer, parURL *url.URL, form []byte, nonce string) ([]byte, int, http.Header, error) {
proof, err := dpop.CreateProof(dpop.ProofRequest{
Signer: dpopSigner, Algorithm: c.cfg.Algorithms.DPoP,
Method: http.MethodPost, URL: parURL, Now: c.deps.Clock.Now(),
Random: c.deps.Random, Nonce: nonce,
})
if err != nil {
return nil, 0, nil, fmt.Errorf("build DPoP proof: %w", err)
}
return c.postForm(ctx, parURL.String(), form, map[string]string{"DPoP": proof})
}

// buildPushedRequestForm builds the PAR endpoint's form body: a client
// assertion for authentication, plus either a signed request object
// (ProfileFAPISecurityWithMessageSigning) or the plain authorization
// parameters directly.
func (c *Client) buildPushedRequestForm(ctx context.Context, now time.Time, params map[string]string, extensions extension.Values) (map[string]string, *Error) {

Check failure on line 270 in client/begin_authorization.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=IDFoundry_FAPIgo&issues=AaBEUIFJMD5aIOx79xHm&open=AaBEUIFJMD5aIOx79xHm&pullRequest=169
assertionSigner, assertionKID, err := c.newSigner(ctx, keys.ClientAuthentication, c.cfg.Algorithms.ClientAuthentication)
if err != nil {
return nil, newError(ErrorInternal, "failed to resolve client authentication key", err)
Expand Down
3 changes: 3 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ func validateConfig(cfg Config) error {
if cfg.Profile != ProfileFAPISecurity && cfg.Profile != ProfileFAPISecurityWithMessageSigning {
return fmt.Errorf("client: config: profile is invalid")
}
if cfg.PARDPoPBinding != PARDPoPBindingProof && cfg.PARDPoPBinding != PARDPoPBindingJKT {
return fmt.Errorf("client: config: par_dpop_binding is invalid")
}

if !cfg.Algorithms.ClientAuthentication.IsValid() {
return fmt.Errorf("client: config: algorithms.client_authentication is required")
Expand Down
15 changes: 15 additions & 0 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,20 @@ func TestNewAcceptsValidConfig(t *testing.T) {
}
}

// A Config that never mentions PARDPoPBinding at all must build
// successfully and behave as PARDPoPBindingProof — RFC 9449 §10.1's
// recommended mechanism is the zero-value default, not a validation
// error, unlike Profile/Algorithms/Limits.
func TestNewAcceptsZeroValuePARDPoPBinding(t *testing.T) {
cfg := validConfig(t)
if cfg.PARDPoPBinding != client.PARDPoPBindingProof {
t.Fatalf("validConfig's zero-value PARDPoPBinding = %v, want PARDPoPBindingProof", cfg.PARDPoPBinding)
}
if _, err := client.New(cfg, validDependencies(t)); err != nil {
t.Fatalf("New: %v", err)
}
}

func TestNewRejectsInvalidConfig(t *testing.T) {
cases := map[string]func(*client.Config){
"zero issuer": func(c *client.Config) { c.Issuer = fapi.URL{} },
Expand All @@ -229,6 +243,7 @@ func TestNewRejectsInvalidConfig(t *testing.T) {
"zero http timeout": func(c *client.Config) { c.Limits.HTTPTimeout = 0 },
"zero max response bytes": func(c *client.Config) { c.Limits.MaxHTTPResponseBytes = 0 },
"zero max jose compact bytes": func(c *client.Config) { c.Limits.MaxJOSECompactBytes = 0 },
"invalid par dpop binding": func(c *client.Config) { c.PARDPoPBinding = client.PARDPoPBinding(99) },
"id_token key management set without content encryption": func(c *client.Config) {
c.Algorithms.IDTokenKeyManagement = fapi.RSAOAEP256
},
Expand Down
39 changes: 39 additions & 0 deletions client/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,39 @@ const (
ProfileFAPISecurityWithMessageSigning
)

// PARDPoPBinding selects how BeginAuthorization commits the eventual
// authorization code to this client's DPoP key at PAR time (RFC 9449
// §10.1 recognizes two mechanisms; an authorization server supporting
// both PAR and DPoP must accept either). Unlike Profile/Algorithms/
// Limits, whose zero value is deliberately invalid because there's no
// universally-preferable choice, this type's zero value is a real,
// meaningful default: RFC 9449 §10.1 itself recommends
// PARDPoPBindingProof over PARDPoPBindingJKT, so a Config that never
// mentions this field gets the recommended behavior for free rather
// than a validation error.
type PARDPoPBinding uint8

const (
// PARDPoPBindingProof (the default, zero value) sends an actual DPoP
// proof — not just its key's thumbprint — as this pushed
// authorization request's own "DPoP" header, binding the eventual
// authorization code to whichever key demonstrated possession here.
// RFC 9449 §10.1 recommends this over PARDPoPBindingJKT: it reuses
// the same proof-building this client already does at the token and
// resource endpoints (no separate thumbprint computation or
// parameter), and unlike a bare dpop_jkt claim, it's actual proof of
// possession at PAR time, not just a key identifier.
PARDPoPBindingProof PARDPoPBinding = iota

// PARDPoPBindingJKT instead declares the key via the plain
// "dpop_jkt" request parameter (RFC 9449 §10), without proving
// possession of it until the token endpoint. Every authorization
// server supporting DPoP at PAR must accept this mechanism too (RFC
// 9449 §10.1's own MUST) — kept for interop with a deployment that
// has a specific reason to prefer it.
PARDPoPBindingJKT
)

// Algorithms are the single algorithm this client uses for each signing
// operation it performs, and the single algorithm it expects the
// authorization server to use for each of its own. A closed
Expand Down Expand Up @@ -234,4 +267,10 @@ type Config struct {
// OIDC Core §5.3.2 exists to catch. Defaults to false, which
// preserves the exact-match behavior this package has always had.
TolerateUserInfoSubjectEqualsClientID bool

// PARDPoPBinding selects how BeginAuthorization commits this
// client's DPoP key at PAR time — see PARDPoPBinding's own doc
// comment. Defaults to PARDPoPBindingProof, RFC 9449 §10.1's own
// recommended mechanism.
PARDPoPBinding PARDPoPBinding
}
Loading
Loading