diff --git a/apps/docs/src/content/docs/discover/default/en/core-concepts/authentication.mdx b/apps/docs/src/content/docs/discover/default/en/core-concepts/authentication.mdx index 0708149..55223f9 100644 --- a/apps/docs/src/content/docs/discover/default/en/core-concepts/authentication.mdx +++ b/apps/docs/src/content/docs/discover/default/en/core-concepts/authentication.mdx @@ -42,14 +42,11 @@ This endpoint returns a JSON document that OIDC clients can read automatically. | `userinfo_endpoint` | Where the application can fetch profile information | | `jwks_uri` | Where public signing keys are exposed so tokens can be verified | | `grant_types_supported` | Which grant types this realm accepts | +| `device_authorization_endpoint` | Where a browserless device starts the [device flow](#device-code) | | `code_challenge_methods_supported` | Which PKCE methods are available | Most applications support discovery, so you rarely paste endpoints by hand. Give the application the discovery URL or the issuer, and it reads the rest from FerrisKey. -:::callout{variant="warning" title="The document under-reports the grants"} -`grant_types_supported` currently advertises `authorization_code`, `refresh_token`, `client_credentials` and `password` only. The [device code grant](#device-code) is served but not listed, and the document carries no `device_authorization_endpoint`, so a client that configures itself purely from discovery will not find the device flow. Configure that one by hand. -::: - :::callout{variant="info" title="Issuer and discovery are not the same setting"} When an application asks for the issuer or the authority, give it the realm URL, for example `https://sso.example.com/realms/home`. When it asks for the discovery endpoint or the OpenID configuration URL, give it the full `/.well-known/openid-configuration` address. ::: @@ -149,17 +146,13 @@ The client needs `oauth_device_code_grant_enabled`. Without it the first call is The flow is also observable: [webhooks](/en/modules/webhooks/triggers) fire `auth.device_flow.initiated`, `auth.device_flow.denied` and `auth.device_flow.expired`. -### Token exchange — not served yet +### Token exchange ``` urn:ietf:params:oauth:grant-type:token-exchange ``` -RFC 8693 token exchange is **modelled but not implemented**. The grant type parses, the request and response shapes exist, and a `token_exchange_policies` table is in the schema carrying a target audience, allowed scopes, and impersonation and delegation switches per client. None of it is wired: the token endpoint answers `invalid_request` for this grant. - -Do not build on it, and do not read the presence of the policy table as a feature. When it does ship, the shape it will take is the RFC's: a `subject_token` plus its `subject_token_type`, an optional `requested_token_type`, `audience`, `resource` and `scope`, with the issued scope constrained to a subset of the subject token's. - -If what you need today is one service calling another under its own identity, use [client credentials](#client-credentials). If you need to carry the end user's identity across a service boundary, pass the access token itself and validate it at each hop. +A confidential client trades an access token it holds for a new one: fewer scopes, a token meant for another service, or a token that names who acts for the user (RFC 8693). The client needs `token_exchange_enabled`, and targeting another service needs a delegation policy. See [Token Exchange](/en/discover/core-concepts/token-exchange). ## The OIDC endpoints @@ -169,7 +162,7 @@ These are the integration surface: what an application, a resource server or a g |---|---|---| | `GET` | `/auth` | Start the authorization code flow | | `POST` | `/auth/device` | Start the device flow (RFC 8628 §3.1) | -| `POST` | `/token` | Exchange a code, refresh token or device code for tokens | +| `POST` | `/token` | Exchange a code, refresh token or device code for tokens, or swap an access token ([token exchange](/en/discover/core-concepts/token-exchange)) | | `POST` | `/token/introspect` | Token introspection (RFC 7662) | | `GET` | `/userinfo` | Profile claims for the bearer of an access token | | `POST` | `/revoke` | Revoke an access or refresh token (RFC 7009) | diff --git a/apps/docs/src/content/docs/discover/default/en/core-concepts/clients.mdx b/apps/docs/src/content/docs/discover/default/en/core-concepts/clients.mdx index 7d8187b..4654551 100644 --- a/apps/docs/src/content/docs/discover/default/en/core-concepts/clients.mdx +++ b/apps/docs/src/content/docs/discover/default/en/core-concepts/clients.mdx @@ -38,6 +38,7 @@ The grant switches are not permanent, and each one widens the attack surface: |---|---| | Direct access grants | Never, for anything new — it is the [deprecated password grant](/en/discover/core-concepts/authentication#password-resource-owner--deprecated) | | OAuth 2.0 device authorization grant | The client is browserless: a CLI, an IoT device, a TV app | +| Token exchange | A confidential client must swap user tokens for narrower ones or for another service. Off by default. See [Token Exchange](/en/discover/core-concepts/token-exchange) | A disabled client keeps its whole configuration and rejects every request — which is what makes it different from [maintenance mode](/en/modules/maintenance/overview), where a whitelist still gets through. @@ -52,6 +53,7 @@ A disabled client keeps its whole configuration and rejects every request — wh | `redirect_uris` | Allowed redirect URIs after authentication | | `direct_access_grants_enabled` | Allow the deprecated password grant type | | `oauth_device_code_grant_enabled` | Allow the device code grant, used by CLIs and other browserless clients | +| `token_exchange_enabled` | Allow the [token exchange](/en/discover/core-concepts/token-exchange) grant (RFC 8693) | | `require_pkce` | Require PKCE on the authorization code flow | | `public_client` | Public client: no secret, so PKCE carries the protection | | `service_account_enabled` | Enable client credentials grant | @@ -85,6 +87,7 @@ A client is not one object: several of its properties are collections with their | Client roles | `GET POST` `…/{client_id}/roles` | Roles scoped to this client | | Client secret | `GET` `…/{client_id}/client-secret` | Reading it raises `client_secret_viewed` in the audit log | | Scope assignment | `PUT DELETE` `…/default-client-scopes/{scope_id}` and `…/optional-client-scopes/{scope_id}` | See [Client Scopes](/en/discover/core-concepts/client-scopes) | +| Token exchange policies | `GET POST` `…/{client_id}/token-exchange-policies`, `DELETE` `…/token-exchange-policies/{policy_id}` | Which audiences this client may exchange tokens for, see [Token Exchange](/en/discover/core-concepts/token-exchange#delegation-policies) | :::callout{variant="info" title="Web origins are per realm-scoped route only"} A client's web origins cover the realm-scoped routes. They do **not** cover `/config`, the health probes or the API documentation, which carry no realm — those need the server-wide `ALLOWED_ORIGINS`. A console served from a different origin than the API needs both. diff --git a/apps/docs/src/content/docs/discover/default/en/core-concepts/token-exchange.mdx b/apps/docs/src/content/docs/discover/default/en/core-concepts/token-exchange.mdx new file mode 100644 index 0000000..50e0a49 --- /dev/null +++ b/apps/docs/src/content/docs/discover/default/en/core-concepts/token-exchange.mdx @@ -0,0 +1,210 @@ +--- +title: Token Exchange +description: "Swap an access token for a narrower one, for another service, or on someone's behalf (RFC 8693)." +icon: arrow-left-right +order: 20 +--- + +# Token Exchange + +Token exchange lets a client trade an access token it already holds for a new one. FerrisKey implements [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) for three jobs: + +| Job | What the client asks for | Example | +|---|---|---| +| **Downscoping** | The same token with fewer scopes | A gateway passes a `profile`-only token to a less trusted component | +| **Audience** | A token meant for another service | The orders API calls the billing API as the signed-in user | +| **Delegation** | A token that says who acts for the user | A support agent works on a customer's account, and the token names both | + +The issued token is a plain access token. Resource servers validate it like any other. + +:::callout{variant="info" title="Not in 0.8.0"} +Token exchange ships with the release after FerrisKey 0.8.0. +::: + +## Before you start + +1. The requesting client is **confidential**. Public clients are always refused, because they cannot keep the secret that proves who is asking. +2. Turn on **token exchange** on that client: the toggle in its settings, or `token_exchange_enabled: true` through the API. It is off by default. +3. To target another service, add a [delegation policy](#delegation-policies) for that audience. Downscoping without an audience needs no policy. + +## The request + +`POST /realms/{realm}/protocol/openid-connect/token`, form-encoded. The client authenticates with HTTP Basic (`client_secret_basic`) or `client_id` + `client_secret` in the body (`client_secret_post`). + +| Parameter | Required | Value | +|---|---|---| +| `grant_type` | yes | `urn:ietf:params:oauth:grant-type:token-exchange` | +| `subject_token` | yes | The access token being exchanged | +| `subject_token_type` | yes | `urn:ietf:params:oauth:token-type:access_token` | +| `requested_token_type` | no | Only `urn:ietf:params:oauth:token-type:access_token`, the default | +| `scope` | no | Space-separated subset of the subject token's scope | +| `audience` | no | `client_id` of a client of the same realm | +| `actor_token` | no | Access token of the party acting for the user, see [delegation](#delegation-the-act-claim) | +| `actor_token_type` | with `actor_token` | `urn:ietf:params:oauth:token-type:access_token` | +| `resource` | no | Not supported yet, answers `invalid_target` | + +Narrow the scope of a token, with no audience and no policy: + +```bash +curl -X POST https://sso.example.com/realms/home/protocol/openid-connect/token \ + -u 'gateway:' \ + -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ + -d subject_token="$ACCESS_TOKEN" \ + -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \ + -d scope=profile +``` + +Get a token for another service, which needs a [policy](#delegation-policies) for `orders-api`: + +```bash +curl -X POST https://sso.example.com/realms/home/protocol/openid-connect/token \ + -u 'gateway:' \ + -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ + -d subject_token="$ACCESS_TOKEN" \ + -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \ + -d audience=orders-api \ + -d scope=orders:read +``` + +The requesting client must be a party to the subject token: its `azp`, or listed in its `aud`. A client cannot exchange a token it intercepted. + +## The response + +```json +{ + "access_token": "eyJhbGciOiJSUzI1NiIs...", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 300, + "scope": "orders:read" +} +``` + +- `scope` is omitted when it equals the subject token's scope. +- No refresh token and no ID token. When the new token expires, exchange again. +- No cookie, and the response carries `Cache-Control: no-store`. + +## What the issued token contains + +| Claim | Where it comes from | +|---|---| +| `iss`, `sub`, `sid` | The subject token. They never change | +| `azp` | The requesting client, which now holds the token | +| `aud` | The `audience` parameter when set, otherwise the subject token's `aud` | +| `scope` | The narrowed scope, see [scope rules](#scope-rules) | +| `exp` | The client's access token lifetime, capped at the subject token's `exp` | +| Mapped claims | Recomputed by the [protocol mappers](/en/modules/aegis/protocol-mappers) of the client that consumes the token | +| `act` | Set on a delegated exchange, see [below](#delegation-the-act-claim) | + +Mapped claims are not copied from the subject token. FerrisKey runs the protocol mappers again, those of the `audience` client when one is set, otherwise those of the requesting client, and only for the scopes the new token carries. Ask for `scope=profile` and the `email` claim is gone, even if the subject token had it. + +Because the new token keeps the subject's `sid`, it lives and dies with the user's session. Log the user out or revoke the session, and the exchanged token stops working at introspection too. + +## Scope rules + +- A requested `scope` must be a subset of the subject token's scope, otherwise `invalid_scope`. +- With a policy, it must also fit within the policy's `allowed_scopes`, when that list is set. +- With no `scope` requested, the token gets what both allow: the subject's scope, intersected with `allowed_scopes`. +- `offline_access` is never granted, since an exchange issues no refresh token. + +## Delegation policies + +A policy lets one client target one audience. Without a matching policy, an exchange with `audience` answers `invalid_target`. + +| Field | Meaning | +|---|---| +| `target_audience` | `client_id` of the client the issued token is for | +| `allowed_scopes` | Scope ceiling. Empty or `null` means no extra cap | +| `allow_impersonation` | The client may exchange with no actor, acting as the user | +| `allow_delegation` | The client may exchange with an `actor_token`, and the token names the actor | + +The two switches are independent. A policy that allows only delegation refuses a plain exchange, and the reverse. + +Manage policies from the client page, tab **Token exchange**, or through the admin API: + +| Method | Path | +|---|---| +| `GET` | `/realms/{realm}/clients/{client_uuid}/token-exchange-policies` | +| `POST` | `/realms/{realm}/clients/{client_uuid}/token-exchange-policies` | +| `DELETE` | `/realms/{realm}/clients/{client_uuid}/token-exchange-policies/{policy_id}` | + +```json +{ + "target_audience": "orders-api", + "allowed_scopes": ["orders:read"], + "allow_impersonation": true, + "allow_delegation": false +} +``` + +A client can hold one policy per audience: a second one answers `409`. Deleting a policy takes effect on the next exchange. + +Create a policy that allows delegation, with an admin token. `$CLIENT_UUID` is the id of the requesting client, not its `client_id`: + +```bash +curl -X POST https://sso.example.com/realms/home/clients/$CLIENT_UUID/token-exchange-policies \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "target_audience": "orders-api", + "allowed_scopes": ["orders:read"], + "allow_impersonation": false, + "allow_delegation": true + }' +``` + +## Delegation: the act claim + +With impersonation, the issued token looks as if the user called the audience directly. With delegation, it also says who acted. The client sends an `actor_token`, and the issued token carries an `act` claim ([RFC 8693 §4.1](https://datatracker.ietf.org/doc/html/rfc8693#section-4.1)): + +```json +{ + "sub": "1b0e…", + "azp": "support-desk", + "aud": ["orders-api"], + "act": { "sub": "7c4f…", "client_id": "support-desk" } +} +``` + +Run a delegated exchange. `$ACTOR_TOKEN` is the access token of the party acting for the user: + +```bash +curl -X POST https://sso.example.com/realms/home/protocol/openid-connect/token \ + -u 'support-desk:' \ + -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ + -d subject_token="$CUSTOMER_TOKEN" \ + -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \ + -d actor_token="$ACTOR_TOKEN" \ + -d actor_token_type=urn:ietf:params:oauth:token-type:access_token \ + -d audience=orders-api \ + -d scope=orders:read +``` + +- The actor token must be a live access token of the realm, issued to the requesting client (`azp`). A `client_credentials` token of the client itself works, and so does a token of the agent signed in to that client. +- Delegation needs a policy with `allow_delegation`. An `actor_token` without `audience` is refused, because only a policy can grant delegation. +- When the subject token already carries an `act`, the new actor wraps it: `act.act` holds the previous one. The chain reads from the current actor down to the first. +- A plain exchange of a delegated token keeps its `act` untouched. An exchange never erases who acted before. + +## Errors + +Errors follow the [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2) shape: `{ "error": "...", "error_description": "..." }`. + +| `error` | Status | Cause | +|---|---|---| +| `invalid_client` | 401 | Unknown or public client, or wrong secret. Comes with `WWW-Authenticate: Basic` | +| `unauthorized_client` | 400 | Token exchange off on the client, client not a party to the subject token, or the policy does not allow this kind of exchange | +| `invalid_request` | 400 | Subject or actor token missing, malformed, expired, revoked, a refresh token, from another realm, or an actor token of another client. Also `actor_token` without `actor_token_type`, or the reverse | +| `unsupported_token_type` | 400 | A token type other than `access_token` | +| `invalid_scope` | 400 | Scope wider than the subject token or the policy ceiling | +| `invalid_target` | 400 | Unknown or disabled audience, no policy for it, or `resource` set | + +## Discovery and observability + +- The [discovery document](/en/discover/core-concepts/authentication#openid-connect-discovery) lists `urn:ietf:params:oauth:grant-type:token-exchange` in `grant_types_supported`. +- Every exchange by an authenticated client is a [Compass](/en/modules/compass/overview) flow with a `subject_token_exchange` step, and a [SeaWatch](/en/modules/seawatch/event-types) `token_exchanged` event naming the client, the subject, the audience, the policy and the actor. +- Requests that fail client authentication leave no trace, so an unknown `client_id` cannot fill those tables. + +## When not to use it + +- **A service calling another under its own identity**: use [client credentials](/en/discover/core-concepts/authentication#client-credentials). No user is involved. +- **One backend passing the user's token to the next hop unchanged**: forward the token and validate it at each hop. Exchange when the next hop should get less, or should know who acts. diff --git a/apps/docs/src/content/docs/modules/default/en/compass/overview.mdx b/apps/docs/src/content/docs/modules/default/en/compass/overview.mdx index 8092095..4287e87 100644 --- a/apps/docs/src/content/docs/modules/default/en/compass/overview.mdx +++ b/apps/docs/src/content/docs/modules/default/en/compass/overview.mdx @@ -109,12 +109,13 @@ Each step records: | `authorize` | OAuth2 authorization request validation, checks redirect URI, scope, response type, CSRF state | <5ms | | `credential_validation` | Username lookup + password hash verification (Argon2) | 50-200ms | | `mfa_challenge` | TOTP code validation or WebAuthn assertion verification | 5-50ms | -| `token_exchange` | Authorization code → token exchange (code lookup + token generation) | 10-30ms | +| `token_exchange` | The token endpoint issuing tokens for a grant: code lookup for the authorization code, then token generation. Not RFC 8693, see `subject_token_exchange` | 10-30ms | | `idp_redirect` | Building and recording the redirect to an external identity provider | <5ms | | `idp_callback` | Processing the callback from an external IdP (token exchange + user lookup) | 100-500ms | | `finalize` | Session creation, SeaWatch event emission, and cleanup | 5-15ms | | `saml_authn_request` | Parsing an incoming SAML `AuthnRequest` and resolving the service provider | <5ms | | `saml_assertion` | Building and signing the SAML assertion sent back to the service provider | 5-20ms | +| `subject_token_exchange` | An RFC 8693 [token exchange](/en/discover/core-concepts/token-exchange): subject and actor checks, policy lookup, mappers and signing | 10-40ms | ### Step status values diff --git a/apps/docs/src/content/docs/modules/default/en/seawatch/event-types.mdx b/apps/docs/src/content/docs/modules/default/en/seawatch/event-types.mdx index 36c49f3..3cf4034 100644 --- a/apps/docs/src/content/docs/modules/default/en/seawatch/event-types.mdx +++ b/apps/docs/src/content/docs/modules/default/en/seawatch/event-types.mdx @@ -90,6 +90,14 @@ Role events are critical for access control audits. They answer: "Who granted th | `session_created` | A user session was established | User | User | | `session_revoked` | A session was revoked | Admin or User | User | +## Token exchange + +| Event | Description | Actor | Target | +|---|---|---|---| +| `token_exchanged` | A client swapped a user's access token for a new one ([RFC 8693](/en/discover/core-concepts/token-exchange)) | User (the token's subject) | Client | + +`details` carries the `client_id`, the requested `audience`, the `policy_id` that allowed it and the `actor` on a delegated exchange, plus `error_code` on failure. Only exchanges by an authenticated client are recorded: a wrong secret leaves no event. + ## Maintenance | Event | Description | Actor | Target | diff --git a/apps/website/src/components/hero-section.astro b/apps/website/src/components/hero-section.astro index 69ba1c1..ce284bb 100644 --- a/apps/website/src/components/hero-section.astro +++ b/apps/website/src/components/hero-section.astro @@ -97,7 +97,7 @@ import { Button } from "@explainer/ui";
- Early Access · v0.8.0 + Early Access · v0.9.0
diff --git a/apps/website/src/i18n/ui.ts b/apps/website/src/i18n/ui.ts index 705724d..5cb1e8d 100644 --- a/apps/website/src/i18n/ui.ts +++ b/apps/website/src/i18n/ui.ts @@ -18,7 +18,7 @@ export const ui = { 'hero.secondaryAction': 'View on GitHub', 'hero.badge.oss': 'Open source', 'hero.badge.license': 'Apache 2.0', - 'hero.badge.version': 'Early Access · v0.8.0', + 'hero.badge.version': 'Early Access · v0.9.0', // Features 'whyNow.label': 'Why now', @@ -242,7 +242,7 @@ export const ui = { 'hero.secondaryAction': 'Voir sur GitHub', 'hero.badge.oss': 'Open source', 'hero.badge.license': 'Apache 2.0', - 'hero.badge.version': 'Accès anticipé · v0.8.0', + 'hero.badge.version': 'Accès anticipé · v0.9.0', // Features 'whyNow.label': 'Pourquoi maintenant', diff --git a/apps/website/src/lib/release-notes.ts b/apps/website/src/lib/release-notes.ts index e722fe5..3c71259 100644 --- a/apps/website/src/lib/release-notes.ts +++ b/apps/website/src/lib/release-notes.ts @@ -18,6 +18,25 @@ export interface ReleaseNoteTranslation { } export const releaseNotes: ReleaseNote[] = [ + { + version: 'v0.9.0', + publishedAt: '2026-10-03T00:00:00Z', + title: 'Token exchange, back-channel logout, and realm isolation enforced by the type system', + summary: + 'FerrisKey 0.9.0 adds RFC 8693 token exchange with delegation policies, OIDC Back-Channel Logout and a consent screen, and carries password hashes over from other systems. Underneath, every repository port now takes a realm scope, which closed a whole class of cross-realm access bugs.', + kind: 'major', + githubUrl: 'https://github.com/ferriskey/ferriskey/releases/tag/v0.9.0', + compareUrl: 'https://github.com/ferriskey/ferriskey/compare/v0.8.0...v0.9.0', + highlights: [ + 'Implements RFC 8693 token exchange for downscoping, targeting another service, and delegation: per-client opt-in, delegation policies with a scope ceiling and separate impersonation and delegation switches, actor tokens, the act claim with chained actors, a console tab to manage policies, and an entry in the discovery document.', + 'Adds OIDC Back-Channel Logout with its console settings, the prompt and max_age parameters, a remember-me setting that now changes the SSO cookie lifetime, an SSO session handed out on the MFA login steps, and a themable consent screen for a client\'s optional scopes.', + 'Makes migrations from other identity systems keep their users: bcrypt password hashes are imported and re-encoded as argon2id on first sign-in, and a user can be created with a caller-supplied id so the sub claim survives. With the CLI, this lets teams import users from Supabase together with their passwords.', + 'Rebuilds webhook delivery on a persisted outbox with retry policies per realm and per webhook, delivery history, manual replay and signing secret regeneration, and translates the console into English and Simplified Chinese with API errors localised from a stable reason code.', + 'Threads the realm through every repository port with a RealmScope type, which turned a series of cross-realm access bugs into compile errors, and fixes around fifteen of them: login sessions, passkeys, roles, credentials, maintenance whitelists and delegated realm-admin roles are now confined to the realm in the URL, and the access token is no longer handed out in a cookie.', + ], + transition: + 'Where 0.8 turned inward to build foundations that could be verified, 0.9 spends them: the realm boundary is now checked by the compiler, and the protocol surface grows with token exchange, back-channel logout and consent on top of it.', + }, { version: 'v0.8.0', publishedAt: '2026-09-15T23:02:24Z', @@ -293,6 +312,21 @@ export const latestRelease = releaseNotes[0] export const releaseNoteTranslations: Record> = { fr: { + 'v0.9.0': { + title: + "Token exchange, back-channel logout et isolation des realms garantie par le système de types", + summary: + "FerrisKey 0.9.0 ajoute le token exchange RFC 8693 avec des policies de délégation, l'OIDC Back-Channel Logout et un écran de consentement, et reprend les hashes de mots de passe d'autres systèmes. En dessous, chaque port de dépôt prend désormais un scope de realm, ce qui a fermé toute une classe de bugs d'accès entre realms.", + highlights: [ + "Implémente le token exchange RFC 8693 pour réduire un scope, viser un autre service et déléguer : activation par client, policies de délégation avec plafond de scope et interrupteurs séparés pour l'impersonation et la délégation, actor tokens, claim act avec chaîne d'acteurs, onglet de console pour gérer les policies et entrée dans le document de discovery.", + "Ajoute l'OIDC Back-Channel Logout avec ses réglages dans la console, les paramètres prompt et max_age, un réglage remember me qui change désormais la durée du cookie SSO, une session SSO remise aux étapes de connexion MFA et un écran de consentement thémable pour les scopes optionnels d'un client.", + "Fait que les migrations depuis d'autres systèmes d'identité gardent leurs utilisateurs : les hashes bcrypt sont importés puis ré-encodés en argon2id à la première connexion, et un utilisateur peut être créé avec un id fourni par l'appelant pour que le claim sub survive. Avec la CLI, les équipes peuvent importer leurs utilisateurs Supabase avec leurs mots de passe.", + "Reconstruit la livraison des webhooks sur une outbox persistée avec des politiques de retry par realm et par webhook, un historique des livraisons, le rejeu manuel et la régénération du secret de signature, et traduit la console en anglais et en chinois simplifié, avec des erreurs d'API localisées à partir d'un code de raison stable.", + "Fait passer le realm par chaque port de dépôt avec un type RealmScope, ce qui a transformé une série de bugs d'accès entre realms en erreurs de compilation, et corrige une quinzaine d'entre eux : sessions de login, passkeys, rôles, credentials, whitelists de maintenance et rôles realm-admin délégués sont désormais confinés au realm de l'URL, et l'access token n'est plus donné dans un cookie.", + ], + transition: + "Là où la 0.8 se tournait vers l'intérieur pour bâtir des fondations vérifiables, la 0.9 les met à profit : la frontière entre realms est maintenant contrôlée par le compilateur, et la surface protocolaire s'élargit avec le token exchange, le back-channel logout et le consentement.", + }, 'v0.8.0': { title: "SAML 2.0, une console d'administration reconstruite et une codebase reprise en profondeur",