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
7 changes: 7 additions & 0 deletions cmd/conformance-as/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ func writeOAuthJSONError(w http.ResponseWriter, err error) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Set before writeRawOAuthError's own WriteHeader call — a header
// can't be added after that. Only ErrorUseDPoPNonce ever carries a
// nonce (Error.Nonce's own doc comment), so this is a no-op for
// every other rejection.
if srvErr.Nonce() != "" {
w.Header().Set("DPoP-Nonce", srvErr.Nonce())
}
writeRawOAuthError(w, srvErr.HTTPStatus(), string(srvErr.Code()), srvErr.PublicDescription())
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/conformance-as/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func main() {
keyOverride := flag.String("key", "", "override tls.key_file from the config file")
insecureHTTP := flag.Bool("insecure-http", false, "serve plaintext HTTP instead of TLS (loopback listen_addr only)")
accessTokenFormat := flag.String("access-token-format", string(AccessTokenFormatJWT), "access token format to issue/verify: jwt or opaque")
dpopNonceChallenge := flag.Bool("dpop-nonce-challenge", false, "require and rotate a DPoP nonce on /accounts and /userinfo (RFC 9449 §8/§9) — off by default, since the OIDF suite's own protected-resource caller may not retry on the challenge")
dpopNonceChallenge := flag.Bool("dpop-nonce-challenge", false, "require and rotate a DPoP nonce on /par, /token, /accounts and /userinfo (RFC 9449 §8/§9) — off by default, since the OIDF suite's own driver may not retry on the challenge")
flag.Parse()

if *configPath == "" {
Expand Down
3 changes: 3 additions & 0 deletions cmd/conformance-as/par.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ func parHandler(srv *server.Server) http.HandlerFunc {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if result.NextDPoPNonce != "" {
w.Header().Set("DPoP-Nonce", result.NextDPoPNonce)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(body)
Expand Down
16 changes: 11 additions & 5 deletions cmd/conformance-as/smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,11 +397,17 @@ func testSmokeAuthorizationCodeFlow(t *testing.T, format AccessTokenFormat) {

// TestSmokeUserInfoWithDPoPNonceChallenge confirms this binary's
// -dpop-nonce-challenge wiring actually interoperates with the client
// package's own nonce-retry logic (client/resource.go's
// ResourceClient.Do), not just each side's own unit tests in isolation:
// FetchUserInfo must succeed transparently even though the very first
// call is challenged and retried entirely inside the client, with no
// special handling from this test.
// package's own nonce-retry logic, not just each side's own unit tests
// in isolation. The flag now gates nonce-challenge on every DPoP proof
// this binary verifies — PAR and the token endpoint (server package,
// RFC 9449 §8) as well as /accounts and /userinfo (resource package,
// §9) — so CompleteAuthorization's own internal PAR/token exchange
// already exercises client.ExchangeCode's token-endpoint nonce retry
// (client/exchange_code.go's sendTokenRequest) before FetchUserInfo
// separately exercises ResourceClient.Do's. FetchUserInfo must succeed
// transparently even though the very first call is challenged and
// retried entirely inside the client, with no special handling from
// this test.
func TestSmokeUserInfoWithDPoPNonceChallenge(t *testing.T) {
h := newSmokeHarnessWithNonceChallenge(t, AccessTokenFormatJWT, true)
ctx := context.Background()
Expand Down
3 changes: 3 additions & 0 deletions cmd/conformance-as/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ func tokenHandler(srv *server.Server) http.HandlerFunc {
resp["refresh_token"] = result.RefreshToken.Reveal()
}

if result.NextDPoPNonce != "" {
w.Header().Set("DPoP-Nonce", result.NextDPoPNonce)
}
// RFC 6749 §5.1 requires this on every token response — checked by
// conformance suites, and worth doing correctly here even though
// fapitest's own blueprint (which tests wire content, not header
Expand Down
12 changes: 12 additions & 0 deletions cmd/conformance-as/wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,18 @@ func newServerMux(resolved ResolvedConfig, allowLoopbackHTTP bool, dpopNonceChal
Random: rand.Reader,
IdentityClaims: identityClaims,
}
// Off by default (main.go's -dpop-nonce-challenge flag) — same
// reasoning as the resource-side block below: client.ExchangeCode
// already retries a use_dpop_nonce challenge, but the OIDF suite's
// own driver isn't guaranteed to, so this stays opt-in. A separate
// nonce store from the resource side's: PAR/token (this server's
// own role, RFC 9449 §8) and /accounts, /userinfo (the resource
// role, §9) are logically distinct nonce spaces, even though this
// one demo binary happens to host both.
if dpopNonceChallenge {
srvCfg.Limits.DPoPNonceLifetime = dpopNonceLifetime
srvDeps.Nonces = memstore.NewNonceStore()
}
srv, err := server.New(srvCfg, srvDeps)
if err != nil {
return nil, err
Expand Down
6 changes: 6 additions & 0 deletions server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ type Limits struct {
// and extends how long past exp an artifact is still accepted. Zero
// means no tolerance.
MaxClockSkew time.Duration

// DPoPNonceLifetime bounds how long an issued DPoP nonce remains
// valid. Required only when Dependencies.Nonces is non-nil — see
// its own doc comment; New rejects a zero value in that case, but
// leaves it unvalidated when nonce-challenge support is disabled.
DPoPNonceLifetime time.Duration
}

// Config is this server's immutable configuration. It is copied by New;
Expand Down
17 changes: 16 additions & 1 deletion server/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ type Dependencies struct {
Clock Clock

// Random is the source of randomness for request_uri, interaction
// handle and authorization code generation.
// handle, authorization code and DPoP nonce generation.
Random io.Reader

// IdentityClaims resolves identity claim values (e.g. "name",
Expand All @@ -75,4 +75,19 @@ type Dependencies struct {
// which is a permitted response to the "claims" request parameter
// (see IdentityClaimsSource), not an error.
IdentityClaims IdentityClaimsSource

// Nonces persists DPoP nonces this server issues and consumes for
// requests to the PAR and token endpoints. Nil disables DPoP
// nonce-challenge support entirely (RFC 9449 §8) — like
// IdentityClaims, this is a genuinely optional field, not a
// security check this module treats as non-negotiable the way
// Revocation is: declining it is the normal, fully spec-compliant
// default. One shared store covers both endpoints — a nonce issued
// from a PAR response is valid at the token endpoint and vice
// versa, the same way resource.Dependencies.Nonces covers every
// protected-resource endpoint uniformly rather than one store per
// endpoint. Set it (and Config.Limits.DPoPNonceLifetime) to have
// this server challenge a DPoP proof carrying no valid nonce, and
// proactively reissue a fresh one on every successful call.
Nonces storage.NonceStore
}
17 changes: 17 additions & 0 deletions server/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ const (
// wrong with an authorization request (missing client_id, a
// malformed request_uri reference that doesn't even parse, etc.).
ErrorInvalidRequestURI ErrorCode = "invalid_request_uri"

// ErrorUseDPoPNonce indicates a DPoP proof presented to the PAR or
// token endpoint was otherwise valid but carried no nonce, or one
// this server didn't just issue (unknown, already consumed, or
// expired) — RFC 9449 §8's own error value, distinct from
// ErrorInvalidGrant/ErrorInvalidRequest (the request itself is
// fine; the caller just needs to retry with the nonce this error's
// own Nonce method returns). Only ever returned when
// Dependencies.Nonces is configured.
ErrorUseDPoPNonce ErrorCode = "use_dpop_nonce"
)

// Error is the error type every public Server method returns. Code and
Expand All @@ -35,6 +45,7 @@ type Error struct {
httpStatus int
description string
cause error
nonce string
}

func newError(code ErrorCode, httpStatus int, description string, cause error) *Error {
Expand All @@ -47,6 +58,12 @@ func (e *Error) Code() ErrorCode { return e.code }
// PublicDescription returns a short, safe-to-expose description.
func (e *Error) PublicDescription() string { return e.description }

// Nonce returns the nonce a caller should present on retry, alongside
// this error's own DPoP challenge — non-empty only when Code is
// ErrorUseDPoPNonce, in which case it belongs in the response's own
// DPoP-Nonce header (RFC 9449 §8).
func (e *Error) Nonce() string { return e.nonce }

// HTTPStatus returns the HTTP status code an adapter should respond
// with.
func (e *Error) HTTPStatus() int { return e.httpStatus }
Expand Down
80 changes: 80 additions & 0 deletions server/nonce.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package server

import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"time"

"github.com/idfoundry/fapigo/storage"
)

// dpopNonceSize is the byte length of a generated DPoP nonce — 256
// bits, matching this module's other generated identifiers (see
// generateInteractionHandle/par.GenerateRequestURI).
const dpopNonceSize = 32

func generateDPoPNonce(random io.Reader) (string, error) {
if random == nil {
random = rand.Reader
}
buf := make([]byte, dpopNonceSize)
if _, err := io.ReadFull(random, buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}

// issueDPoPNonce generates and persists a fresh nonce, valid from now
// for s.cfg.Limits.DPoPNonceLifetime. Only ever called once
// s.deps.Nonces is known to be non-nil.
func (s *Server) issueDPoPNonce(ctx context.Context, now time.Time) (string, error) {
nonce, err := generateDPoPNonce(s.deps.Random)
if err != nil {
return "", fmt.Errorf("server: generate dpop nonce: %w", err)
}
if err := s.deps.Nonces.Issue(ctx, storage.NonceIssuance{
Nonce: nonce, ExpiresAt: now.Add(s.cfg.Limits.DPoPNonceLifetime),
}); err != nil {
return "", fmt.Errorf("server: issue dpop nonce: %w", err)
}
return nonce, nil
}

// checkDPoPNonce enforces the nonce challenge (RFC 9449 §8) when
// s.deps.Nonces is configured: presented is the DPoP proof's own
// "nonce" claim (empty if absent) — shared by both the token endpoint
// and PAR, since one nonce store covers everything this server
// verifies (see Dependencies.Nonces's own doc comment). A missing,
// unknown, already-consumed or expired nonce is rejected with a
// freshly issued replacement attached (via *Error's own Nonce method)
// for the caller to retry with; a validly consumed nonce returns nil,
// letting the caller proceed.
//
// Called only when s.deps.Nonces != nil — every call site guards that,
// since this is the one dependency this package treats as genuinely
// optional rather than required-with-visible-opt-out (see
// Dependencies.Nonces's own doc comment).
func (s *Server) checkDPoPNonce(ctx context.Context, presented string, now time.Time) *Error {
valid := false
if presented != "" {
record, err := s.deps.Nonces.Consume(ctx, storage.NonceConsumption{Nonce: presented})
valid = err == nil && !now.After(record.ExpiresAt)
}
if valid {
return nil
}

fresh, err := s.issueDPoPNonce(ctx, now)
if err != nil {
return newError(ErrorServerError, 500, "failed to issue dpop nonce", err)
}
// RFC 9449 §8 token-endpoint nonce errors are ordinary RFC 6749
// §5.2 OAuth errors — 400, not the 401 WWW-Authenticate challenge
// resource.ErrorUseDPoPNonce uses for a protected-resource request.
challenge := newError(ErrorUseDPoPNonce, 400, "DPoP proof must carry a current nonce", nil)
challenge.nonce = fresh
return challenge
}
Loading
Loading