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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,12 @@ standing up an authorization server and resource server end to end,
including a runnable configuration you can start from.

For one mechanism at a time, [docs/guides](docs/guides/README.md) has
short guides to [DPoP](docs/guides/dpop.md),
[PAR](docs/guides/par.md) and [building a wallet or other native app
client](docs/guides/native-wallet.md).
short guides to [DPoP](docs/guides/dpop.md), [mutual
TLS](docs/guides/mtls.md), [PAR](docs/guides/par.md), [Message
Signing](docs/guides/message-signing.md), [CIBA](docs/guides/ciba.md),
[Rich Authorization Requests](docs/guides/rar.md), [OpenID
Federation](docs/guides/openid-federation.md), and [building a wallet or
other native app client](docs/guides/native-wallet.md).

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
Expand Down
5 changes: 5 additions & 0 deletions docs/guides/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ runnable demo. For wiring a whole deployment, start with
| [DPoP in Go](dpop.md) | Sender-constrained access tokens (RFC 9449): client, authorization server and resource server |
| [Pushed Authorization Requests in Go](par.md) | PAR (RFC 9126), and signed request objects under Message Signing |
| [A FAPI 2.0 wallet in Go](native-wallet.md) | Native app clients: redirects, client attestation, platform keys, relaunch, and token lifecycle |
| [Mutual TLS in Go](mtls.md) | Certificate client authentication and certificate-bound tokens (RFC 8705), the alternative to DPoP |
| [FAPI 2.0 Message Signing in Go](message-signing.md) | Signed request objects (JAR), signed authorization responses (JARM), and signed UserInfo |
| [CIBA in Go](ciba.md) | Decoupled authorization (FAPI-CIBA): poll and ping delivery, and the server's three steps |
| [Rich Authorization Requests in Go](rar.md) | Fine-grained `authorization_details` (RFC 9396): defining types, policies, consent and reading grants |
| [OpenID Federation in Go](openid-federation.md) | Trust chains, automatic client registration, and running a Trust Anchor or Intermediate |
140 changes: 140 additions & 0 deletions docs/guides/ciba.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# CIBA in Go

CIBA (OpenID Connect Client-Initiated Backchannel Authentication,
[CIBA]) lets a client start an authorization with no browser redirect at
all. A till, a call-centre agent or a budgeting app sends the
authorization server a
request naming the user. The server asks the user on a device of their
own, typically a banking app on their phone, and the client collects the
tokens once they decide. The user never types credentials into the
client's device.

FAPI-CIBA ([FAPI-CIBA]) profiles it for high-value APIs: the request is
always a signed request object, the client authenticates strongly, and
the tokens are sender-constrained. FAPIgo implements both decision
delivery modes the profile allows, poll and ping.

## Client: begin, then poll or wait for a ping

```go
session, err := c.BeginBackchannelAuthentication(ctx, client.BeginBackchannelAuthenticationRequest{
Scope: []string{"openid", "payments"},
LoginHint: "sam@example.com", // or LoginHintToken, or IDTokenHint: exactly one
BindingMessage: "Order 1042",
})
// ... later, no sooner than session.Interval() apart:
result, err := c.PollBackchannelAuthentication(ctx, session)
switch r := result.(type) {
case client.BackchannelAuthenticationPending:
retryLater(r.SlowDown) // SlowDown: the last poll came too soon, so back off
case client.BackchannelAuthenticationApproved:
useTokens(r.Tokens)
case client.BackchannelAuthenticationDenied:
reportDenied(r.Code, r.Description)
case client.BackchannelAuthenticationExpired:
// the user didn't decide in time: start again
}
```

`BeginBackchannelAuthentication` signs the request, authenticates as
the client and sends it. `PollBackchannelAuthentication` makes exactly
one attempt, so you poll on your own schedule (a job queue, a ticker)
rather than blocking a goroutine while a person decides. The session
survives restarts: `MarshalText` and `UnmarshalText` store it.

For ping delivery, set `Config.BackchannelTokenDeliveryMode` to
`storage.BackchannelTokenDeliveryModePing`. The server then calls your
notification endpoint once the user decides:

```go
func notify(w http.ResponseWriter, r *http.Request) {
n, err := client.ParseBackchannelNotification(r)
if err != nil {
http.Error(w, "malformed notification", http.StatusBadRequest)
return
}
session, ok := sessionFor(n.AuthReqID()) // your own lookup
if !ok || !n.Authenticates(session) {
http.Error(w, "unknown request or wrong token", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusNoContent)
go collect(session) // PollBackchannelAuthentication, after answering
}
```

`Authenticates` checks the notification's bearer token against the
`client_notification_token` the client sent, in constant time; CIBA
requires that before a ping is trusted.

## Authorization server: three calls around your user's decision

```go
func backchannelAuthentication(w http.ResponseWriter, r *http.Request) {
req, err := server.BeginBackchannelAuthenticationRequestFromHTTP(r)
if err != nil {
server.WriteError(w, err)
return
}
action, err := srv.BeginBackchannelAuthentication(r.Context(), req)
if err != nil {
server.WriteError(w, err)
return
}
switch a := action.(type) {
case server.BackchannelAuthenticationLocalError:
a.Error.WriteJSON(w)
case server.BackchannelInteractionRequired:
askTheUser(a.Handle, a.Interaction) // your app: notify the user's device
a.WriteJSON(w) // auth_req_id, expires_in, interval
}
}
```

1. **`BeginBackchannelAuthentication`** authenticates the client, refuses
one not registered for CIBA (`unauthorized_client`), verifies the
signed request, requires exactly one of `login_hint`,
`login_hint_token` and `id_token_hint`, and stores the request.
`a.Interaction` has what to show the user: the scope, the hints, the
binding message and any Rich Authorization Request details.
`LookupBackchannelInteraction` reads it back by handle later.
2. **`CompleteBackchannelAuthentication`** records the user's decision
(`server.Authorize(...)` or a denial) against the handle, once. For a
ping client it then calls `Dependencies.BackchannelNotifier`.
3. **`ExchangeBackchannelAuthentication`** serves the token endpoint's
CIBA grant (`TokenEndpointRequest.BackchannelTokenExchange()`):
`authorization_pending` until a decision, `slow_down` for a poll
sooner than `Limits.BackchannelAuthenticationPollInterval`,
`access_denied`, `expired_token` after
`Limits.BackchannelAuthenticationRequestLifetime`, and on approval
sender-constrained tokens, issued exactly once.

Set `Dependencies.Backchannel` (`memstore.NewBackchannelAuthenticationStore()`
to start) and `Dependencies.BackchannelNotifier`: `backchannelhttp.New`
sends pings through a hardened HTTP client, and `NoBackchannelNotifications{}`
declines them for a poll-only deployment. `Dependencies.BackchannelHints`
can refuse a hint naming nobody (`unknown_user_id`) before the request is
stored. A hint only says whose device to ask: the user's approval there
is what authenticates them. The binding message is free text the client
wrote, so show it as such, beside what you describe from the request
itself.

## See it running

[decoupled-checkout](../../examples/decoupled-checkout/README.md): a till
that pays by polling, and a budgeting app linked by ping.

- The server's endpoints are in
[`checkout/bank.go`](../../examples/decoupled-checkout/checkout/bank.go);
the polling till is
[`checkout/till.go`](../../examples/decoupled-checkout/checkout/till.go),
and the ping client is
[`checkout/pocketwise.go`](../../examples/decoupled-checkout/checkout/pocketwise.go).
- Its tour tries to charge more than was approved or charge twice, use
the till's token from another device, and collect tokens a second time
with the same `auth_req_id`; it also shows a misleading binding
message, and a client asking for more than it's allowed, refused
before the request reaches the phone.

[FAPI-CIBA]: https://openid.net/specs/openid-financial-api-ciba-ID1.html
[CIBA]: https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html
97 changes: 97 additions & 0 deletions docs/guides/message-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# FAPI 2.0 Message Signing in Go

The FAPI 2.0 Security Profile protects requests and responses in
transit. The Message Signing profile adds signatures, so each side can
prove later what the other one sent:

- the authorization request is a signed request object (JAR,
[RFC 9101]), so the authorization server holds the client's own
signature over exactly what was asked for;
- the authorization response is a signed JWT (JARM), so the client holds
the authorization server's signature over the code and `state` it
received.

This is what a payment needs when either party may later have to show
what was agreed. FAPIgo selects the profile with one setting on each
side.

## Selecting the profile

```go
// Authorization server.
cfg.Profile = server.ProfileFAPISecurityWithMessageSigning
cfg.Algorithms.RequestObject = server.AlgorithmSet{fapi.ES256} // accepted from clients
cfg.Algorithms.JARM = fapi.ES256 // signs authorization responses
cfg.Limits.MaxRequestObjectLifetime = time.Minute
cfg.Limits.JARMResponseLifetime = 90 * time.Second

// Client.
clientCfg.Profile = client.ProfileFAPISecurityWithMessageSigning
clientCfg.Algorithms.RequestObject = fapi.ES256
clientCfg.Algorithms.JARM = fapi.ES256
clientCfg.Limits.RequestObjectLifetime = time.Minute
clientCfg.Limits.MaxJARMResponseLifetime = 90 * time.Second
```

Each client is registered with the algorithm it signs request objects
with (`storage.RegisteredClientConfig.RequestObjectAlgorithm`). The
server's key manager signs authorization responses under
`keys.JARMSigning`, and `PublicJWKS` publishes that key; the client signs
request objects under `keys.RequestObjectSigning`. Under this profile the
server's `New` refuses a configuration without `Algorithms.JARM` and
`Limits.JARMResponseLifetime`, and the client's without its request
object and JARM algorithms and lifetimes.

## Client: still one call each way

`BeginAuthorization` signs the request object and pushes it; there is
nothing else to call. `PushedRequestEncoding` follows the profile by
default, and `PushedRequestEncodingRequestObject` sends a signed request
object under the Security Profile too, without switching responses to
JARM.

`CompleteAuthorization` (or `HandleAuthorizationResponse`) then expects
the callback's `response` parameter, and verifies it before reading
anything inside: the signature with the authorization server's key
(`Dependencies.IssuerKeys`, purpose `keys.JARMVerification`), using
`Algorithms.JARM` rather than the JWT's own header, then `iss`, `aud`
and expiry. Which form to expect comes from the profile, never from the
callback, so a plain response can't stand in for a signed one.

## Authorization server: what changes

- **PAR** accepts only a signed request object; plain parameters are
refused. The object must verify under the client's registered key and
algorithm, name this server as its audience, carry `nbf` (Message
Signing 搂5.3.1), be within `Limits.MaxRequestObjectLifetime`, be
unused before, and not carry a `request_uri` of its own.
- **The authorization response**, success or error, is a JWT signed with
`keys.JARMSigning`, addressed to the client and valid for
`Limits.JARMResponseLifetime`.
- **Metadata** advertises `require_signed_request_object` and the `jwt`
response mode.

## Signed UserInfo

Signed UserInfo responses are opt-in under either profile. On the
server, set `Algorithms.UserInfo` and `SignUserInfoResponse` signs the
claims your UserInfo handler serves, always adding `iss` and `aud`, and
encrypts them too for a client registered for encrypted UserInfo.
On the client, setting `Algorithms.UserInfo` makes `FetchUserInfo`
verify the response; `VerifyIssuerJWS` verifies any other
issuer-signed artifact with the same keys.

## See it running

- [payment-consent](../../examples/payment-consent/README.md) runs the
Message Signing profile end to end. Its protocol trace
([`payment/trace.go`](../../examples/payment-consent/payment/trace.go))
shows each signed request object and authorization response decoded,
and its attack lab tampers with the signed request and presents a
forged authorization response, both refused. The server configuration
is in [`payment/bank.go`](../../examples/payment-consent/payment/bank.go).
- [identity-check](../../examples/identity-check/README.md) serves signed
and encrypted UserInfo responses.
- [PAR in Go](par.md), which every request object travels through.

[RFC 9101]: https://www.rfc-editor.org/rfc/rfc9101
111 changes: 111 additions & 0 deletions docs/guides/mtls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Mutual TLS in Go

OAuth 2.0 Mutual-TLS ([RFC 8705]) does two separate jobs with a TLS
client certificate:

- **Client authentication** (搂2): the client proves who it is by the
certificate it presents, instead of a signed client assertion. Either
the certificate chains to a CA the authorization server trusts and
names the registered subject (`tls_client_auth`), or it's a
self-signed certificate the server has registered by thumbprint
(`self_signed_tls_client_auth`).
- **Certificate-bound access tokens** (搂3): the access token carries
the certificate's thumbprint (`cnf.x5t#S256`), and a resource server
accepts it only over a connection presenting that certificate.

FAPI 2.0 requires sender-constrained access tokens, by mTLS or by
[DPoP](dpop.md). FAPIgo supports both; the two jobs are independent, so
a client can, for example, authenticate with `private_key_jwt` and still
get certificate-bound tokens.

## Client: a certificate on the HTTP client

FAPIgo never holds the certificate's private key. It's configured on
the `*http.Client` you pass as `Dependencies.HTTP`:

```go
endpoints := discovered.Endpoints
// RFC 8705 搂5: send certificate-carrying requests to the mTLS aliases.
discovered.MTLSEndpointAliases.ApplyForClientAuth(&endpoints) // PAR and token
discovered.MTLSEndpointAliases.ApplyForSenderConstrain(&endpoints) // token, CIBA, revocation

c, err := client.NewFromDiscovery(discovered, client.Config{
// ClientID, Profile, Limits ...
Endpoints: endpoints,
ClientAuthMethod: storage.ClientAuthMethodTLSClientAuth,
SenderConstrain: storage.SenderConstrainMTLS,
}, client.Dependencies{
HTTP: &http.Client{Transport: &http.Transport{
TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{clientCert}},
}},
// Clock, Random ...
})
```

With `SenderConstrainMTLS`, the client sends no DPoP proofs, and
`ProtectedResource(tokens).Do` (or `ClientCredentialsResource` for the
client credentials grant) sends `Authorization: Bearer <token>` over the
same certificate-presenting transport. A client that only authenticates
with mTLS, and signs nothing else, needs no `Dependencies.Keys`.

## Authorization server: trust, registration and the certificate

```go
srv, err := server.New(server.Config{
// Issuer, Endpoints, Profile, Algorithms, Limits ...
MTLSEndpoints: server.MTLSEndpoints{Token: mtlsTokenURL},
}, server.Dependencies{
// RFC 8705 搂2.1.1: verify the chain yourself, and check revocation.
ClientCertificateTrust: server.TrustedClientCAs{
Roots: rootCAs,
Intermediates: issuingCAs, // revocation-checked, unlike Roots
Revocation: server.ClientCertificateCRLs{Lists: currentCRLs},
},
// ...
})
```

- **Registration.** `ClientAuthMethodTLSClientAuth` matches
`ExpectedSubjectDN`; its siblings `ClientAuthMethodTLSClientAuthSANDNS`,
`...SANURI`, `...SANIP` and `...SANEmail` match a subject alternative
name instead. `ClientAuthMethodSelfSignedTLSClientAuth` matches
`ExpectedCertificateThumbprint` and needs no chain trust. Set
`SenderConstrain: storage.SenderConstrainMTLS` for bound tokens.
- **Chain trust is required.** `Dependencies.ClientCertificateTrust` has
no default: `TrustedClientCAs` verifies the chain and asks its
`Revocation` (CRLs, your own OCSP check, or an explicit
`NoClientCertificateRevocationCheck{}`), and
`NoClientCertificateChainTrust{}` declares that something before
FAPIgo already verified the chain (your TLS listener's `ClientCAs`, or
a gateway). A missing or stale CRL fails closed.
- **The certificate.** The `鈥romHTTP` request constructors read it from
the request's own TLS connection (`PeerCertificateFromHTTP`). Behind a
proxy that terminates TLS, set it yourself, with
`TokenEndpointRequest.SetPeerCertificate` or the `PeerCertificate`
field of the other request types.
- **Metadata** advertises `mtls_endpoint_aliases`,
`tls_client_certificate_bound_access_tokens`, and the mTLS
authentication methods, once `MTLSEndpoints` is set.

## Resource server: the token follows the certificate

`resource.Verifier` checks that the certificate on the request matches
the token's `cnf.x5t#S256`. `resource.VerifyRequestFromHTTP` reads it
from the connection; behind a proxy, set `VerifyRequest.PeerCertificate`.
The resource server checks the binding, not revocation, so keep
`Limits.AccessTokenLifetime` short.

## See it running

- [payroll-run](../../examples/payroll-run/README.md): a payroll
provider pays staff through its bank's API with `tls_client_auth` and
certificate-bound tokens. Its attack lab gets a token without the
provider's key (self-signed, wrong-CA, expired, revoked and
wrong-subject certificates) and steals the token; a rotation panel
shows tokens following the certificate. The bank's wiring is in
[`payroll/bank.go`](../../examples/payroll-run/payroll/bank.go), the
client's in
[`payroll/ledgerline.go`](../../examples/payroll-run/payroll/ledgerline.go).
- [GETTING_STARTED](../../GETTING_STARTED.md): wiring all three roles.

[RFC 8705]: https://www.rfc-editor.org/rfc/rfc8705
Loading
Loading