diff --git a/README.md b/README.md index 54ebe915..c1e77116 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/guides/README.md b/docs/guides/README.md index e9e476c6..56dd189a 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -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 | diff --git a/docs/guides/ciba.md b/docs/guides/ciba.md new file mode 100644 index 00000000..c4fed651 --- /dev/null +++ b/docs/guides/ciba.md @@ -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 diff --git a/docs/guides/message-signing.md b/docs/guides/message-signing.md new file mode 100644 index 00000000..8d14c727 --- /dev/null +++ b/docs/guides/message-signing.md @@ -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 diff --git a/docs/guides/mtls.md b/docs/guides/mtls.md new file mode 100644 index 00000000..dc868a6c --- /dev/null +++ b/docs/guides/mtls.md @@ -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 ` 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 `…FromHTTP` 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 diff --git a/docs/guides/openid-federation.md b/docs/guides/openid-federation.md new file mode 100644 index 00000000..4576fc9b --- /dev/null +++ b/docs/guides/openid-federation.md @@ -0,0 +1,144 @@ +# OpenID Federation in Go + +[OpenID Federation 1.0] lets parties that have never met trust each other +through a shared authority. Each entity publishes a signed Entity +Configuration about itself; superiors (Intermediates, and at the top a +Trust Anchor) publish signed Subordinate Statements about the entities +they vouch for, with metadata policy those entities must follow. To +trust a peer, you walk its Trust Chain up to a Trust Anchor you already +trust and apply every policy on the way. + +The payoff for OAuth is that a client needs no registration step: with +automatic registration (§12.1), it presents its own Entity Identifier as +its `client_id`, and the authorization server resolves its chain and +takes its registration from the resolved metadata. + +## Authorization server: publish, and accept federated clients + +```go +cfg := server.Config{ + // Issuer, Endpoints, Profile, Algorithms, Limits ... + Federation: server.FederationConfig{ + EntityID: "https://id.example.com", + AuthorityHints: []string{"https://federation.example.org"}, + Lifetime: 24 * time.Hour, + Algorithm: fapi.ES256, // a keys.FederationEntitySigning key in Dependencies.Keys + }, + AutomaticRegistration: server.AutomaticRegistrationConfig{ + TrustAnchors: []federation.TrustAnchor{anchor}, + AllowedScopes: []string{"openid"}, + MaxPathLength: 4, + MaxAuthorityHints: 5, + MaxStatementLifetime: 48 * time.Hour, + MaxClockSkew: 30 * time.Second, + MaxCacheAge: 10 * time.Minute, + }, +} +``` + +Serve your Entity Configuration at `federation.WellKnownPath`. You build +the metadata, typically your server's own `Metadata` as +`openid_provider`, plus `federation_entity`, and the server signs it: + +```go +token, err := srv.EntityConfiguration(ctx, map[string]json.RawMessage{ + "openid_provider": providerMetadata, + "federation_entity": entityMetadata, +}) +// ... +federation.WriteEntityStatement(w, token) +``` + +With `AutomaticRegistration.TrustAnchors` set, an unknown `client_id` +that is an `https` Entity Identifier is resolved through its Trust Chain, +and its `openid_relying_party` metadata becomes its registration. +Outbound fetches go through `Dependencies.FederationHTTP`, which `New` +requires once trust anchors are set. Statically registered clients +always take priority. What a client may do +beyond the authorization code flow is your grant, never its own metadata's: +`AllowedScopes`, `AllowsClientCredentialsGrant`, `AllowsCIBA` and +`AllowedClientAuthMethods`. A resolved registration is cached for at most +`MaxCacheAge`, so a superior that stops vouching for a client takes +effect within that time. `OnResolutionFailure` tells your operator why a +client was refused; the client itself only sees `invalid_client`. + +## Relying party: be resolvable, and find providers + +A federated client's `client_id` is its Entity Identifier, and it pushes +a signed request object, which is how it proves control of the keys in +its resolved metadata (§12.1.1.1): + +```go +c, err := client.NewFromDiscovery(discovered, client.Config{ + ClientID: fapi.ClientID("https://bank.example.com"), + PushedRequestEncoding: client.PushedRequestEncodingRequestObject, + // RedirectURI, Profile, Algorithms, Limits ... +}, deps) +``` + +Publish your own Entity Configuration with `Client.EntityConfiguration` +(set `Config.Federation`), with an `openid_relying_party` object +describing your redirect URIs, keys and authentication method +(`federation.OpenIDRelyingPartyMetadata` is a typed form of it). To find +a provider through the federation rather than plain discovery, +`client.DiscoverViaFederation` resolves the provider's Trust Chain with a +`federation.Resolver` and returns the same `DiscoveredMetadata`. + +## Trust Anchors and Intermediates + +A superior signs its own Entity Configuration with `federation.SelfIssuer` +and a Subordinate Statement for each entity it vouches for with +`federation.SubordinateIssuer`, carrying that entity's keys, metadata +policy and constraints. Serve the statements from your +`federation_fetch_endpoint`: + +```go +func fetch(w http.ResponseWriter, r *http.Request) { + sub, err := issuer.SubjectFromFetchRequest(r) + if err != nil { + federation.WriteError(w, err) + return + } + token, err := issuer.SubordinateStatement(federation.SubordinateStatementParams{ + Subject: sub, + JWKS: jwksOf(sub), + MetadataPolicy: policyFor(sub), + SourceEndpoint: fetchEndpoint, + }) + // ... + federation.WriteEntityStatement(w, token) +} +``` + +You keep the list of subordinates: the package signs statements, but +doesn't store who your subordinates are. + +## What's checked for you + +`federation.Resolver` validates every statement's signature and expiry at +each hop, and applies: + +- **Metadata policy** from every superior, merged top-down by entity + type, parameter and operator (§6.1.4.1), refusing a combination that + conflicts or uses an operator marked critical that it doesn't know. +- **Constraints**: each statement's `max_path_length`, + `naming_constraints` and `allowed_entity_types`. +- **Bounded fetching**: `Limits.MaxPathLength` and + `Limits.MaxAuthorityHints` cap one resolution at a fixed number of + outbound fetches, since automatic registration resolves a `client_id` + chosen by an unauthenticated caller. +- **Strict JSON**: entity metadata and keys are decoded case-sensitively, + so a member that differs only in case (`REDIRECT_URIS`) can't slip past + a policy written for the real one. + +## See it running + +- [federated-union](../../examples/federated-union/README.md): three + countries under one Trust Anchor, identity providers registering + services automatically, Union and national policy combined, and a + suspended authority. The entities are in + [`union/entity.go`](../../examples/federated-union/union/entity.go), + the providers in [`union/idp.go`](../../examples/federated-union/union/idp.go), + and the services in [`union/rp.go`](../../examples/federated-union/union/rp.go). + +[OpenID Federation 1.0]: https://openid.net/specs/openid-federation-1_0.html diff --git a/docs/guides/rar.md b/docs/guides/rar.md new file mode 100644 index 00000000..29c0f271 --- /dev/null +++ b/docs/guides/rar.md @@ -0,0 +1,129 @@ +# Rich Authorization Requests in Go + +A scope says what kind of access a client wants ("payments"). Rich +Authorization Requests ([RFC 9396]) say exactly what: one payment of +EUR 129.00 to a named account, or read access to the accounts the +customer picks. The client sends an `authorization_details` array of +typed JSON objects, the user approves those details (or a narrower +version), and the access token carries what was granted. + +FAPIgo validates the details at every step against types you define in +Go: when the client asks, when the user approves, and when a resource +server reads the token. + +## Define a type + +```go +type paymentInitiation struct { + InstructedAmount struct { + Currency string `json:"currency"` + Amount string `json:"amount"` + } `json:"instructedAmount"` + CreditorName string `json:"creditorName"` +} + +var paymentType = extension.RARDefinition[paymentInitiation]{ + Type: "payment_initiation", MaxObjects: 1, MaxBytesPerObject: 1024, + Validate: func(p paymentInitiation) error { /* your own checks */ return nil }, + // ValidateGrant nil: approved exactly as asked, or not at all. +} + +registry, err := extension.NewRARRegistry(4096, 4, paymentType) // total bytes, nesting depth +``` + +`Validate` adds your own checks to the type's shape. `ValidateGrant` +decides what counts as an acceptable narrowing at approval (a lower +amount, fewer accounts); left nil, a granted object must match what was +requested exactly. + +## Authorization server: register, gate, approve + +Set `server.Config.RAR` to the registry; without it, an +`authorization_details` parameter is refused outright. Then: + +- **List each client's types** in + `storage.RegisteredClientConfig.AuthorizationDetailsTypes`. Any other + type is refused with `invalid_authorization_details`, and an empty list + allows none. `Server.CheckClientRegistration` finds a listed type the + registry doesn't know, usually a typo. +- **Set a policy per grant**: `Dependencies.AuthorizationCodeRARPolicy`, + `CIBARARPolicy` or `ClientCredentialsRARPolicy`. It sees each request + as it arrives, before anyone is asked to approve it (for client + credentials, there's no one to ask, so it decides alone). + `server.AllowRequestedAuthorizationDetails{}` + passes what a client's registered types allow; supply your own + `RARPolicy` to check the details themselves, such as a payment limit. + A grant with no policy refuses `authorization_details`. +- **Approve at consent.** `InteractionRequest.AuthorizationDetails` holds + the validated request. Read it with `extension.RARGet`, show it to the + user, and pass what they approved back, re-encoded with + `extension.RARSet`: + +```go +payments, err := extension.RARGet(interaction.AuthorizationDetails, paymentType) +// ... show payments[0].Fields to the user ... +approved, err := extension.RARSet(paymentType, payments[0].Fields) +// ... +result := server.Authorize(subject, authCtx, server.GrantedAuthorization{ + Scope: interaction.Scope, + AuthorizationDetails: []json.RawMessage{approved}, +}) +``` + +`CompleteAuthorization` refuses a granted object that isn't an +acceptable narrowing of one that was requested (per `ValidateGrant`), +just as it refuses a scope that wasn't requested. + +## Client: ask with the same definition + +```go +detail, err := extension.RARSet(paymentType, payment) +// ... +session, err := c.BeginAuthorization(ctx, client.BeginAuthorizationRequest{ + Scope: []string{"openid"}, + AuthorizationDetails: []json.RawMessage{detail}, +}) +``` + +`RARSet` stamps the definition's `type` into each object, so your Go +type needn't carry one. The token response's granted details come back +as `TokenSet.AuthorizationDetails`. + +## Resource server: read what was granted + +```go +authz, err := verifier.Verify(r.Context(), resource.VerifyRequestFromHTTP(r, paymentsEndpoint)) +// ... +granted, err := extension.ParseGrantedRAR(authz.Claims[extension.AuthorizationDetailsClaim]) +// ... +payments, err := extension.RARGet(granted, paymentType) +``` + +A token without the claim was granted none: the result is empty, never +an error read as "nothing to check". + +## Checked for you + +- **Strict parsing:** a member the Go type doesn't declare is refused, and + so is one spelled differently from its JSON tag (`ACTIONS` for + `actions`), at any depth. Each object's `type` must be a registered + one, and no member may appear twice. +- **Bounds:** the whole array's size and nesting depth, and each type's + object count and object size. + +## See it running + +- [payment-consent](../../examples/payment-consent/README.md): a single + `payment_initiation` approved exactly as asked. The type is in + [`payment/rar.go`](../../examples/payment-consent/payment/rar.go), the + consent step in [`payment/bank.go`](../../examples/payment-consent/payment/bank.go), + and the resource server reads it in + [`payment/api.go`](../../examples/payment-consent/payment/api.go). +- [linked-accounts](../../examples/linked-accounts/README.md): an + `account_access` request naming no accounts, narrowed by the customer + choosing which to share; its `ValidateGrant` is in + [`linked/rar.go`](../../examples/linked-accounts/linked/rar.go). +- [decoupled-checkout](../../examples/decoupled-checkout/README.md): the + same over CIBA, approved on the customer's phone. + +[RFC 9396]: https://www.rfc-editor.org/rfc/rfc9396