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
30 changes: 23 additions & 7 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ fapigo/ // package fapi: shared value types only
├── client/ // RP public API
├── server/ // AS public API
├── resource/ // RS verification API
├── serverresource/ // a resource.Verifier matching a server in the same process
├── federation/ // OpenID Federation 1.0 leaf-entity primitives — a shared
│ // subsystem package like keys/storage, not a fourth role
├── extension/ // shared custom-parameter & RAR definitions
Expand Down Expand Up @@ -82,13 +83,20 @@ fapigo/ // package fapi: shared value types only
│ ├── httperror/ // shared error-type (server/resource/federation) mechanical bookkeeping
│ ├── critical/ // JWS/JWE "crit" header parameter check (RFC 7515/7516)
│ ├── authchallenge/ // WWW-Authenticate challenge parsing (RFC 9110 §11)
│ ├── grantrevocation/ // the grant_id claim and revocation key shared by server and resource
│ ├── canonical/ // URL/JSON canonicalization
│ ├── strictjson/ // case-sensitive JSON member names for JOSE/metadata decoding
│ └── validation/ // generic strict-parsing helpers
└── conformance/
├── client/ // OIDF RP/client test plan config + scripts
├── server/ // OIDF AS test plan config + scripts
└── resource/ // RS verification test vectors (not covered by OIDF)
├── cmd/
│ ├── conformance-as/ // the AS (and its RS) the OIDF suite tests, built on server/resource
│ ├── conformance-client/ // the RP the OIDF suite tests, built on client
│ └── conformance-federation-trust-anchor/ // a Trust Anchor for the federation test plans
├── conformance/
│ ├── client/ // OIDF RP/client test plan config + scripts
│ ├── server/ // OIDF AS test plan config + scripts
│ └── resource/ // RS verification test vectors (not covered by OIDF)
└── examples/ // runnable demos, each its own module, public API only (see examples/README.md)
└── internal/demokit/ // the demos' shared local TLS, CA and host routing
```

## Design rules
Expand Down Expand Up @@ -140,7 +148,10 @@ that operation.
`BeginAuthorization` → `AuthorizationSession` (opaque, carries the
authorization URL and a session handle) → `HandleAuthorizationResponse`
→ `ExchangeCode`, or the combined `CompleteAuthorization` so a caller
cannot skip callback validation before exchanging a code. The
cannot skip callback validation before exchanging a code; then
`RefreshTokens` for a new access token from the same grant. CIBA
(`BeginBackchannelAuthentication` → `PollBackchannelAuthentication`)
and `RequestClientCredentialsToken` are the other two flows. The
intermediate `ValidatedAuthorizationResponse` is opaque and can only be
constructed by `HandleAuthorizationResponse`.

Expand Down Expand Up @@ -275,8 +286,13 @@ a PAR body or similar wire-level payload.
### 7. Server: a state machine over client-generated artefacts

`PushAuthorizationRequest` → `BeginAuthorization` → `CompleteAuthorization`
→ `ExchangeAuthorizationCode` → `RefreshAccessToken`, plus `Metadata` and
`PublicJWKS`. The server only ever verifies; it must not expose
→ `ExchangeAuthorizationCode` → `RefreshAccessToken`, plus `RevokeGrant`
to withdraw a grant, the CIBA and client credentials grants, `Metadata`
and `PublicJWKS`. `AuthenticateAttestedClient` and
`VerifyTokenRequestBinding` let an embedder serve its own grant type at
the same token endpoint; they fit this rule because each is one
endpoint's own check, run exactly as the server's own grants run it,
not a generic primitive. The server only ever verifies; it must not expose
`client`'s request-building functionality, and it must not expose a
generic `HandleRequest(map[string]any)` or bare `ValidateJWT(token
string)` — which claims, algorithms, audiences, replay checks and key
Expand Down
59 changes: 46 additions & 13 deletions GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ cfg := server.Config{
Profile: server.ProfileFAPISecurity, // or ProfileFAPISecurityWithMessageSigning
Algorithms: server.RecommendedAlgorithms(),
Limits: server.RecommendedLimits(),
Assurance: server.AssuranceDevelopment, // AssuranceProduction once your real deps are ready
Assurance: server.AssuranceDevelopment, // AssuranceProduction once your real deps are ready
}
```

Expand Down Expand Up @@ -143,9 +143,10 @@ deps := server.Dependencies{
AccessTokens: accessTokens,
// Lets this server revoke a token it already issued when it later
// detects the authorization code that produced it being reused
// (RFC 6749 §4.1.2). Pass server.NoRevocation{} instead to
// explicitly decline — see its doc comment for why declining must
// be a conscious choice, not a silent default.
// (RFC 6749 §4.1.2), and revoke a whole grant with RevokeGrant
// (step 5). Pass server.NoRevocation{} instead to explicitly
// decline — see its doc comment for why declining must be a
// conscious choice, not a silent default; RevokeGrant then refuses.
Revocation: memstore.NewRevocationStore(),
// Whether this package re-verifies an mTLS client certificate's
// chain itself. NoClientCertificateChainTrust{} declines — right when
Expand Down Expand Up @@ -195,9 +196,12 @@ switch a := action.(type) {
case server.InteractionRequired:
// Render whatever UI you want here — a password form, an SSO
// redirect, WebAuthn, a magic link. a.Interaction carries the
// client ID, requested scope, and an unauthenticated login_hint
// to help pre-fill it. a.Handle must come back to
// CompleteAuthorization once the user is done.
// client ID, requested scope, an unauthenticated login_hint to
// help pre-fill it, and the client's authentication requirements:
// ACRValues (how strongly to authenticate) and, when HasMaxAge,
// MaxAge (how recent the authentication must be — re-authenticate
// a user whose existing session is older). a.Handle must come back
// to CompleteAuthorization once the user is done.
case server.RedirectResponse:
// no interaction needed — redirect the browser to a.Destination
case server.LocalErrorResponse:
Expand All @@ -211,7 +215,10 @@ verified their SSO assertion, whatever), conclude the interaction:
```go
subjectID, _ := server.NewSubjectID(theRealAuthenticatedUserID)
subject, _ := server.NewAuthenticatedSubject(subjectID)
authCtx, _ := server.NewAuthenticationContext(time.Now(), acr, amr)
// When the user actually authenticated — not time.Now() when you reuse
// an existing session: CompleteAuthorization answers login_required
// when this is older than the client's max_age.
authCtx, _ := server.NewAuthenticationContext(userAuthenticatedAt, acr, amr)

result := server.Authorize(subject, authCtx, server.GrantedAuthorization{
Scope: whateverScopesTheUserActuallyApproved,
Expand All @@ -223,6 +230,10 @@ result := server.Authorize(subject, authCtx, server.GrantedAuthorization{
// parameter (a.Interaction.RequestedClaims) that the user agreed to
// release. Nil releases none.
ApprovedIdentityClaims: whicheverRequestedClaimsTheUserApproved,
// Optional: your own ID for this grant, to withdraw it later with
// srv.RevokeGrant — for a "connected apps" page, say. It refuses
// the grant's refresh token and every access token issued from it.
GrantID: yourOwnIDForThisGrant,
})
// or: server.Deny("user declined") / server.AuthenticationFailed("bad credentials")

Expand Down Expand Up @@ -306,19 +317,32 @@ resolved there.
`server.Server` has no built-in HTTP layer — every endpoint is a plain
handler you write, calling the corresponding method
(`PushAuthorizationRequest`, `ExchangeAuthorizationCode`,
`RefreshAccessToken`, `Metadata`, `PublicJWKS`). `cmd/conformance-as/router.go`
`RefreshAccessToken`, `Metadata`, `PublicJWKS`, and, when you enable
them, `RequestClientCredentialsToken` and the CIBA methods). `cmd/conformance-as/router.go`
shows the complete routing table on a bare `net/http.ServeMux` — no
framework dependency required, though nothing here stops you from using
one. `cmd/conformance-as/token.go`, `par.go`, `metadata.go` and
`jwks.go` are the corresponding handler implementations to read
alongside `authorize.go`.

**Serving a grant this package doesn't.** To serve another grant type
at the same token endpoint — OpenID4VCI's `pre-authorized_code`, say —
read the request once with `server.TokenEndpointRequestFromHTTP` and
switch on `GrantType()`. For your own grant, take its form with
`Parameters()`, authenticate the client with `AuthenticateAttestedClient`
(`req.AttestedClientAuthentication()`), and check its DPoP proof or
client certificate with `VerifyTokenRequestBinding`: the same checks,
and the same replay records, as this package's own grants. List the
grant type in `Config.AdditionalGrantTypes` so `Metadata` advertises
it. Whether the client may use the grant, and the grant itself, stay
yours.

## 7. Wire the resource server: verifying access tokens

`resource.Verifier` is FAPI 2.0's third role, a deliberately separate
package from `server` rather than a mode of it — verifying a presented
access token is inseparable from the HTTP request it arrived on, so
`Verify(ctx, VerifyRequest{Method, URL, Authorization, DPoPProofs})` is
`Verify(ctx, VerifyRequest{Method, URL, Authorization, DPoPProofs, PeerCertificate})` is
the only entry point, never a bare `VerifyJWT`. In a real deployment
this is usually a wholly separate service protecting its own API;
`cmd/conformance-as` only co-locates it in the same binary because the
Expand Down Expand Up @@ -387,9 +411,9 @@ that AS's own metadata publishes them.

`Dependencies.Revocation` needs the same care as the access-token
format: if the AS revokes a token on detected authorization-code reuse
(RFC 6749 §4.1.2 — step 4's `Revocation` field), this resource server
must see that revocation too, or it will keep accepting a token the AS
has already disowned. Wire it to the *same* `RevocationSink`/
(RFC 6749 §4.1.2 — step 4's `Revocation` field), or revokes a whole
grant with `RevokeGrant`, this resource server must see that revocation
too, or it will keep accepting a token the AS has already disowned. Wire it to the *same* `RevocationSink`/
`RevocationChecker` pair the AS uses — `memstore.NewRevocationStore()`
already implements both, if co-located — or `resource.NoRevocation{}`
to explicitly decline (matching `server.NoRevocation{}`'s own
Expand All @@ -414,6 +438,8 @@ authCtx, err := verifier.Verify(ctx, resource.VerifyRequest{
URL: protectedResourceURL, // this endpoint's own fixed external URL — see below, never r.URL
Authorization: r.Header.Get("Authorization"),
DPoPProofs: r.Header.Values("DPoP"), // see below — never r.Header.Get("DPoP")
// The TLS client certificate, for an mTLS-bound access token.
PeerCertificate: resource.PeerCertificateFromHTTP(r),
})
```

Expand Down Expand Up @@ -445,6 +471,13 @@ which silently returns only the first of several duplicate headers.
§7.1), so there's no adapter-side check to write here — unlike an
older version of this library, which left that check to the caller.

`PeerCertificate` is the TLS client certificate the request arrived
with: an access token bound to a certificate (RFC 8705) is refused
without it. `resource.PeerCertificateFromHTTP(r)` reads it from `r.TLS`;
behind a proxy that terminates TLS, set it from however the proxy
forwards the certificate instead. Leave it out only if no client of
this API uses mTLS-bound tokens.

That's the whole surface: `resource.Verifier` has no other public entry
point. Everything above `Verify` — routing, and what the protected API
actually returns — is your own handler, same as step 5's login flow was
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ variants and OAuth 2.0 attestation-based client authentication.
- private_key_jwt client authentication
- OAuth 2.0 Attestation-Based Client Authentication, including HAIP 1.0 x5c attester certificate chains
- JAR / JARM · RAR (RFC 9396) · CIBA (poll & ping delivery)
- Refresh tokens (not rotated, per FAPI 2.0) and whole-grant revocation
- OpenID Connect: the `claims` parameter with per-claim consent, `acr_values` and enforced `max_age`, signed and encrypted ID tokens and UserInfo
- Grants you serve yourself at the token endpoint (OpenID4VCI's `pre-authorized_code`, say), with the server's own client authentication and DPoP/mTLS checks
- OpenID Federation 1.0 (trust chains, automatic client registration, trust marks)
- OpenID Certified™ for OP, RP and FAPI-CIBA OP conformance profiles — see below

Expand Down Expand Up @@ -93,6 +96,10 @@ See [GETTING_STARTED.md](GETTING_STARTED.md) for a full walkthrough of
standing up an authorization server and resource server end to end,
including a runnable configuration you can start from.

Six runnable demos show these end to end, each with a guided tour and an
attack lab; [examples/README.md](examples/README.md) maps every capability
to the demo that shows it.

[examples/federated-union](examples/federated-union/README.md) is a
runnable demo: three fictional countries' identity federations joined
into one OpenID Federation, with cross-border sign-in, automatic
Expand Down
11 changes: 7 additions & 4 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,18 @@ the user again.
have passed since the user last actively authenticated, "the OP MUST
attempt to actively re-authenticate the End-User". The server used to
ignore `max_age`. It now validates it at the pushed authorization
request (a malformed value is `invalid_request`), surfaces it as
request (a value that isn't a whole number of seconds from 0 to 100
years is `invalid_request`), surfaces it as
`InteractionRequest.MaxAge`/`HasMaxAge`, and has `CompleteAuthorization`
answer the client with `login_required` when the authentication time
passed to `NewAuthenticationContext` is older than that.
passed to `NewAuthenticationContext` is older than that, allowing
`Limits.MaxClockSkew`.

**What to change:** when `InteractionRequest.HasMaxAge` is set and your
user's last authentication is older than `MaxAge`, authenticate them
again before calling `Authorize`. A `max_age` of 0 asks for a fresh
authentication every time. `InteractionRequest.ACRValues` now carries
again before calling `Authorize`, and pass the time they actually
authenticated, never `time.Now()` for an existing session. A `max_age`
of 0 asks for a fresh authentication every time. `InteractionRequest.ACRValues` now carries
the client's `acr_values` too, for deciding how strongly to
authenticate. It's a request, not a requirement.

Expand Down
18 changes: 12 additions & 6 deletions client/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// The package exposes workflow methods (BeginAuthorization,
// HandleAuthorizationResponse, ExchangeCode, CompleteAuthorization, for
// CIBA BeginBackchannelAuthentication and PollBackchannelAuthentication,
// and RefreshTokens to redeem a refresh token either issued)
// and RefreshTokens to redeem the refresh token either flow issued)
// rather than low-level JWT, PAR or DPoP primitives — those live under internal/
// and are composed here behind a state machine that a caller cannot drive
// out of order. In particular, only this package may construct request
Expand All @@ -14,10 +14,11 @@
//
// OpenID Connect identity (an ID token, Subject, IDTokenClaims) is
// entirely optional and driven purely by what the authorization server
// actually granted, never assumed by this package: ExchangeCode and
// PollBackchannelAuthentication populate TokenSet.IDToken/Subject/
// IDTokenClaims only when the token response actually carried an
// id_token (which, per the FAPI 2.0 authorization server this package
// actually granted, never assumed by this package: ExchangeCode,
// PollBackchannelAuthentication and RefreshTokens populate
// TokenSet.IDToken/Subject/IDTokenClaims only when the token response
// actually carried an id_token (RefreshTokens keeps the original's when
// a refresh returns none) (which, per the FAPI 2.0 authorization server this package
// targets, happens exactly when "openid" was included in the granted
// scope — see server's own package doc comment for that side of the
// contract) and leave TokenSet.HasIDToken false otherwise, which is a
Expand Down Expand Up @@ -53,7 +54,12 @@
// embedder registering with an authorization server (out of band; this
// package has no dynamic client registration flow) doesn't have to
// hand-roll RFC 7517 JWK encoding for whatever it configured
// Dependencies.Keys/Dependencies.Decryption with.
// Dependencies.Keys/Dependencies.Decryption with. ClientAttestationHeaders,
// likewise, hands an embedder the attestation-based client
// authentication headers for a request it sends itself — OpenID4VCI's
// pre-authorized_code token request, say — built by the same code as
// this package's own requests, rather than a second copy of the PoP
// format.
//
// client must not import server. Where both roles need the same wire
// format or cryptographic operation, that logic belongs in internal/ and
Expand Down
Loading
Loading