diff --git a/apps/docs/public/dashboard/account_security-dark.jpg b/apps/docs/public/dashboard/account_security-dark.jpg new file mode 100644 index 0000000..e79fe95 Binary files /dev/null and b/apps/docs/public/dashboard/account_security-dark.jpg differ diff --git a/apps/docs/public/dashboard/account_security-light.jpg b/apps/docs/public/dashboard/account_security-light.jpg new file mode 100644 index 0000000..1b25412 Binary files /dev/null and b/apps/docs/public/dashboard/account_security-light.jpg differ diff --git a/apps/docs/public/dashboard/client_access-dark.jpg b/apps/docs/public/dashboard/client_access-dark.jpg new file mode 100644 index 0000000..86c418c Binary files /dev/null and b/apps/docs/public/dashboard/client_access-dark.jpg differ diff --git a/apps/docs/public/dashboard/client_access-light.jpg b/apps/docs/public/dashboard/client_access-light.jpg new file mode 100644 index 0000000..eed52f7 Binary files /dev/null and b/apps/docs/public/dashboard/client_access-light.jpg differ diff --git a/apps/docs/public/dashboard/client_client_scopes-dark.jpg b/apps/docs/public/dashboard/client_client_scopes-dark.jpg new file mode 100644 index 0000000..fa3f830 Binary files /dev/null and b/apps/docs/public/dashboard/client_client_scopes-dark.jpg differ diff --git a/apps/docs/public/dashboard/client_client_scopes-light.jpg b/apps/docs/public/dashboard/client_client_scopes-light.jpg new file mode 100644 index 0000000..fa2eafe Binary files /dev/null and b/apps/docs/public/dashboard/client_client_scopes-light.jpg differ diff --git a/apps/docs/public/dashboard/client_maintenance-dark.jpg b/apps/docs/public/dashboard/client_maintenance-dark.jpg new file mode 100644 index 0000000..eef919f Binary files /dev/null and b/apps/docs/public/dashboard/client_maintenance-dark.jpg differ diff --git a/apps/docs/public/dashboard/client_maintenance-light.jpg b/apps/docs/public/dashboard/client_maintenance-light.jpg new file mode 100644 index 0000000..43b8f52 Binary files /dev/null and b/apps/docs/public/dashboard/client_maintenance-light.jpg differ diff --git a/apps/docs/public/dashboard/client_protocol-dark.jpg b/apps/docs/public/dashboard/client_protocol-dark.jpg new file mode 100644 index 0000000..e945912 Binary files /dev/null and b/apps/docs/public/dashboard/client_protocol-dark.jpg differ diff --git a/apps/docs/public/dashboard/client_protocol-light.jpg b/apps/docs/public/dashboard/client_protocol-light.jpg new file mode 100644 index 0000000..4bfe50a Binary files /dev/null and b/apps/docs/public/dashboard/client_protocol-light.jpg differ diff --git a/apps/docs/public/dashboard/client_scopes-dark.jpg b/apps/docs/public/dashboard/client_scopes-dark.jpg new file mode 100644 index 0000000..4b5281e Binary files /dev/null and b/apps/docs/public/dashboard/client_scopes-dark.jpg differ diff --git a/apps/docs/public/dashboard/client_scopes-light.jpg b/apps/docs/public/dashboard/client_scopes-light.jpg new file mode 100644 index 0000000..4400947 Binary files /dev/null and b/apps/docs/public/dashboard/client_scopes-light.jpg differ diff --git a/apps/docs/public/dashboard/client_scopes.png b/apps/docs/public/dashboard/client_scopes.png deleted file mode 100644 index 0807258..0000000 Binary files a/apps/docs/public/dashboard/client_scopes.png and /dev/null differ diff --git a/apps/docs/public/dashboard/client_secret-dark.jpg b/apps/docs/public/dashboard/client_secret-dark.jpg new file mode 100644 index 0000000..cd67320 Binary files /dev/null and b/apps/docs/public/dashboard/client_secret-dark.jpg differ diff --git a/apps/docs/public/dashboard/client_secret-light.jpg b/apps/docs/public/dashboard/client_secret-light.jpg new file mode 100644 index 0000000..e8c9a42 Binary files /dev/null and b/apps/docs/public/dashboard/client_secret-light.jpg differ diff --git a/apps/docs/public/dashboard/client_secret.png b/apps/docs/public/dashboard/client_secret.png deleted file mode 100644 index afb2d29..0000000 Binary files a/apps/docs/public/dashboard/client_secret.png and /dev/null differ diff --git a/apps/docs/public/dashboard/client_settings-dark.jpg b/apps/docs/public/dashboard/client_settings-dark.jpg new file mode 100644 index 0000000..1b6ce1e Binary files /dev/null and b/apps/docs/public/dashboard/client_settings-dark.jpg differ diff --git a/apps/docs/public/dashboard/client_settings-light.jpg b/apps/docs/public/dashboard/client_settings-light.jpg new file mode 100644 index 0000000..7639928 Binary files /dev/null and b/apps/docs/public/dashboard/client_settings-light.jpg differ diff --git a/apps/docs/public/dashboard/client_settings.png b/apps/docs/public/dashboard/client_settings.png deleted file mode 100644 index 3f388ee..0000000 Binary files a/apps/docs/public/dashboard/client_settings.png and /dev/null differ diff --git a/apps/docs/public/dashboard/compass-dark.jpg b/apps/docs/public/dashboard/compass-dark.jpg new file mode 100644 index 0000000..bc43557 Binary files /dev/null and b/apps/docs/public/dashboard/compass-dark.jpg differ diff --git a/apps/docs/public/dashboard/compass-light.jpg b/apps/docs/public/dashboard/compass-light.jpg new file mode 100644 index 0000000..b648889 Binary files /dev/null and b/apps/docs/public/dashboard/compass-light.jpg differ diff --git a/apps/docs/public/dashboard/create_client-dark.jpg b/apps/docs/public/dashboard/create_client-dark.jpg new file mode 100644 index 0000000..7609733 Binary files /dev/null and b/apps/docs/public/dashboard/create_client-dark.jpg differ diff --git a/apps/docs/public/dashboard/create_client-light.jpg b/apps/docs/public/dashboard/create_client-light.jpg new file mode 100644 index 0000000..ffedaa4 Binary files /dev/null and b/apps/docs/public/dashboard/create_client-light.jpg differ diff --git a/apps/docs/public/dashboard/create_client.png b/apps/docs/public/dashboard/create_client.png deleted file mode 100644 index 4bff738..0000000 Binary files a/apps/docs/public/dashboard/create_client.png and /dev/null differ diff --git a/apps/docs/public/dashboard/email_templates-dark.jpg b/apps/docs/public/dashboard/email_templates-dark.jpg new file mode 100644 index 0000000..e052c1f Binary files /dev/null and b/apps/docs/public/dashboard/email_templates-dark.jpg differ diff --git a/apps/docs/public/dashboard/email_templates-light.jpg b/apps/docs/public/dashboard/email_templates-light.jpg new file mode 100644 index 0000000..0460ba9 Binary files /dev/null and b/apps/docs/public/dashboard/email_templates-light.jpg differ diff --git a/apps/docs/public/dashboard/ldap_provider-dark.jpg b/apps/docs/public/dashboard/ldap_provider-dark.jpg new file mode 100644 index 0000000..04fe742 Binary files /dev/null and b/apps/docs/public/dashboard/ldap_provider-dark.jpg differ diff --git a/apps/docs/public/dashboard/ldap_provider-light.jpg b/apps/docs/public/dashboard/ldap_provider-light.jpg new file mode 100644 index 0000000..a556591 Binary files /dev/null and b/apps/docs/public/dashboard/ldap_provider-light.jpg differ diff --git a/apps/docs/public/dashboard/ldap_sync-dark.jpg b/apps/docs/public/dashboard/ldap_sync-dark.jpg new file mode 100644 index 0000000..aa62150 Binary files /dev/null and b/apps/docs/public/dashboard/ldap_sync-dark.jpg differ diff --git a/apps/docs/public/dashboard/ldap_sync-light.jpg b/apps/docs/public/dashboard/ldap_sync-light.jpg new file mode 100644 index 0000000..766f2d8 Binary files /dev/null and b/apps/docs/public/dashboard/ldap_sync-light.jpg differ diff --git a/apps/docs/public/dashboard/list_clients-dark.jpg b/apps/docs/public/dashboard/list_clients-dark.jpg new file mode 100644 index 0000000..28216b2 Binary files /dev/null and b/apps/docs/public/dashboard/list_clients-dark.jpg differ diff --git a/apps/docs/public/dashboard/list_clients-light.jpg b/apps/docs/public/dashboard/list_clients-light.jpg new file mode 100644 index 0000000..56ba965 Binary files /dev/null and b/apps/docs/public/dashboard/list_clients-light.jpg differ diff --git a/apps/docs/public/dashboard/list_clients.png b/apps/docs/public/dashboard/list_clients.png deleted file mode 100644 index bfb2a60..0000000 Binary files a/apps/docs/public/dashboard/list_clients.png and /dev/null differ diff --git a/apps/docs/public/dashboard/password_policy-dark.jpg b/apps/docs/public/dashboard/password_policy-dark.jpg new file mode 100644 index 0000000..c6e531a Binary files /dev/null and b/apps/docs/public/dashboard/password_policy-dark.jpg differ diff --git a/apps/docs/public/dashboard/password_policy-light.jpg b/apps/docs/public/dashboard/password_policy-light.jpg new file mode 100644 index 0000000..fd0ae34 Binary files /dev/null and b/apps/docs/public/dashboard/password_policy-light.jpg differ diff --git a/apps/docs/public/dashboard/portal_layouts-dark.jpg b/apps/docs/public/dashboard/portal_layouts-dark.jpg new file mode 100644 index 0000000..214f07e Binary files /dev/null and b/apps/docs/public/dashboard/portal_layouts-dark.jpg differ diff --git a/apps/docs/public/dashboard/portal_layouts-light.jpg b/apps/docs/public/dashboard/portal_layouts-light.jpg new file mode 100644 index 0000000..9609897 Binary files /dev/null and b/apps/docs/public/dashboard/portal_layouts-light.jpg differ diff --git a/apps/docs/public/dashboard/portal_themes-dark.jpg b/apps/docs/public/dashboard/portal_themes-dark.jpg new file mode 100644 index 0000000..3bbe53f Binary files /dev/null and b/apps/docs/public/dashboard/portal_themes-dark.jpg differ diff --git a/apps/docs/public/dashboard/portal_themes-light.jpg b/apps/docs/public/dashboard/portal_themes-light.jpg new file mode 100644 index 0000000..754631e Binary files /dev/null and b/apps/docs/public/dashboard/portal_themes-light.jpg differ diff --git a/apps/docs/public/dashboard/realm_maintenance-dark.jpg b/apps/docs/public/dashboard/realm_maintenance-dark.jpg new file mode 100644 index 0000000..a292764 Binary files /dev/null and b/apps/docs/public/dashboard/realm_maintenance-dark.jpg differ diff --git a/apps/docs/public/dashboard/realm_maintenance-light.jpg b/apps/docs/public/dashboard/realm_maintenance-light.jpg new file mode 100644 index 0000000..b0a3fca Binary files /dev/null and b/apps/docs/public/dashboard/realm_maintenance-light.jpg differ diff --git a/apps/docs/public/dashboard/realm_switch-dark.jpg b/apps/docs/public/dashboard/realm_switch-dark.jpg new file mode 100644 index 0000000..7054f9e Binary files /dev/null and b/apps/docs/public/dashboard/realm_switch-dark.jpg differ diff --git a/apps/docs/public/dashboard/realm_switch-light.jpg b/apps/docs/public/dashboard/realm_switch-light.jpg new file mode 100644 index 0000000..8eb8dc6 Binary files /dev/null and b/apps/docs/public/dashboard/realm_switch-light.jpg differ diff --git a/apps/docs/public/dashboard/realm_switch.png b/apps/docs/public/dashboard/realm_switch.png deleted file mode 100644 index c9cbaaa..0000000 Binary files a/apps/docs/public/dashboard/realm_switch.png and /dev/null differ diff --git a/apps/docs/public/dashboard/role_permissions-dark.jpg b/apps/docs/public/dashboard/role_permissions-dark.jpg new file mode 100644 index 0000000..5fb50e8 Binary files /dev/null and b/apps/docs/public/dashboard/role_permissions-dark.jpg differ diff --git a/apps/docs/public/dashboard/role_permissions-light.jpg b/apps/docs/public/dashboard/role_permissions-light.jpg new file mode 100644 index 0000000..42ed46d Binary files /dev/null and b/apps/docs/public/dashboard/role_permissions-light.jpg differ diff --git a/apps/docs/public/dashboard/seawatch-dark.jpg b/apps/docs/public/dashboard/seawatch-dark.jpg new file mode 100644 index 0000000..de0c69e Binary files /dev/null and b/apps/docs/public/dashboard/seawatch-dark.jpg differ diff --git a/apps/docs/public/dashboard/seawatch-light.jpg b/apps/docs/public/dashboard/seawatch-light.jpg new file mode 100644 index 0000000..f8dcdfc Binary files /dev/null and b/apps/docs/public/dashboard/seawatch-light.jpg differ diff --git a/apps/docs/public/dashboard/user_credentials-dark.jpg b/apps/docs/public/dashboard/user_credentials-dark.jpg new file mode 100644 index 0000000..6ca5e15 Binary files /dev/null and b/apps/docs/public/dashboard/user_credentials-dark.jpg differ diff --git a/apps/docs/public/dashboard/user_credentials-light.jpg b/apps/docs/public/dashboard/user_credentials-light.jpg new file mode 100644 index 0000000..f4a3c5f Binary files /dev/null and b/apps/docs/public/dashboard/user_credentials-light.jpg differ diff --git a/apps/docs/public/dashboard/user_role_mapping-dark.jpg b/apps/docs/public/dashboard/user_role_mapping-dark.jpg new file mode 100644 index 0000000..20e2713 Binary files /dev/null and b/apps/docs/public/dashboard/user_role_mapping-dark.jpg differ diff --git a/apps/docs/public/dashboard/user_role_mapping-light.jpg b/apps/docs/public/dashboard/user_role_mapping-light.jpg new file mode 100644 index 0000000..1348403 Binary files /dev/null and b/apps/docs/public/dashboard/user_role_mapping-light.jpg differ diff --git a/apps/docs/public/dashboard/webhooks_list-dark.jpg b/apps/docs/public/dashboard/webhooks_list-dark.jpg new file mode 100644 index 0000000..7c4ba0b Binary files /dev/null and b/apps/docs/public/dashboard/webhooks_list-dark.jpg differ diff --git a/apps/docs/public/dashboard/webhooks_list-light.jpg b/apps/docs/public/dashboard/webhooks_list-light.jpg new file mode 100644 index 0000000..8b5e6cb Binary files /dev/null and b/apps/docs/public/dashboard/webhooks_list-light.jpg differ diff --git a/apps/docs/src/content/docs/cli/default/en/commands/client.mdx b/apps/docs/src/content/docs/cli/default/en/commands/client.mdx index 83d5146..bbd07e7 100644 --- a/apps/docs/src/content/docs/cli/default/en/commands/client.mdx +++ b/apps/docs/src/content/docs/cli/default/en/commands/client.mdx @@ -1,5 +1,5 @@ --- -title: client +title: Client description: "Create, inspect, and delete OAuth2 clients within a realm." icon: box order: 3 diff --git a/apps/docs/src/content/docs/cli/default/en/commands/context.mdx b/apps/docs/src/content/docs/cli/default/en/commands/context.mdx index 95ece2e..d1429ae 100644 --- a/apps/docs/src/content/docs/cli/default/en/commands/context.mdx +++ b/apps/docs/src/content/docs/cli/default/en/commands/context.mdx @@ -1,5 +1,5 @@ --- -title: context +title: Context description: "Manage connection contexts: named profiles that store the server URL, client, and default realm." icon: server order: 1 diff --git a/apps/docs/src/content/docs/cli/default/en/commands/login.mdx b/apps/docs/src/content/docs/cli/default/en/commands/login.mdx index e6b8cc7..7802aa3 100644 --- a/apps/docs/src/content/docs/cli/default/en/commands/login.mdx +++ b/apps/docs/src/content/docs/cli/default/en/commands/login.mdx @@ -1,5 +1,5 @@ --- -title: login +title: Login description: "Sign in via the OAuth 2.0 Device Authorization Grant and persist a session for other commands." icon: log-in order: 6 diff --git a/apps/docs/src/content/docs/cli/default/en/commands/logout.mdx b/apps/docs/src/content/docs/cli/default/en/commands/logout.mdx index e93a867..5638ef0 100644 --- a/apps/docs/src/content/docs/cli/default/en/commands/logout.mdx +++ b/apps/docs/src/content/docs/cli/default/en/commands/logout.mdx @@ -1,5 +1,5 @@ --- -title: logout +title: Logout description: "Remove the stored login session." icon: log-out order: 7 diff --git a/apps/docs/src/content/docs/cli/default/en/commands/index.mdx b/apps/docs/src/content/docs/cli/default/en/commands/overview.mdx similarity index 83% rename from apps/docs/src/content/docs/cli/default/en/commands/index.mdx rename to apps/docs/src/content/docs/cli/default/en/commands/overview.mdx index fa88199..788f625 100644 --- a/apps/docs/src/content/docs/cli/default/en/commands/index.mdx +++ b/apps/docs/src/content/docs/cli/default/en/commands/overview.mdx @@ -13,13 +13,13 @@ order: 0 ## Connect and authenticate ::::card-group{cols=3} -:::card{label="context" icon="lucide:server" href="/en/cli/commands/context"} +:::card{label="Context" icon="lucide:server" href="/en/cli/commands/context"} Named connection profiles storing a server URL, a client, and a default realm. Switch environments without retyping flags. ::: -:::card{label="login" icon="lucide:log-in" href="/en/cli/commands/login"} +:::card{label="Login" icon="lucide:log-in" href="/en/cli/commands/login"} Sign in with the OAuth 2.0 Device Authorization Grant and persist the session for every later command. ::: -:::card{label="logout" icon="lucide:log-out" href="/en/cli/commands/logout"} +:::card{label="Logout" icon="lucide:log-out" href="/en/cli/commands/logout"} Drop the stored session by deleting the credentials file. ::: :::: @@ -27,13 +27,13 @@ Drop the stored session by deleting the credentials file. ## Administer a realm ::::card-group{cols=3} -:::card{label="realm" icon="lucide:layers" href="/en/cli/commands/realm"} +:::card{label="Realm" icon="lucide:layers" href="/en/cli/commands/realm"} Create, inspect, and delete realms, manage their roles, and import one from an external source. ::: -:::card{label="client" icon="lucide:box" href="/en/cli/commands/client"} +:::card{label="Client" icon="lucide:box" href="/en/cli/commands/client"} Create, inspect, and delete OAuth2 clients within a realm. ::: -:::card{label="user" icon="lucide:users" href="/en/cli/commands/user"} +:::card{label="User" icon="lucide:users" href="/en/cli/commands/user"} Create, inspect, and delete users, set their passwords, and assign roles. ::: :::: @@ -41,7 +41,7 @@ Create, inspect, and delete users, set their passwords, and assign roles. ## Import ::::card-group{cols=2} -:::card{label="source" icon="lucide:database" href="/en/cli/commands/source"} +:::card{label="Source" icon="lucide:database" href="/en/cli/commands/source"} Store reusable import sources, so credentials and URLs are not repeated on every import. ::: :::card{label="Importing realms" icon="lucide:import" href="/en/cli/import/overview"} diff --git a/apps/docs/src/content/docs/cli/default/en/commands/realm.mdx b/apps/docs/src/content/docs/cli/default/en/commands/realm.mdx index 35a332f..bfaf678 100644 --- a/apps/docs/src/content/docs/cli/default/en/commands/realm.mdx +++ b/apps/docs/src/content/docs/cli/default/en/commands/realm.mdx @@ -1,5 +1,5 @@ --- -title: realm +title: Realm description: "Create, inspect, delete, and import realms, and manage realm and client roles." icon: layers order: 2 @@ -131,7 +131,7 @@ ferris-ctl realm role delete [--realm ] [--client ] [-- ## `realm import` -Import a realm (its settings, roles, clients, and users) from a description file, a live Keycloak, or a live Zitadel instance. +Import a realm (its settings, roles, clients, and users) from a description file, a live Keycloak, a live Zitadel, or a Supabase project. ```bash ferris-ctl realm import --from [source flags] [--target-realm ] [--dry-run] @@ -139,18 +139,32 @@ ferris-ctl realm import --from [source flags] [--target-realm ] [-- | Flag | Description | |------|-------------| -| `--from` | Source kind: `config`, `keycloak`, or `zitadel` (optional if `--source-ref` is given) | +| `--from` | Source kind: `config`, `keycloak`, `zitadel`, or `supabase` (optional if `--source-ref` is given) | | `--source-ref` | Name of a stored [source](/en/cli/commands/source); inline flags below override its values | | `--file` | Path to a realm description file (required for `--from config`); `.yaml`, `.yml`, or `.toml` | -| `--source-url` | Base URL of the source instance (Keycloak / Zitadel) | +| `--source-url` | Base URL of the source instance (Keycloak, Zitadel, Supabase project) | | `--source-realm` | Source realm name (Keycloak) | | `--source-org` | Source organization id (Zitadel); sent as the `x-zitadel-orgid` header | | `--source-client-id` | Client id for source authentication (Keycloak client credentials) | | `--source-client-secret` | Client secret for source authentication (Keycloak) | -| `--source-token` | Bearer token / personal access token (Zitadel PAT, or a ready Keycloak token) | +| `--source-token` | Bearer token / personal access token (Zitadel PAT, Supabase `service_role` key, or a ready Keycloak token) | | `--target-realm` | Override the name of the realm created in FerrisKey (defaults to the source realm name) | | `--dry-run` | Resolve and print the planned realm without calling the FerrisKey API | +### Supabase-only flags + +These five are refused on any other source rather than silently ignored: + +| Flag | Description | +|------|-------------| +| `--source-include-deleted` | Import users an operator soft-deleted. Dropped by default: their `deleted_at` is set but the row survives | +| `--source-include-anonymous` | Import anonymous sign-in sessions. Dropped by default: rows with neither an email nor a phone | +| `--source-include-unconfirmed` | Import users who never confirmed an email or a phone. Dropped by default | +| `--source-passwords ` | Carry passwords over, read from a CSV export of `auth.users`. The Auth API never serves hashes | +| `--source-preserve-ids` | Create each user with its existing Supabase id, so the `sub` of every token survives | + +Each one is covered in [From Supabase](/en/cli/import/supabase). + ```bash title="Preview a realm from a file (no network writes)" ferris-ctl realm import --from config --file examples/realm.yaml --dry-run -o yaml ``` @@ -164,6 +178,15 @@ ferris-ctl realm import --from keycloak \ --target-realm acme ``` +```bash title="Import a Supabase project, passwords and ids included" +ferris-ctl realm import --from supabase \ + --source-url https://abcdefgh.supabase.co \ + --source-token "$SUPABASE_SERVICE_ROLE_KEY" \ + --source-passwords auth_users.csv \ + --source-preserve-ids \ + --target-realm acme +``` + The [Import](/en/cli/import/overview) section covers each source in full, including what is and isn't migrated. :::callout{variant="info" title="Re-running imports is safe"} diff --git a/apps/docs/src/content/docs/cli/default/en/commands/source.mdx b/apps/docs/src/content/docs/cli/default/en/commands/source.mdx index f942608..e910594 100644 --- a/apps/docs/src/content/docs/cli/default/en/commands/source.mdx +++ b/apps/docs/src/content/docs/cli/default/en/commands/source.mdx @@ -1,5 +1,5 @@ --- -title: source +title: Source description: "Store reusable import sources so you don't repeat credentials and URLs on every realm import." icon: database order: 5 @@ -34,7 +34,7 @@ ferris-ctl source add --kind --url [auth flags] | Argument | Required | Description | |----------|----------|-------------| | `` | yes | Source name | -| `--kind` | yes | `keycloak` or `zitadel` | +| `--kind` | yes | `keycloak`, `zitadel` or `supabase`. A config file is not a storable source: it is a path, passed with `--file` at import time | | `--url` | yes | Base URL of the source instance | | `--realm` | no | Source realm name (Keycloak) | | `--client-id` | no | Client id for source auth (Keycloak) | diff --git a/apps/docs/src/content/docs/cli/default/en/commands/user.mdx b/apps/docs/src/content/docs/cli/default/en/commands/user.mdx index 677ce1e..0decdfc 100644 --- a/apps/docs/src/content/docs/cli/default/en/commands/user.mdx +++ b/apps/docs/src/content/docs/cli/default/en/commands/user.mdx @@ -1,5 +1,5 @@ --- -title: user +title: User description: "Create, inspect, and delete users, set passwords, and manage role assignments." icon: users order: 4 diff --git a/apps/docs/src/content/docs/cli/default/en/import/config-file.mdx b/apps/docs/src/content/docs/cli/default/en/import/config-file.mdx index f1bbf49..11ce85e 100644 --- a/apps/docs/src/content/docs/cli/default/en/import/config-file.mdx +++ b/apps/docs/src/content/docs/cli/default/en/import/config-file.mdx @@ -117,7 +117,7 @@ Permissions use the names from the [permissions reference](/en/discover/core-con | `public_client` | `false` | Public client, no secret | | `service_account_enabled` | `false` | Create a linked service account user | | `direct_access_grants_enabled` | `false` | Allow the password grant | -| `device_authorization_grant_enabled` | `false` | Allow the device code grant | +| `oauth_device_code_grant_enabled` | `false` | Allow the device code grant | | `redirect_uris` | `[]` | Allowed redirect URIs | | `post_logout_redirect_uris` | `[]` | Allowed post-logout redirect URIs | | `web_origins` | `[]` | Browser origins allowed on this client's realm-scoped routes | diff --git a/apps/docs/src/content/docs/cli/default/en/import/overview.mdx b/apps/docs/src/content/docs/cli/default/en/import/overview.mdx index 7ae1563..ab1a769 100644 --- a/apps/docs/src/content/docs/cli/default/en/import/overview.mdx +++ b/apps/docs/src/content/docs/cli/default/en/import/overview.mdx @@ -7,7 +7,7 @@ order: 1 # Import -`ferris-ctl realm import` brings a realm (its settings, roles, clients, and users) into FerrisKey from three kinds of source: +`ferris-ctl realm import` brings a realm (its settings, roles, clients, and users) into FerrisKey from four kinds of source: ::::card-group{cols=3} :::card{label="Config file" icon="lucide:file-text" href="/en/cli/import/config-file"} @@ -19,6 +19,9 @@ Read a realm live from a Keycloak Admin REST API. :::card{label="Zitadel" icon="lucide:server" href="/en/cli/import/zitadel"} Read organizations and projects live from a Zitadel instance. ::: +:::card{label="Supabase" icon="lucide:database" href="/en/cli/import/supabase"} +Read users live from a Supabase project. Users only, with optional password and id carry-over. +::: :::: ## How it works @@ -26,13 +29,13 @@ Read organizations and projects live from a Zitadel instance. Each source is resolved into a **blueprint** (a normalized description of the realm) which is then applied to FerrisKey. ``` -source (file | keycloak | zitadel) → blueprint → apply to FerrisKey +source (file | keycloak | zitadel | supabase) → blueprint → apply to FerrisKey ``` Apply order: realm → settings → realm roles → clients (with redirect URIs and client roles) → users (with role assignments). ```bash title="Basic shape" -ferris-ctl realm import --from [source flags] [--target-realm ] +ferris-ctl realm import --from [source flags] [--target-realm ] ``` See the full flag list on the [`realm import`](/en/cli/commands/realm) reference. diff --git a/apps/docs/src/content/docs/cli/default/en/import/supabase.mdx b/apps/docs/src/content/docs/cli/default/en/import/supabase.mdx new file mode 100644 index 0000000..baff223 --- /dev/null +++ b/apps/docs/src/content/docs/cli/default/en/import/supabase.mdx @@ -0,0 +1,159 @@ +--- +title: From Supabase +description: "Import users from a Supabase project, carry their password hashes over, and keep their ids so tokens survive." +icon: database +order: 5 +--- + +# From Supabase + +Supabase is the one source that is **users only**. It has no client catalogue and no role catalogue to read, so an import brings the accounts across and nothing else — you create the clients and the realm settings yourself afterwards. + +```bash +ferris-ctl realm import --from supabase \ + --source-url https://abcdefgh.supabase.co \ + --source-token "$SUPABASE_SERVICE_ROLE_KEY" \ + --target-realm acme +``` + +Accounts are read through the project's **Auth (GoTrue) Admin API**, which is why `--source-token` wants the project's `service_role` key rather than the anon key. + +## What gets carried over + +| FerrisKey field | Comes from | +|---|---| +| `username` | The email. Falling back to the phone number, then to the Supabase id | +| `email` | The email, when there is one | +| `email_verified` | Whether `email_confirmed_at` is set — only computed for users who have an email | +| `firstname` / `lastname` | `user_metadata`, trying `first_name`, `firstName`, `given_name` and the matching last-name keys; failing that, `full_name` or `name` split in two | +| Roles | `app_metadata.roles` (an array) and `app_metadata.role` (a single string), merged and de-duplicated | +| Password | Only from `--source-passwords`, below | + +Roles are created in the target realm as they are encountered, so a `roles` array in `app_metadata` is enough to rebuild a role catalogue Supabase never had. + +:::callout{variant="warning" title="A role name cannot contain a colon"} +FerrisKey reserves `:` to separate a client from its role, so a Supabase role like `billing:admin` is refused rather than silently reinterpreted. The import stops and names both the role and the user carrying it. Rename it in `app_metadata` before re-running. +::: + +## Three kinds of row are dropped by default + +Supabase's `auth.users` holds rows that are not really accounts you want. All three filters are **off** by default, and turning one on is a deliberate act: + +| Flag | What it lets in | Why it is off by default | +|---|---|---| +| `--source-include-deleted` | Users an operator soft-deleted | Their `deleted_at` is set but the row survives. A plain import would resurrect accounts somebody removed on purpose | +| `--source-include-anonymous` | Anonymous sign-in sessions | They are real rows with neither an email nor a phone number | +| `--source-include-unconfirmed` | Users who never confirmed an email or a phone | Nothing proves the address or the number belongs to them | + +A user counts as confirmed when **either** `email_confirmed_at` or `phone_confirmed_at` is set. + +:::callout{variant="info" title="Count before you commit"} +Run the import with `--dry-run` first, then again with a filter added, and compare the user counts. That difference is exactly how many soft-deleted, anonymous or unconfirmed rows your project is carrying — usually more than anyone expects. +::: + +## Carrying the passwords over + +The Auth Admin API **never serves password hashes**. Exporting the table is the only way to get them, so `--source-passwords` takes a CSV file rather than reading the API: + +```bash +ferris-ctl realm import --from supabase \ + --source-url https://abcdefgh.supabase.co \ + --source-token "$SUPABASE_SERVICE_ROLE_KEY" \ + --source-passwords auth_users.csv \ + --target-realm acme +``` + +### Producing the export + +The connection string is behind the project's **Connect** button: + +```bash +psql "" -c \ + "\copy (select id, encrypted_password from auth.users) to 'auth_users.csv' with (format csv, header)" +``` + +:::callout{variant="warning" title="The backslash on \\copy matters"} +`\copy` runs client-side and writes the file on **your** machine. A plain `COPY TO` would try to write on the database server, where you have no filesystem access. +::: + +Or run `select id, encrypted_password from auth.users;` in the dashboard's SQL editor and use the download button above the results. + +Only `id` and `encrypted_password` are read and extra columns are ignored, so a plain `select *` export works too. Rows join onto users by **`auth.users.id`, never by email** — which is what makes the join safe when an address has changed on one side. + +:::callout{variant="danger" title="That file holds every password hash in your directory"} +Keep it to yourself, never commit it, and delete it once the import has run. It is the single most sensitive artefact a migration produces. +::: + +### What survives, and what does not + +Only **bcrypt** hashes that FerrisKey accepts are imported. Every other account arrives without credentials and needs a password reset — so plan the reset emails for that subset rather than for everyone. + +An imported hash is re-encoded as argon2id the first time that user signs in successfully. See [Export & Import](/en/discover/guides/portability#password-hashes) for what that means on the FerrisKey side, including the `password_imported` audit event that lets you measure migration progress. + +## Keeping the user ids + +```bash +ferris-ctl realm import --from supabase … --source-preserve-ids +``` + +Creates each user with the id it already has in Supabase, so the **`sub` claim of every token survives the migration**. Supabase only. + +Reach for it when anything downstream stores the Supabase user id as a foreign key — your own tables, an analytics warehouse, a billing system. Without it those references all break and you are writing a mapping table. + +## A realistic migration + +::::step-group +:::step{title="Preview, and read the counts"} +```bash +ferris-ctl realm import --from supabase \ + --source-url https://abcdefgh.supabase.co \ + --source-token "$SUPABASE_SERVICE_ROLE_KEY" \ + --target-realm acme --dry-run -o yaml +``` + +No FerrisKey call is made. Check the user count and the roles that were derived from `app_metadata`. +::: + +:::step{title="Decide on the filtered rows"} +Re-run the preview with each `--source-include-*` flag to see what you are leaving behind, and make that a decision rather than a default. +::: + +:::step{title="Export the passwords"} +Only once you are about to run the real import — the file should exist for as short a time as possible. +::: + +:::step{title="Import"} +Add `--source-passwords` and, if anything references the Supabase ids, `--source-preserve-ids`. Delete the CSV afterwards. +::: + +:::step{title="Build what Supabase did not have"} +Clients, redirect URIs, scopes and realm settings. [Connect an Application with SSO](/en/discover/guides/application-sso) is the path for each application. +::: +:::: + +:::callout{variant="info" title="Re-running is safe"} +Like every source, a Supabase import is idempotent: accounts that already exist are skipped with a warning, so you can fix the source and run it again. +::: + +## Storing the connection + +Typing the service_role key on every run is how it ends up in a shell history. Store the source once: + +```bash +ferris-ctl source add supabase-prod --kind supabase \ + --url https://abcdefgh.supabase.co \ + --token "$SUPABASE_SERVICE_ROLE_KEY" + +ferris-ctl realm import --source-ref supabase-prod --target-realm acme +``` + +Inline `--source-*` flags still override individual fields of a stored source, so the stored entry can hold the connection while the run decides the filters. See [`source`](/en/cli/commands/source). + +::::card-group{cols=2} +:::card{label="Import overview" icon="lucide:download" href="/en/cli/import/overview"} +How imports resolve, dry runs, and idempotency. +::: +:::card{label="realm import" icon="lucide:layers" href="/en/cli/commands/realm"} +Every flag, for all four sources. +::: +:::: 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 6cd8a6e..0708149 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 @@ -1,13 +1,22 @@ --- title: Authentication -description: "OAuth2 grant types, authentication flows, and the authentication chain in FerrisKey." +description: "The two protocols FerrisKey authenticates over, OAuth2/OIDC and SAML 2.0, and the chain they share." icon: log-in -order: 16 +order: 17 --- # Authentication -FerrisKey implements OAuth 2.0 and OpenID Connect. Authentication always ends in tokens; the grant type decides how the user, or the client, proves who they are. +FerrisKey authenticates over **two protocols**: + +| Protocol | Front door | What the application receives | +|---|---|---| +| **OAuth 2.0 / OpenID Connect** | `/protocol/openid-connect/*` | Access, refresh and ID tokens | +| **SAML 2.0** | `/protocol/saml` | One signed XML assertion | + +They differ only at the edges. Underneath, both funnel through the same login, the same MFA, the same lockout and the same audit trail — the [authentication chain](#authentication-chain) below is shared, and it is the part worth understanding first. + +Most of this page is about OIDC, because that is the default and the richer surface. [SAML](#saml-20) has its own section. ## OpenID Connect discovery @@ -32,16 +41,23 @@ This endpoint returns a JSON document that OIDC clients can read automatically. | `token_endpoint` | Where the application exchanges codes for tokens | | `userinfo_endpoint` | Where the application can fetch profile information | | `jwks_uri` | Where public signing keys are exposed so tokens can be verified | -| `scopes_supported` | Which scopes can be requested, such as `openid`, `profile`, and `email` | +| `grant_types_supported` | Which grant types this realm accepts | +| `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. ::: ## Grant types +A grant type is an OAuth2 notion: it names *how* the client proves who it is acting for. SAML has no equivalent — see [bindings](#saml-20) instead. + ### Authorization code The safest flow for web applications. The user is redirected to FerrisKey, authenticates there, and comes back to the client with a code that the client exchanges for tokens. @@ -55,7 +71,13 @@ The steps: Best for web applications and single-page apps with a backend. -### Password (resource owner) +### Password (resource owner) — deprecated + +:::callout{variant="danger" title="Deprecated"} +The password grant is deprecated. It is still served, so existing integrations keep working, but do not build anything new on it — OAuth 2.1 removes it, and it is incompatible with anything the client cannot see, which includes passkeys and any federated login. + +For a CLI or another device without a browser, use the [device code grant](#device-code) instead. For a first-party web application, use [authorization code](#authorization-code) with PKCE. +::: The client collects the credentials itself and posts them to the token endpoint. Simpler, and weaker: the client sees the user's password. @@ -66,7 +88,7 @@ The steps: 4. Client completes MFA challenge with the temporary token 5. FerrisKey returns full tokens -Best for trusted first-party applications, testing, and CLI tools. +Only appropriate for an existing integration that has not migrated yet. :::callout{variant="warning" title="Direct access grants required"} The client needs `direct_access_grants_enabled` for this flow to work. @@ -94,9 +116,132 @@ The steps: Best for any flow that issued a refresh token and needs to keep a session alive. +### Device code + +``` +urn:ietf:params:oauth:grant-type:device_code +``` + +The flow for anything that cannot host a browser, or cannot be trusted with a password: a CLI, a TV app, a headless box. The device never sees the credential. It asks FerrisKey for a code, tells the user where to type it, and polls until the user has approved it in their own browser — where MFA, passkeys and federated login all work normally. + +This is how `ferris-ctl login` connects. + +The steps: + +1. The device posts to `/realms/{realm}/protocol/openid-connect/auth/device`. A public client passes `client_id` in the form body; a confidential client authenticates with HTTP Basic. +2. FerrisKey returns a device code, a short end-user code, and the verification URI to display. +3. The device shows the user code and the URI, and starts polling the token endpoint with `grant_type=urn:ietf:params:oauth:grant-type:device_code` and the device code. +4. The user opens `/realms/{realm}/device` in any browser, enters the code, and authenticates — full chain, MFA included. +5. The next poll returns tokens. + +While the user has not finished, the poll answers with the RFC 8628 error codes — `authorization_pending`, `slow_down`, `access_denied`, `expired_token` — so a client can tell "not yet" from "refused" and back off accordingly. + +| Endpoint | Purpose | +|---|---| +| `POST /realms/{realm_name}/protocol/openid-connect/auth/device` | Start the flow, get the codes | +| `GET /realms/{realm_name}/device` | Page where the user enters the code | +| `POST /realms/{realm_name}/device/verify` | Submit the code | +| `POST /realms/{realm_name}/protocol/openid-connect/token` | Poll for the tokens | + +:::callout{variant="warning" title="The client has to allow it"} +The client needs `oauth_device_code_grant_enabled`. Without it the first call is refused. +::: + +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 + +``` +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. + +## The OIDC endpoints + +These are the integration surface: what an application, a resource server or a gateway talks to. All are relative to `/realms/{realm_name}/protocol/openid-connect`. + +| Method | Path | Purpose | +|---|---|---| +| `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/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) | +| `GET` `POST` | `/logout` | RP-initiated logout | +| `GET` | `/certs` | The realm's JWK keys | +| `GET` | `/jwks.json` | The realm's JWKS, for validating signatures | +| `POST` | `/registrations` | Self-service registration, when the realm allows it | + +:::callout{variant="info" title="Introspection is for confidential clients only"} +`/token/introspect` may only be called by a confidential client, authenticating with `client_secret_basic` or `client_secret_post`, and the caller's service account needs the right permission. A public client cannot introspect — validate the JWT signature against `/jwks.json` instead, which needs no credential and no round trip. +::: + +Logout accepts `id_token_hint`, `post_logout_redirect_uri`, `state` and `client_id`. The redirect target has to be registered on the client as a [post-logout redirect](/en/discover/core-concepts/clients#sub-resources), otherwise it is refused. + +`/certs` and `/jwks.json` both expose key material and neither requires authentication — they are meant to be public. There is also an unannotated `/jwks` alias that answers the same thing; prefer `/jwks.json`, which is the one the discovery document advertises. + +:::callout{variant="info" title="Building your own login pages?"} +The endpoints the portal itself drives — the login form, the MFA challenge, the reset flow — are a separate family. See [Login Actions](/en/modules/portal/login-actions). +::: + +## SAML 2.0 + +For applications that speak nothing else, FerrisKey acts as a **SAML identity provider**: a service provider sends an `AuthnRequest`, the user signs in through the normal FerrisKey login, and FerrisKey posts back a signed assertion. + +### Bindings instead of grants + +SAML has no grant types. What it has is **bindings** — how the request travels — and FerrisKey serves both on one path: + +| Method | Path | Binding | +|---|---|---| +| `GET` | `/realms/{realm_name}/protocol/saml` | HTTP-Redirect | +| `POST` | `/realms/{realm_name}/protocol/saml` | HTTP-POST | +| `GET` | `/realms/{realm_name}/protocol/saml/continue` | Resume after the user has authenticated | +| `GET` | `/realms/{realm_name}/protocol/saml/descriptor` | IdP metadata to hand to a service provider | + +The descriptor is the equivalent of the discovery document: point a service provider at that URL and it learns the entity id, the SSO endpoints and the signing certificate on its own. + +### A client speaks one protocol + +A client carries a `protocol` — `openid-connect` (the default) or `saml` — and **it is set at creation and cannot be changed afterwards**, like the confidential/public choice. An application that migrates from SAML to OIDC gets a new client, not an edited one. + +A SAML client has two collections of its own: + +``` +GET PUT /realms/{realm_name}/clients/{client_id}/saml-config +GET POST /realms/{realm_name}/clients/{client_id}/saml-attribute-mappers +DELETE /realms/{realm_name}/clients/{client_id}/saml-attribute-mappers/{mapper_id} +``` + +Attribute mappers are SAML's answer to [protocol mappers](/en/modules/aegis/protocol-mappers): they decide which user data lands in the assertion. The two systems are separate — a protocol mapper does not affect an assertion, and an attribute mapper does not affect a JWT. + +### What SAML does not get + +The OIDC surface has no SAML counterpart, and that is a protocol difference rather than a gap: + +- **No refresh tokens.** The assertion is the whole answer; there is nothing to refresh. +- **No `userinfo`, no introspection.** Everything the service provider learns is inside the assertion it already holds. +- **No device flow, no client credentials.** Both are OAuth2 constructs. + +### One configuration trap + +The IdP entity id is derived from the public base URL and signed into **every** assertion, so it cannot move. With `SERVER_PUBLIC_URL` unset, FerrisKey falls back to the request's `Host` header — harmless for OIDC, and fatal for SAML the moment a request arrives through a different hostname: the entity id changes and service providers reject the assertion. + +:::callout{variant="warning" title="Set SERVER_PUBLIC_URL before enabling SAML"} +This is the single most common SAML misconfiguration, and it fails intermittently rather than outright — which makes it expensive to diagnose. Set it to the origin browsers and service providers actually reach. See [Configuration](/en/discover/guides/configuration). +::: + +[SAML 2.0](/en/discover/core-concepts/saml) has the rest: registering a service provider, Name ID formats, the attribute mapper reference, the full flow diagram, and assertion validity. + ## Authentication chain -Every user authentication runs through the same chain: +Both protocols run through the same chain. Whether the request arrived as an OIDC authorization request or a SAML `AuthnRequest`, what happens between the user and the credential is identical: ```mermaid graph TD @@ -115,7 +260,9 @@ graph TD 1. Credential validation. The username and password are verified. 2. Required actions. If any are pending (`configure_otp`, `verify_email`, `update_password`, `configure_passkey`), a temporary token comes back instead of the real thing. 3. MFA. If TOTP or WebAuthn is configured, the challenge has to be answered. -4. Token issuance. Access, refresh, and ID tokens are generated. +4. Token issuance. Access, refresh and ID tokens are generated — or, for a SAML request, the assertion is built and signed. + +Everything downstream is shared too. [Compass](/en/modules/compass/overview) records SAML executions alongside OIDC ones, with `saml_authn_request` and `saml_assertion` as their own steps, and [SeaWatch](/en/modules/seawatch/overview) logs the same `login_success` and `login_failure` events whichever protocol asked. There is no separate SAML audit trail to go looking for. ## Auth sessions diff --git a/apps/docs/src/content/docs/discover/default/en/core-concepts/client-scopes.mdx b/apps/docs/src/content/docs/discover/default/en/core-concepts/client-scopes.mdx index 3b43c67..43769fa 100644 --- a/apps/docs/src/content/docs/discover/default/en/core-concepts/client-scopes.mdx +++ b/apps/docs/src/content/docs/discover/default/en/core-concepts/client-scopes.mdx @@ -2,7 +2,7 @@ title: Client Scopes description: "OAuth2 scopes, protocol mappers, and token claim control in FerrisKey." icon: scan -order: 15 +order: 16 --- # Client Scopes @@ -28,11 +28,21 @@ Every realm is seeded with a standard set of OIDC scopes, and every client in th | `openid` | default | none of its own; marks the request as OIDC | | `profile` | default | `given_name`, `family_name`, `preferred_username` | | `email` | default | `email`, `email_verified` | +| `organization` | default | `organizations` | | `roles` | default | `realm_access.roles` | | `offline_access` | optional | none of its own | | `phone` | optional | `phone_number` | | `address` | optional | `address` | +FerrisKey client scopes list showing the eight seeded scopes with their assignment and mapper counts +FerrisKey client scopes list showing the eight seeded scopes with their assignment and mapper counts + +:::callout{variant="info" title="openid and offline_access carry no mapper"} +Two of the eight seeded scopes write no claim of their own. `openid` marks the request as OIDC and `offline_access` unlocks refresh-token issuance — both act on the flow, not on the payload. A scope with zero mappers is not necessarily misconfigured. +::: + +The `organization` scope is the newest, and the only one whose two mappers deliberately target the **same** claim: the membership mapper contributes each organization's `id`, `name` and `alias`, the role mapper contributes `roles` and `clients..roles`, and the two deep-merge into one `organizations` object. + ## Protocol mappers A mapper is identified by a `mapper_type` string. FerrisKey keeps the Keycloak names, so configuration written for Keycloak is recognizable here. @@ -63,6 +73,40 @@ Each mapper carries a JSON `config`. The keys follow the Keycloak convention: `claim.name` supports dotted paths, so `realm_access.roles` produces a nested object rather than a flat key with a dot in its name. +## Managing scopes + +``` +GET /realms/{realm_name}/client-scopes +POST /realms/{realm_name}/client-scopes +GET /realms/{realm_name}/client-scopes/{scope_id} +PATCH /realms/{realm_name}/client-scopes/{scope_id} +DELETE /realms/{realm_name}/client-scopes/{scope_id} +``` + +Mappers live under their scope: + +``` +POST /realms/{realm_name}/client-scopes/{scope_id}/protocol-mappers +PATCH /realms/{realm_name}/client-scopes/{scope_id}/protocol-mappers/{mapper_id} +DELETE /realms/{realm_name}/client-scopes/{scope_id}/protocol-mappers/{mapper_id} +``` + +### Assigning a scope to a client + +``` +GET /realms/{realm_name}/clients/{client_id}/client-scopes +PUT /realms/{realm_name}/clients/{client_id}/default-client-scopes/{scope_id} +DELETE /realms/{realm_name}/clients/{client_id}/default-client-scopes/{scope_id} +PUT /realms/{realm_name}/clients/{client_id}/optional-client-scopes/{scope_id} +DELETE /realms/{realm_name}/clients/{client_id}/optional-client-scopes/{scope_id} +``` + +A **default** scope applies to every request from that client. An **optional** scope applies only when the request names it in its `scope` parameter. The two lists are separate: assigning a scope as default does not remove it from the optional list, so move a scope by unassigning it from one and assigning it to the other. + +**Required permissions:** `view_client_scopes` or `manage_client_scopes` to read, `manage_client_scopes` to write. `manage_realm` grants both. + +`POST /realms/{realm_name}/clients/{client_id}/evaluate-scopes` previews the claims a client's current assignment produces, without a login. + ## How scopes shape a token ```mermaid 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 2c4ad72..7d8187b 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 @@ -23,6 +23,24 @@ There are three: Pick confidential when your application has a backend that can hold the secret safely. Pick public for anything running in a browser or on a device, where the secret would end up in the user's hands anyway. ::: +## Capability config + +Which flows a client may take is set on its settings screen, and two of the choices are permanent. + +FerrisKey client settings showing general fields and the capability config with public/confidential and grant switches +FerrisKey client settings showing general fields and the capability config with public/confidential and grant switches + +**Client authentication is set at creation and final.** A client cannot move between confidential and public afterwards. Pick confidential when a backend can hold a secret server-side, public for SPAs, mobile apps and CLIs where a secret would end up in the user's hands anyway. + +The grant switches are not permanent, and each one widens the attack surface: + +| Switch | Turn it on when | +|---|---| +| 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 | + +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. + ## Client properties | Property | Description | @@ -32,7 +50,10 @@ Pick confidential when your application has a backend that can hold the secret s | `protocol` | Protocol type (e.g., `openid-connect`) | | `enabled` | Whether the client can initiate authentication | | `redirect_uris` | Allowed redirect URIs after authentication | -| `direct_access_grants_enabled` | Allow the password grant type | +| `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 | +| `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 | ## Token lifetime overrides @@ -52,6 +73,31 @@ A value set on the client wins over the realm default. Leave it `null` and the r With this enabled, a client can use the resource owner password credentials grant, sending a username and password straight to the token endpoint. It is handy for trusted first-party applications and for testing. Do not enable it for third-party clients. +## Sub-resources + +A client is not one object: several of its properties are collections with their own endpoints. + +| Collection | Endpoints | What it controls | +|---|---|---| +| Redirect URIs | `GET POST` `…/{client_id}/redirects`, `PUT DELETE` `…/redirects/{uri_id}` | Where FerrisKey may send the browser back after login | +| Post-logout redirects | `GET POST` `…/{client_id}/post-logout-redirects`, `PUT DELETE` `…/post-logout-redirects/{uri_id}` | Where it may send the browser after logout | +| Web origins | `GET POST` `…/{client_id}/web-origins`, `DELETE` `…/web-origins/{web_origin_id}` | Browser origins allowed to call realm-scoped routes | +| 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) | + +:::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. +::: + +### Previewing the token a client would get + +``` +POST /realms/{realm_name}/clients/{client_id}/evaluate-scopes +``` + +Returns the claims this client's active scopes would produce, without a login. It is the fastest way to check a [protocol mapper](/en/modules/aegis/protocol-mappers) before wiring an application to it. + ## Service accounts A client with `service_account_enabled` can authenticate through the client credentials grant, with no user involved. FerrisKey creates a linked service account user for it, and that user takes roles and permissions like any other. diff --git a/apps/docs/src/content/docs/discover/default/en/core-concepts/credentials.mdx b/apps/docs/src/content/docs/discover/default/en/core-concepts/credentials.mdx index 18eeec3..a822ecf 100644 --- a/apps/docs/src/content/docs/discover/default/en/core-concepts/credentials.mdx +++ b/apps/docs/src/content/docs/discover/default/en/core-concepts/credentials.mdx @@ -7,6 +7,10 @@ order: 13 # Credentials +:::callout{variant="info" title="Migrating from another IAM?"} +Password hashes from another identity provider can be imported per user and are re-encoded as argon2id at first login — see [Export & Import](/en/discover/guides/portability#password-hashes). +::: + A credential is proof of identity. A user can hold several at once, which is what makes multi-factor authentication possible. ## Credential types diff --git a/apps/docs/src/content/docs/discover/default/en/core-concepts/password-policy.mdx b/apps/docs/src/content/docs/discover/default/en/core-concepts/password-policy.mdx new file mode 100644 index 0000000..a51b64a --- /dev/null +++ b/apps/docs/src/content/docs/discover/default/en/core-concepts/password-policy.mdx @@ -0,0 +1,87 @@ +--- +title: Password Policy +description: "The rules a realm applies to passwords, and the public endpoint that lets a form check them before submitting." +icon: key-round +order: 14 +--- + +# Password Policy + +Each realm carries its own password policy, stored separately from the rest of the realm settings and edited on its own screen. It is enforced everywhere a password is set: registration, an administrative reset, and a user changing their own. + +FerrisKey realm settings password policy tab showing length, character class, expiry and entropy rules +FerrisKey realm settings password policy tab showing length, character class, expiry and entropy rules + +## The rules + +| Field | Default | Description | +|---|---|---| +| `min_length` | `8` | Minimum number of characters | +| `require_uppercase` | `true` | At least one uppercase letter | +| `require_lowercase` | `true` | At least one lowercase letter | +| `require_number` | `true` | At least one digit | +| `require_special` | `true` | At least one special character | +| `max_age_days` | `0` | Force a change after this many days. `0` disables expiry | +| `min_entropy_bits` | `20` | Reject passwords weaker than this Shannon entropy. `0` disables the check | +| `forbid_common` | `true` | Reject passwords from the common-password list, and those reusing the username or the email address | +| `check_breached` | `false` | Reject passwords reported as breached | + +:::callout{variant="warning" title="check_breached needs a provider to do anything"} +Turning it on without an external breach-lookup provider configured has no effect. The setting stores, the check does not run. Do not count it as a control until the provider is in place. +::: + +## Entropy is the rule that does the real work + +The four character-class requirements are the familiar ones, and they are also the weakest. `Passw0rd!` satisfies every one of them and is among the first few thousand guesses any attacker makes. `forbid_common` is what actually catches that particular password; `min_entropy_bits` is what catches the next ten thousand like it. + +The default of 20 bits is deliberately permissive — it is a floor that rejects the obviously trivial without surprising anyone migrating an existing user base. [CNIL deliberation 2022-100](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000046444507) suggests 80 bits where the password is the only factor, and the console points at that figure directly. + +Two things follow: + +- **If you raise entropy, you can relax the character classes.** A long passphrase clears 80 bits comfortably and contains no special character. Requiring one pushes users toward `Password1!` rather than toward something strong. +- **If MFA is mandatory on the realm** through `require_mfa`, the password is not the only factor, and the CNIL figure is not the bar you are being held to. + +## Expiry is usually the wrong lever + +`max_age_days` defaults to `0`, which is off, and that is the right default for most realms. Forced rotation reliably produces `Summer2026!` followed by `Autumn2026!`, and current guidance from both NIST and the UK's NCSC is to drop scheduled expiry in favour of screening against breach lists and reacting to actual compromise. + +Set it when a compliance regime requires it, not because it feels prudent. + +## The public endpoint + +``` +GET /realms/{realm_name}/password-policy/public +``` + +Unauthenticated, and it returns the rules without the realm's other configuration. This exists so a registration form or a password field can evaluate the policy **as the user types** rather than rejecting them after a round trip — which is exactly what the account console does. + +Use it rather than hard-coding the rules in your frontend: the realm's policy can change, and a form that checks a stale copy tells users their password is fine and then watches the server refuse it. + +## API reference + +| Method | Path | Description | +|---|---|---| +| `GET` | `/realms/{realm_name}/password-policy` | Read the full policy | +| `PUT` | `/realms/{realm_name}/password-policy` | Update the policy | +| `GET` | `/realms/{realm_name}/password-policy/public` | Read the rules, unauthenticated | + +**Required permissions:** `manage_realm`, for the read as well as the update. The public endpoint needs none. + +:::callout{variant="info" title="Changing the policy does not invalidate existing passwords"} +A stricter policy applies to the next password each user sets. Passwords already stored keep working — nothing scans them against the new rules. If you need the whole realm to re-comply, `max_age_days` is the lever that forces it, one user at a time as they next sign in. +::: + +## Imported passwords + +Passwords migrated from another IAM are a special case. FerrisKey accepts a bcrypt hash on import and re-hashes it to argon2id the first time the user signs in successfully — the plaintext is only available at that moment, and that is when the upgrade happens. The event is recorded as `password_imported` in [SeaWatch](/en/modules/seawatch/event-types). + +The policy is not applied retroactively to an imported password. It applies when that user next changes it. + +::::card-group{cols=2} +:::card{label="Credentials" icon="lucide:key" href="/en/discover/core-concepts/credentials"} +How passwords, OTP secrets and passkeys are stored. +::: +:::card{label="Account Security" icon="lucide:shield-check" href="/en/modules/account/security"} +The self-service path a user takes to change their own password. +::: +:::: diff --git a/apps/docs/src/content/docs/discover/default/en/core-concepts/realms.mdx b/apps/docs/src/content/docs/discover/default/en/core-concepts/realms.mdx index 771ca13..0a8da6e 100644 --- a/apps/docs/src/content/docs/discover/default/en/core-concepts/realms.mdx +++ b/apps/docs/src/content/docs/discover/default/en/core-concepts/realms.mdx @@ -62,7 +62,7 @@ Every realm carries its own settings. They control how sign-in behaves, how long | `forgot_password_enabled` | `false` | Enable the password reset flow | | `remember_me_enabled` | `false` | Support remember-me sessions | | `magic_link_enabled` | `false` | Enable magic link sign-in by email | -| `magic_link_ttl_minutes` | `15` | How long a magic link stays valid | +| `magic_link_ttl` | `15` | How long a magic link stays valid | | `passkey_enabled` | `false` | Allow passkey (WebAuthn) sign-in | | `require_mfa` | `false` | Force every user in the realm to enroll in MFA | | `login_aliases` | `[email, username]` | Ordered list of identifiers accepted at login. Must be non-empty and free of duplicates | @@ -76,10 +76,10 @@ Every realm carries its own settings. They control how sign-in behaves, how long | Setting | Default | Description | |---|---|---| | `default_signing_algorithm` | `RS256` | JWT signing algorithm | -| `access_token_lifetime_secs` | `300` | Access token TTL | -| `refresh_token_lifetime_secs` | `86400` | Refresh token TTL | -| `id_token_lifetime_secs` | `300` | ID token TTL | -| `temporary_token_lifetime_secs` | `300` | Temporary token TTL, used while required actions are pending | +| `access_token_lifetime` | `300` | Access token TTL | +| `refresh_token_lifetime` | `86400` | Refresh token TTL | +| `id_token_lifetime` | `300` | ID token TTL | +| `temporary_token_lifetime` | `300` | Temporary token TTL, used while required actions are pending | ### Lockout @@ -99,17 +99,48 @@ SeaWatch can strip or pseudonymise personal data before an event is stored. | `seawatch_pii_mode` | `off` | One of `off`, `mask`, `pseudonymise` | | `seawatch_pseudo_key` | unset | HMAC key used when the mode is `pseudonymise` | +### Locales + +| Setting | Default | Description | +|---|---|---| +| `default_locale` | unset | Locale used when the user has expressed no preference | +| `supported_locales` | unset | Locales the login portal and emails may be rendered in | + +A user's own choice is stored on the user and set through `PUT /realms/{realm_name}/users/@me/locale`. + +### Webhook retries + +Leave these unset and the realm uses the server defaults. + +| Setting | Default | Description | +|---|---|---| +| `webhook_retry_max_attempts` | unset | How many times a failed delivery is retried | +| `webhook_retry_base_delay_ms` | unset | Delay before the first retry, in milliseconds | +| `webhook_retry_max_delay_ms` | unset | Ceiling for a single backoff interval | +| `webhook_retry_max_total_delay_ms` | unset | Ceiling for the whole retry sequence | + ### Branding and email templates | Setting | Default | Description | |---|---|---| -| `portal_theme_id` | unset | Theme applied to the login portal | | `reset_password_template_id` | unset | Template used for password reset emails | | `magic_link_template_id` | unset | Template used for magic link emails | | `email_verification_template_id` | unset | Template used for verification emails | Leave a template id unset and FerrisKey falls back to the built-in default for that email type. +:::callout{variant="info" title="The portal theme is not set here"} +A realm does carry an active portal theme, but it is not a field on this endpoint. Activate one with `POST /realms/{realm_name}/portal/themes/{theme_id}/activate` — see [Portal](/en/modules/portal/overview). +::: + +## Reading settings from the login page + +``` +GET /realms/{name}/login-settings +``` + +The login-facing subset of a realm's settings, which is what the portal reads to know which sign-in methods to offer — whether registration is open, whether magic links and passkeys are enabled, which identifiers are accepted. Use it rather than the full settings read when all you need is to render a login page. + ## SMTP configuration SMTP lives on the realm too, so two realms in the same deployment can send from different providers and different addresses. The [Email & Templates guide](/en/discover/guides/email) covers the fields and the API. diff --git a/apps/docs/src/content/docs/discover/default/en/core-concepts/roles.mdx b/apps/docs/src/content/docs/discover/default/en/core-concepts/roles.mdx index 3176499..63b82e5 100644 --- a/apps/docs/src/content/docs/discover/default/en/core-concepts/roles.mdx +++ b/apps/docs/src/content/docs/discover/default/en/core-concepts/roles.mdx @@ -2,7 +2,7 @@ title: Roles & Permissions description: "Bitwise role system in FerrisKey: permission bundles, bitmask operations, and role mappings." icon: shield-check -order: 14 +order: 15 --- # Roles & Permissions @@ -20,7 +20,7 @@ ManageClients = 0b0000...0100 (bit 2) ... ``` -A role stores its permissions as a single `u64` bitmask, the OR of all included permission bits. Checking whether a user has a permission is a single AND operation: +A role stores its permissions as a single `u64` bitmask, the OR of all included permission bits. Checking one permission is a single AND: ```rust title="Permission check" fn has_permission(user_permissions: u64, required: u64) -> bool { @@ -28,8 +28,40 @@ fn has_permission(user_permissions: u64, required: u64) -> bool { } ``` +:::callout{variant="info" title="An operation accepts several permissions, and any one of them grants"} +Most operations are guarded by a *set* of permissions rather than a single one, and holding **any** member of the set is enough. Reading a federation provider, for example, accepts either `manage_realm` or `view_realm`. So a `manage_*` permission generally implies the matching `view_*` and you do not need to grant both. +::: + ## Permissions reference +The console groups the 29 permissions into eleven families, which is also the most convenient way to reason about them — you rarely grant one permission, you grant a family's worth of read or a family's worth of write. + +FerrisKey role permissions editor showing permission families with their enabled counts +FerrisKey role permissions editor showing permission families with their enabled counts + +| Family | Permissions | +|---|---| +| User Management | 3 | +| Client Management | 4 | +| Role & Authorization | 4 | +| Realm Management | 3 | +| Identity Providers | 2 | +| Events & Audit | 2 | +| Groups | 1 | +| Client Scopes | 3 | +| Webhooks | 3 | +| Email Templates | 2 | +| Organizations | 2 | + +Editing a role's permission set is one call: + +``` +PATCH /realms/{realm_name}/roles/{role_id}/permissions +``` + +It replaces the set rather than merging into it, so send the full list you want the role to end up with. + + The API and the CLI both take permissions by their snake_case name, so `manage_realm`, not `ManageRealm` and not `realm:manage`. An unrecognized name is dropped silently when a role is saved, which is worth remembering when a role looks like it granted nothing. ### Manage @@ -47,6 +79,7 @@ The API and the CLI both take permissions by their snake_case name, so `manage_r | `manage_webhooks` | Configure webhook subscriptions | | `manage_client_scopes` | Manage client scopes and protocol mappers | | `manage_email_templates` | Create, update, and delete email templates | +| `manage_organizations` | Create, update, and delete organizations, their groups and their members | ### Query @@ -73,6 +106,22 @@ The API and the CLI both take permissions by their snake_case name, so `manage_r | `view_webhooks` | View webhook details | | `view_client_scopes` | View client scope details | | `view_email_templates` | View email templates | +| `view_organizations` | View organizations, their groups and their members | + +## Actions, and how a check is resolved + +Permissions are what a role stores. They are not how the code names an operation. + +Every authorization-guarded operation has a stable action id of the form `resource_type:verb` — `client:create`, `user_role:assign`, `security_event:read`. There are 95 of them, and the names are a public contract: an authorization engine resolves an action to the set of permissions that grant it, and the mapping is kept in one decision table rather than scattered across call sites. + +Two things follow that matter when you design roles: + +- **The vocabulary you grant and the vocabulary the code checks are different layers.** You assign `manage_users`; the code asks whether `user:update` is allowed. The engine bridges the two. +- **Some checks short-circuit on identity.** A handful of operations allow the subject when the subject *is* the target, whatever their permissions — which is how a user reads their own profile without holding `view_users`. Self-service goes through [its own endpoints](/en/modules/account/security) rather than the admin ones. + +:::callout{variant="info" title="Not the AuthZen API"} +This catalogue is internal: FerrisKey resolves it in-process and does not yet expose an [AuthZen](/en/learn/authzen/overview) decision endpoint. It is the vocabulary such a facade would speak if and when it ships. +::: ## Role mappings @@ -85,6 +134,27 @@ Take a user with two roles: The effective mask is `0b...11110`: they can view and manage users, view clients, and query users. +## Managing roles + +``` +GET /realms/{realm_name}/roles +POST /realms/{realm_name}/roles +GET /realms/{realm_name}/roles/{role_id} +PUT /realms/{realm_name}/roles/{role_id} +DELETE /realms/{realm_name}/roles/{role_id} +PATCH /realms/{realm_name}/roles/{role_id}/permissions +``` + +A client role is created under its client instead: `POST /realms/{realm_name}/clients/{client_id}/roles`. + +:::callout{variant="warning" title="A role's scope is fixed, and deletion is not retroactive"} +A role is attached to the realm or to a client at creation, and **moving it between scopes is not supported** — recreate it instead. + +Deleting a role permanently removes every user assignment it had, but **tokens already issued stay valid until they expire**. The window between the two is the access token lifetime, so revoke the sessions as well when you are removing a role because it should never have been granted. +::: + +**Required permissions:** `view_roles`, `manage_roles` or `manage_users` to read — `manage_users` is on the list because assigning a role requires seeing it. `manage_roles` to write. `manage_realm` grants everything. + ## Realm and client roles A realm role is defined on the realm and applies across all its clients. A client role is scoped to one client and only means something in that client's context. Both are assigned to users the same way, and role ids are unique across the two scopes. diff --git a/apps/docs/src/content/docs/modules/default/en/saml/overview.mdx b/apps/docs/src/content/docs/discover/default/en/core-concepts/saml.mdx similarity index 90% rename from apps/docs/src/content/docs/modules/default/en/saml/overview.mdx rename to apps/docs/src/content/docs/discover/default/en/core-concepts/saml.mdx index c022b72..846e588 100644 --- a/apps/docs/src/content/docs/modules/default/en/saml/overview.mdx +++ b/apps/docs/src/content/docs/discover/default/en/core-concepts/saml.mdx @@ -1,8 +1,8 @@ --- -title: Overview +title: SAML 2.0 description: "FerrisKey as a SAML 2.0 identity provider: the SSO endpoints, IdP metadata, and how an assertion is built." icon: file-badge -order: 60 +order: 18 --- # SAML 2.0 @@ -12,9 +12,11 @@ FerrisKey speaks SAML 2.0 as an identity provider. A service provider sends it a This is what gets an application that only supports SAML, and there are plenty of them, onto the same realm, the same users, and the same audit trail as everything else. :::callout{variant="info" title="Identity provider, not broker"} -This module makes FerrisKey the IdP that other applications trust. Signing in *through* an external SAML provider is a different problem, handled by federation in [Abyss](/en/modules/abyss/overview), and it is not shipped yet. +This page is about FerrisKey being the IdP that other applications trust. Signing in *through* an external SAML provider is the opposite direction, handled by federation in [Abyss](/en/modules/abyss/overview), and it is not shipped yet. ::: +The sign-in itself is not SAML-specific: a SAML request runs the same [authentication chain](/en/discover/core-concepts/authentication#authentication-chain) as an OIDC one — same login, same MFA, same lockout, same audit events — and only the front door and the response format differ. That page also sets the two protocols side by side if you are deciding which one an application should use. + ## Endpoints Every endpoint is realm-scoped, under the realm's protocol path. diff --git a/apps/docs/src/content/docs/discover/default/en/core-concepts/tokens.mdx b/apps/docs/src/content/docs/discover/default/en/core-concepts/tokens.mdx index 8010de9..1844efb 100644 --- a/apps/docs/src/content/docs/discover/default/en/core-concepts/tokens.mdx +++ b/apps/docs/src/content/docs/discover/default/en/core-concepts/tokens.mdx @@ -2,7 +2,7 @@ title: Tokens description: "JWT tokens in FerrisKey: access, refresh, ID, and temporary tokens, claims, and lifetimes." icon: ticket -order: 17 +order: 19 --- # Tokens diff --git a/apps/docs/src/content/docs/discover/default/en/core-concepts/users.mdx b/apps/docs/src/content/docs/discover/default/en/core-concepts/users.mdx index 893bc34..b22d937 100644 --- a/apps/docs/src/content/docs/discover/default/en/core-concepts/users.mdx +++ b/apps/docs/src/content/docs/discover/default/en/core-concepts/users.mdx @@ -151,6 +151,92 @@ The resulting access token has `sub` set to the service account user's `id`, and See [Authentication, client credentials](/en/discover/core-concepts/authentication#client-credentials) for the full grant. +## Administering a user + +The console groups everything about one account behind five tabs: overview, credentials, role mapping, organizations and attributes. + +FerrisKey user credentials tab showing the registered password, the reset form and its validity choice +FerrisKey user credentials tab showing the registered password, the reset form and its validity choice + +### Credentials + +``` +GET /realms/{realm_name}/users/{user_id}/credentials +DELETE /realms/{realm_name}/users/{user_id}/credentials/{credential_id} +PUT /realms/{realm_name}/users/{user_id}/reset-password +POST /realms/{realm_name}/users/{user_id}/credentials/import +``` + +A read lists the authentication methods registered on the account — password, authenticator, passkeys — never the secrets. The stored password is not displayed anywhere: it can only be replaced. + +A reset takes a **validity**: + +| Validity | Effect | +|---|---| +| Temporary | The user must change it at their next sign-in. Sets the `update_password` required action | +| Permanent | It stays valid until the user changes it themselves | + +:::callout{variant="warning" title="Two asymmetries worth knowing"} +Resetting a password replaces it immediately but **leaves open sessions valid** — an administrative reset alone does not cut access, so revoke the sessions too when that is the intent. And deleting a credential is immediate, irreversible, and the user is **not notified**: they discover it at their next sign-in. +::: + +`credentials/import` is the migration path for hashes coming from another IAM — see [Export & Import](/en/discover/guides/portability#password-hashes). + +### Roles + +FerrisKey user role mapping tab listing assigned roles with their permission counts +FerrisKey user role mapping tab listing assigned roles with their permission counts + +``` +GET /realms/{realm_name}/users/{user_id}/roles +POST /realms/{realm_name}/users/{user_id}/roles/{role_id} +DELETE /realms/{realm_name}/users/{user_id}/roles/{role_id} +GET /realms/{realm_name}/users/{user_id}/permissions +GET /realms/{realm_name}/users/{user_id}/organizations +``` + +`permissions` is the resolved set — the union of every role the account holds — which is what to read when you are debugging "why can this user do that". `roles` is the assignment list; `permissions` is the consequence. + +### Attributes + +``` +GET /realms/{realm_name}/users/{user_id}/attributes +PUT /realms/{realm_name}/users/{user_id}/attributes +DELETE /realms/{realm_name}/users/{user_id}/attributes/{key} +``` + +Free-form key-value metadata. Attributes reach tokens only through a [protocol mapper](/en/modules/aegis/protocol-mappers#oidc-usermodel-attribute-mapper) on an active scope — storing one changes nothing on its own. + +### Lifecycle operations + +``` +DELETE /realms/{realm_name}/users/bulk +POST /realms/{realm_name}/users/{user_id}/unlock +``` + +`bulk` deletes several accounts in one call, and it reports the batch as a batch: **one** `user.bulk_deleted` webhook carrying the id list, and **one** `user_deleted` audit event whose details hold `user_ids`. Neither produces one record per account, so a consumer that counts rows will undercount a bulk delete. + +`unlock` clears a lockout before its window expires. See below. + +### Identity provider links + +``` +GET /realms/{realm_name}/users/{user_id}/identity-provider-links +DELETE /realms/{realm_name}/users/{user_id}/identity-provider-links/{link_id} +``` + +The external identities [brokered](/en/modules/abyss/providers) onto this account. Deleting a link does not delete the account; the user simply can no longer sign in through that provider, and removal raises `identity_provider_link_removed` in the audit log. + +**Required permissions:** `view_users` or `manage_users` to read, `manage_users` to write. `manage_realm` grants both. + +## Lockout + +After too many failed attempts an account locks itself, governed by two realm settings: `lockout_threshold` (default 10 attempts) and `lockout_duration_seconds` (default 900, so fifteen minutes). + +The user carries the state: `failed_login_attempts` and `locked_until`. A locked account authenticates again on its own once the window passes — or immediately, if an administrator calls `unlock`. + +Failed attempts are recorded as `login_failure` in [SeaWatch](/en/modules/seawatch/event-types), which is what makes "one IP, forty accounts, nine attempts each" visible before the threshold quietly absorbs it. + ## Sessions A user can hold several sessions at once, one per browser, device, or service holding a valid refresh token. @@ -163,6 +249,19 @@ A user can hold several sessions at once, one per browser, device, or service ho Revoking a refresh token ends the user session immediately. Access tokens issued from it cannot be refreshed and simply expire on their own. [Tokens](/en/discover/core-concepts/tokens) and [Authentication, auth sessions](/en/discover/core-concepts/authentication#auth-sessions) have the details. +### Listing and revoking + +``` +GET /realms/{realm_name}/users/{user_id}/sessions +DELETE /realms/{realm_name}/users/{user_id}/sessions/{session_id} +``` + +:::callout{variant="info" title="API only"} +These two have no screen in the admin console — a user's sessions are reachable through the API only. The user's own [account page](/en/modules/account/overview) does have a sessions tab, so the person can review their own. +::: + +Session creation and revocation are both audited, as `session_created` and `session_revoked`. + ## Security considerations **Disable rather than delete.** Disabling keeps the audit history and the role assignments. Delete only when you are certain the identity will never come back. diff --git a/apps/docs/src/content/docs/discover/default/en/getting-started.mdx b/apps/docs/src/content/docs/discover/default/en/getting-started.mdx index af6d470..5c688ab 100644 --- a/apps/docs/src/content/docs/discover/default/en/getting-started.mdx +++ b/apps/docs/src/content/docs/discover/default/en/getting-started.mdx @@ -122,7 +122,7 @@ docker compose up -d The migration files are baked into the `ferriskey-api` image (under `/usr/local/src/ferriskey/migrations`), so the `db-migrations` service applies the schema without any checked-out source. These are the same images the repository's `registry` profile uses, extracted into a self-contained file you can drop onto any host or convert to a Docker Swarm stack. :::callout{variant="info" title="Pin a version for production"} -The images above track the latest tag. For reproducible deployments, pin an explicit tag (for example `ghcr.io/ferriskey/ferriskey-api:v0.7.0`) on both the `api` and `db-migrations` services, so they always run the same binary and schema. +The images above track the latest tag. For reproducible deployments, pin an explicit tag (for example `ghcr.io/ferriskey/ferriskey-api:v0.9.0`) on both the `api` and `db-migrations` services, so they always run the same binary and schema. ::: ## Run everything in one container @@ -254,7 +254,7 @@ A response containing an `access_token`, a `refresh_token`, and optionally an `i ## Connect a real application -The password grant above is fine for a quick API test. Real applications should use browser-based SSO with the OpenID Connect authorization code flow. +The password grant above is fine for a quick API test, and nothing else: it is [deprecated](/en/discover/core-concepts/authentication#password-resource-owner--deprecated). Real applications use browser-based SSO with the authorization code flow, and a CLI uses the device code grant. To connect a dashboard, an internal service, or a self-hosted tool, follow the application SSO guide next. It walks through which console screens to use, what values to copy, and how to debug the usual setup mistakes. diff --git a/apps/docs/src/content/docs/discover/default/en/guides/application-sso.mdx b/apps/docs/src/content/docs/discover/default/en/guides/application-sso.mdx index ad349ce..a2f5cf4 100644 --- a/apps/docs/src/content/docs/discover/default/en/guides/application-sso.mdx +++ b/apps/docs/src/content/docs/discover/default/en/guides/application-sso.mdx @@ -50,9 +50,10 @@ A realm keeps users, clients, roles, and scopes together. Avoid using `master` f ::::step-group :::step{title="Open the realm selector"} -Click the current realm name in the left sidebar. Select the realm that will own this application, or click **Create Realm** if you need a new one. +Click the realm name in the **top bar**, next to the FerrisKey logo. Pick the realm that will own this application, or **Create realm** if you need a new one. The selector is searchable, which matters once a deployment holds more than a handful. -FerrisKey realm selector showing existing realms and the Create Realm action +FerrisKey realm selector open in the top bar, listing the realms and the Create realm action +FerrisKey realm selector open in the top bar, listing the realms and the Create realm action ::: :::step{title="Stay in the application realm"} @@ -66,9 +67,23 @@ The client is the application entry in FerrisKey. It tells FerrisKey which app i ::::step-group :::step{title="Open Clients"} -In the sidebar, click **Clients**, then click **New Client**. +In the sidebar, click **Clients**, then **New client**. -FerrisKey clients list with the New Client button +FerrisKey clients list with counters for total, active, public and confidential clients, and the New client button +FerrisKey clients list with counters for total, active, public and confidential clients, and the New client button + +The banner at the top of that list is worth reading before you add anything: it names every client that has **no redirect URI**, because the authorization code flow cannot work without one. +::: + +:::step{title="Choose the protocol"} +Creation starts by asking which protocol the application speaks: **OpenID Connect** for anything token-based, or **SAML 2.0** for applications that speak nothing else. + +FerrisKey client protocol dialog offering OpenID Connect or SAML 2.0 +FerrisKey client protocol dialog offering OpenID Connect or SAML 2.0 + +:::callout{variant="warning" title="This choice is final"} +It decides how the application signs its users in and **cannot be changed after creation**. An application that later moves from SAML to OIDC needs a new client, not an edited one. See [Authentication](/en/discover/core-concepts/authentication#saml-20) for what each protocol gives you. +::: ::: :::step{title="Fill the client details"} @@ -76,12 +91,15 @@ Use a stable client ID. In this example: - **Client ID:** `vaultwarden` - **Name:** `Vaultwarden` -- **Client Enabled:** enabled -- **Client Authentication:** enabled +- **Client enabled:** Enabled +- **Client authentication:** Confidential -Enable **Client Authentication** for server-side applications. FerrisKey will treat the client as confidential and generate a client secret. +**Client authentication is a choice between Confidential and Public, and it is also final.** Pick Confidential for a server-side application that can hold a secret — FerrisKey then generates one. Pick Public for an SPA, a mobile app or a CLI, where a secret would end up in the user's hands and PKCE carries the protection instead. -FerrisKey new client form with client ID, name, enabled switch, and client authentication switch +Leave both capability switches off for browser SSO: **Direct access grants** is the [deprecated password grant](/en/discover/core-concepts/authentication#password-resource-owner--deprecated), and the **device authorization grant** is for browserless clients. + +FerrisKey new client form with client ID, name, enabled state, the confidential/public choice and the capability switches +FerrisKey new client form with client ID, name, enabled state, the confidential/public choice and the capability switches ::: :::: @@ -95,7 +113,7 @@ Open the client you just created and stay on the **Settings** tab. ::: :::step{title="Add the redirect URI"} -In **Access Settings**, add the exact callback URL from your application. +Scroll to the **Access** section and add the exact callback URL from your application. Every entry is saved as you add it — there is no separate save button for this list. For the example: @@ -103,9 +121,13 @@ For the example: https://vault.example.com/identity/connect/oidc-signin ``` -Keep **Direct Access Grants** disabled unless the application explicitly asks for the password grant. Normal browser SSO should use the authorization code flow. +FerrisKey client Access section with redirect URIs, web origins and post-logout redirect URIs +FerrisKey client Access section with redirect URIs, web origins and post-logout redirect URIs + +Two neighbours in the same area matter as much as the redirect URI: -FerrisKey client settings showing client authentication, direct access grants, and redirect URIs +- **Web origins** — the origins this application may call FerrisKey from in a browser. The `+` button derives them from the redirect URIs you just entered, but only from **literal** ones: regex patterns are skipped. Removing an origin takes a few minutes to clear browser preflight caches, and up to 30 seconds to propagate to the other API replicas. +- **Post-logout redirect URIs**, under **Logout** — where the user lands once the session closes. With no entry, logout ends on the FerrisKey page rather than back in your application. ::: :::: @@ -117,14 +139,19 @@ Open the client's **Credentials** tab. ::: :::step{title="Copy the secret"} -Copy the **Client Secret** and put it in the application's OIDC configuration. Treat it like a password. +The secret is masked. Click **Reveal** to read it, then copy it into the application's OIDC configuration and treat it like a password. + +FerrisKey client credentials tab showing the client ID and the masked client secret with a Reveal action +FerrisKey client credentials tab showing the client ID and the masked client secret with a Reveal action -FerrisKey client credentials tab showing the client ID and client secret fields +Revealing a secret is **recorded as a security event** — `client_secret_viewed` in [SeaWatch](/en/modules/seawatch/event-types) — which is intentional: it means an unexplained reveal is visible in the audit trail. ::: :::: -:::callout{variant="warning" title="Rotate leaked secrets"} -If a client secret is pasted into a public issue, log, screenshot, or repository, rotate it and update the application immediately. +:::callout{variant="danger" title="A leaked secret cannot be rotated through the API yet"} +Rotating a client secret is **not exposed by the administration API** at this version, as the Credentials tab states. So if a secret is pasted into a public issue, a log, a screenshot or a repository, there is no rotate call to make. + +Until it ships, the containment path is to create a replacement client, move the application over, and delete the compromised one. Plan for that being slower than a rotation, and treat client secrets accordingly — in a secret manager, never in a screenshot. ::: ## Check the client scopes @@ -137,15 +164,18 @@ Open the client's **Client Scopes** tab. ::: :::step{title="Confirm required scopes"} -Make sure these scopes are assigned as **Default**: +Each assigned scope carries a **default / optional** switch. Make sure these three are on **default**, which means every token gets them without the application asking: - `openid` - `profile` - `email` -If the application needs roles, also assign `roles`. +If the application needs roles, set `roles` to default too. A new realm seeds eight scopes, and the remaining ones — `address`, `phone`, `offline_access` — arrive as **optional**, applied only when a request names them in its `scope` parameter. + +FerrisKey client Client Scopes tab listing the assigned scopes with their default and optional switches +FerrisKey client Client Scopes tab listing the assigned scopes with their default and optional switches -FerrisKey client scopes tab showing openid, profile, email, and roles as default scopes +The **Evaluate** sub-tab next to the assigned list answers the question this step is really about: it shows the claims this client would receive, without you having to run a login first. ::: :::: diff --git a/apps/docs/src/content/docs/discover/default/en/guides/architecture.mdx b/apps/docs/src/content/docs/discover/default/en/guides/architecture.mdx index c9125ec..51c06e4 100644 --- a/apps/docs/src/content/docs/discover/default/en/guides/architecture.mdx +++ b/apps/docs/src/content/docs/discover/default/en/guides/architecture.mdx @@ -2,7 +2,7 @@ title: Architecture description: "Hexagonal architecture, domain modules, and the ports & adapters pattern in FerrisKey." icon: boxes -order: 22 +order: 23 --- # Architecture @@ -57,6 +57,7 @@ core/src/domain/ ├── authentication/ # OAuth2 / OIDC flows ├── user/ # User lifecycle and required actions ├── account/ # Self-service account operations +├── account_security/ # Password, MFA and passkey self-service ├── client/ # OAuth2 clients ├── realm/ # Multi-tenant realms and settings ├── credential/ # Passwords, OTP, WebAuthn @@ -90,6 +91,7 @@ Feature crates hold domain and infrastructure logic for one module: ``` libs/ ├── ferriskey-domain/ # Shared domain types +├── ferriskey-authz/ # Authorization engine and action catalogue ├── ferriskey-security/ # Hashing and crypto primitives ├── ferriskey-trident/ # MFA ├── ferriskey-abyss/ # Identity provider federation diff --git a/apps/docs/src/content/docs/discover/default/en/guides/configuration.mdx b/apps/docs/src/content/docs/discover/default/en/guides/configuration.mdx index 4817c2b..e354864 100644 --- a/apps/docs/src/content/docs/discover/default/en/guides/configuration.mdx +++ b/apps/docs/src/content/docs/discover/default/en/guides/configuration.mdx @@ -44,7 +44,7 @@ The admin account is created on first boot in the `master` realm. | `SERVER_PUBLIC_URL` | unset | The origin browsers and service providers reach this deployment at, as `scheme://host[:port]` | | `ALLOWED_ORIGINS` | empty | Comma-separated browser origins allowed on every route | | `WEBAPP_URL` | `http://localhost:5555` | URL of the admin console, used when building links | -| `ENV` | `development` | Deprecated. Kept for compatibility and ignored by new code | +| `ENV` | `development` | Reported back by `/config` and otherwise unused. Kept for compatibility | `ALLOWED_ORIGINS` deserves a note of its own. Each entry must be a serialized origin (`scheme://host[:port]`), with no path and no wildcard. It applies to every route, including the ones that carry no realm: `/config`, the health probes, and the API documentation. Clients also declare their own web origins per realm, but those only cover realm-scoped routes, so a console served from a different origin than the API still needs its origin listed here. @@ -68,6 +68,12 @@ Both variables are required together. Set neither to serve plain HTTP and termin | `LOG_FILTER` | `info` | [`EnvFilter`](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives) directives, for example `info,ferriskey_core=debug` | | `LOG_JSON` | `false` | Emit structured JSON logs instead of human-readable lines | +### Webhooks + +| Variable | Default | Description | +|---|---|---| +| `WEBHOOK_ALLOW_PRIVATE_ENDPOINTS` | `false` | Allow webhook endpoints to resolve to loopback or private addresses. For local development only. Link-local addresses stay refused regardless | + ### Observability | Variable | Default | Description | @@ -134,6 +140,10 @@ Then run the server: ./ferriskey-api ``` +## Moving configuration between realms + +Portal themes, portal layouts and email templates export and import as files, and password hashes from another IAM can be imported per user. See [Export & Import](/en/discover/guides/portability). + ## SMTP configuration Email delivery for magic links, password resets, and email verification is configured per realm, in the database, not through environment variables. Open the admin console, go to **Realm Settings → Email**, and fill in the host, port, sender address, credentials, and encryption mode. diff --git a/apps/docs/src/content/docs/discover/default/en/guides/contributing.mdx b/apps/docs/src/content/docs/discover/default/en/guides/contributing.mdx index 8275310..fe8183a 100644 --- a/apps/docs/src/content/docs/discover/default/en/guides/contributing.mdx +++ b/apps/docs/src/content/docs/discover/default/en/guides/contributing.mdx @@ -2,7 +2,7 @@ title: Contributing description: "How to contribute issues, documentation, code, tests, and pull requests to FerrisKey." icon: git-pull-request -order: 23 +order: 24 --- # Contributing diff --git a/apps/docs/src/content/docs/discover/default/en/guides/email.mdx b/apps/docs/src/content/docs/discover/default/en/guides/email.mdx index abb29dd..37338a6 100644 --- a/apps/docs/src/content/docs/discover/default/en/guides/email.mdx +++ b/apps/docs/src/content/docs/discover/default/en/guides/email.mdx @@ -98,6 +98,11 @@ Turning a toggle on in Realm Settings activates the matching flow. Until you ass --- +FerrisKey email templates page showing per-type assignment and the Import action +FerrisKey email templates page showing per-type assignment and the Import action + +Assignment is per email type, and a type with nothing assigned is not broken: FerrisKey falls back to its plain-text default, which the page states explicitly rather than leaving you to infer. + ## Customizing templates ### Template engine diff --git a/apps/docs/src/content/docs/discover/default/en/guides/portability.mdx b/apps/docs/src/content/docs/discover/default/en/guides/portability.mdx new file mode 100644 index 0000000..dcd4315 --- /dev/null +++ b/apps/docs/src/content/docs/discover/default/en/guides/portability.mdx @@ -0,0 +1,143 @@ +--- +title: Export & Import +description: "Move themes, layouts, email templates and password hashes between realms and deployments." +icon: arrow-left-right +order: 22 +--- + +# Export & Import + +Four things in FerrisKey are portable: portal themes, portal layouts, email templates, and password hashes coming from another identity provider. Everything else is configured per realm and stays there. + +The four are not variations on one feature — they solve different problems, and the asymmetries are deliberate. + +| Resource | Export | Import | Why | +|---|---|---|---| +| Portal theme | ✓ | ✓ | Build a brand once, ship it to every realm | +| Portal layout | ✓ | ✓ | Same, for the page structure | +| Email template | ✓ | ✓ | Same, and hand MJML to a designer | +| Password hash | — | ✓ | Migrate users off another IAM. There is no export, on purpose | + +## Portal themes + +``` +GET /realms/{realm_name}/portal/themes/{theme_id}/export +POST /realms/{realm_name}/portal/themes/import +``` + +The export is a JSON envelope carrying the theme's design tokens, **every one of its twelve page trees**, and the layout that frames it — the layout by value, inside the file. That last part is what makes the file genuinely self-contained: importing it into another realm or another deployment recreates the layout too and binds it to the new theme, rather than dangling a reference to a layout id that does not exist there. + +FerrisKey portal themes tab with the Import action +FerrisKey portal themes tab with the Import action + +:::callout{variant="info" title="An imported theme is not active"} +Import creates the theme; it does not activate it. Activation is a separate call, and it is gated on every page being valid — see [Portal](/en/modules/portal/overview#activation-is-gated-on-validity). That ordering is what lets you import into production and review before anyone sees it. +::: + +## Portal layouts + +``` +GET /realms/{realm_name}/portal-layouts/{layout_id}/export +POST /realms/{realm_name}/portal-layouts/import +``` + +FerrisKey portal layouts tab with the Import action and layout counters +FerrisKey portal layouts tab with the Import action and layout counters + +A layout exports as its tree. On import the tree is **validated before it is stored** — and that is the interesting difference: the ordinary create endpoint is fed by the console's own builder, which only ever produces valid trees, so it trusts its input. The import endpoint cannot make that assumption, so it checks. + +Practical consequence: if you are generating layouts from a script, import is the endpoint that will tell you your tree is wrong. Create will accept it and you will find out when the portal renders. + +## Email templates + +``` +GET /realms/{realm_name}/email-templates/{template_id}/export?format=json +GET /realms/{realm_name}/email-templates/{template_id}/export?format=mjml +POST /realms/{realm_name}/email-templates/import +``` + +FerrisKey email templates page showing type assignment and the Import action +FerrisKey email templates page showing type assignment and the Import action + +Export takes a `format`: + +| Format | Returns | Use it when | +|---|---|---| +| `json` (default) | The builder envelope | You are moving the template between realms and want it to stay editable in the console | +| `mjml` | The rendered MJML markup | You are handing it to a designer, or keeping it in version control | + +Import accepts either shape. MJML is parsed back into a builder structure so the imported template stays editable rather than becoming an opaque blob. + +:::callout{variant="warning" title="MJML round-trips lose everything outside mj-body"} +The builder can only represent what is inside ``. Anything outside it — ``, and therefore custom fonts, ``, breakpoint declarations and preview text set that way — **is dropped on import**. + +So a `json` → `json` round trip is lossless, and an `mjml` round trip is not. If a designer edits the MJML and adds anything in the head, importing it back silently discards that part. Export as `json` for moving templates, and treat `mjml` as a one-way door out. +::: + +A template's **type is fixed at creation** and decides which variables it may use, so an import lands as the type the file declares. See [Email & Templates](/en/discover/guides/email) for the variables each type carries. + +## Password hashes + +``` +POST /realms/{realm_name}/users/{user_id}/credentials/import +``` + +This is the migration endpoint: it stores a hash produced by **another** identity provider as the user's password credential, so your users keep their existing passwords when you move to FerrisKey. + +```json title="Request body" +{ + "algorithm": "bcrypt", + "secret_data": "$2b$12$…", + "hash_iterations": 12, + "salt": null +} +``` + +| Field | Required | Description | +|---|---|---| +| `algorithm` | yes | `bcrypt` or `argon2` | +| `secret_data` | yes | The hash itself | +| `hash_iterations` | yes | Cost factor from the source system | +| `salt` | no | When the source stores it separately from the hash | + +Supported algorithms are **bcrypt** (`$2a$`, `$2b$`, `$2y$`) and **argon2** as a PHC string. + +### The upgrade happens at first login + +An imported bcrypt hash is re-encoded as **argon2id the first time the user successfully signs in** — the only moment the plaintext is available to hash again. Until then the account authenticates against the original hash. + +That has two consequences worth planning for: + +- **Your realm will hold a mix of algorithms** for as long as some migrated users have not signed in. That is expected, not a defect. +- **The upgrade is observable.** It raises `password_imported` in [SeaWatch](/en/modules/seawatch/event-types), so counting those events tells you how far through the migration your user base actually is — which is a far better signal than counting imported accounts. + +:::callout{variant="danger" title="There is no export counterpart, and that is the point"} +FerrisKey will not hand you its password hashes. An endpoint that exports credential material is an endpoint that exfiltrates it, and no legitimate migration needs to leave *this* way. If you are moving off FerrisKey, run both systems side by side and let users re-authenticate. +::: + +### The local policy still applies afterwards + +An imported password is not checked against the realm's [password policy](/en/discover/core-concepts/password-policy) — it cannot be, since the plaintext was never seen. The policy applies the next time that user changes their password. If the source system's rules were weaker than yours, `max_age_days` is the lever that forces the whole migrated population through a change. + +## What is not portable + +Realms, clients, users, roles, scopes and organizations have no export/import endpoints. Moving those is the job of the [ferris-ctl importer](/en/cli/import/overview), which reads a description file, a live Keycloak or a live Zitadel and creates the objects through the ordinary API. + +## Permissions + +| Resource | Export | Import | +|---|---|---| +| Portal theme, portal layout | `manage_realm` | `manage_realm` | +| Email template | `view_email_templates` or `manage_email_templates` | `manage_email_templates` | +| Password hash | — | `manage_users` | + +`manage_realm` grants all of them. + +::::card-group{cols=2} +:::card{label="Portal" icon="lucide:palette" href="/en/modules/portal/overview"} +What a theme and a layout contain before you move them. +::: +:::card{label="CLI Import" icon="lucide:download" href="/en/cli/import/overview"} +Importing whole realms from Keycloak or Zitadel. +::: +:::: diff --git a/apps/docs/src/content/docs/discover/default/en/what-is-ferriskey.mdx b/apps/docs/src/content/docs/discover/default/en/what-is-ferriskey.mdx index 60b3d8c..7a8b2be 100644 --- a/apps/docs/src/content/docs/discover/default/en/what-is-ferriskey.mdx +++ b/apps/docs/src/content/docs/discover/default/en/what-is-ferriskey.mdx @@ -53,7 +53,7 @@ Event-driven integrations for lifecycle events and external notifications. :::card{label="Organizations" icon="lucide:building-2" href="/en/modules/organization/overview"} B2B tenancy: organizations, groups, membership, and custom attributes. ::: -:::card{label="SAML" icon="lucide:file-badge" href="/en/modules/saml/overview"} +:::card{label="SAML" icon="lucide:file-badge" href="/en/discover/core-concepts/saml"} FerrisKey as a SAML 2.0 identity provider for applications that speak nothing else. ::: :::: diff --git a/apps/docs/src/content/docs/kubernetes/default/en/operator.mdx b/apps/docs/src/content/docs/kubernetes/default/en/operator.mdx index 3c7a65c..b0f5dcd 100644 --- a/apps/docs/src/content/docs/kubernetes/default/en/operator.mdx +++ b/apps/docs/src/content/docs/kubernetes/default/en/operator.mdx @@ -22,7 +22,7 @@ metadata: spec: name: my-ferriskey replicas: 3 - version: "0.7.0" + version: "0.9.0" api: apiUrl: "https://api.iam.yourorg.com" webappUrl: "https://iam.yourorg.com" diff --git a/apps/docs/src/content/docs/kubernetes/default/en/production-guide.mdx b/apps/docs/src/content/docs/kubernetes/default/en/production-guide.mdx index 66a8b75..df91510 100644 --- a/apps/docs/src/content/docs/kubernetes/default/en/production-guide.mdx +++ b/apps/docs/src/content/docs/kubernetes/default/en/production-guide.mdx @@ -101,6 +101,21 @@ spec: Check the label on your own pods with `kubectl get pod -n ferriskey --show-labels` before applying it. +## Health probes + +``` +GET /health/live +GET /health/ready +``` + +Liveness answers whether the process is up; readiness whether it can serve, which includes reaching the database. The chart wires both by default, at `/api/health/live` and `/api/health/ready` because it sets `rootPath: /api`. + +:::callout{variant="info" title="They are not in the OpenAPI document"} +Both probes are real routes but carry no OpenAPI annotation, so they do not appear in the generated spec or in the API documentation UIs. They are part of the deployment contract rather than the API contract — do not go looking for them in `/scalar`. +::: + +Keep readiness pointed at `/health/ready` rather than at a realm route: a realm-scoped URL makes the probe depend on a realm existing, and a readiness probe that fails because someone renamed a realm will take the pods down with it. + ## Monitoring The API serves Prometheus metrics on `/metrics`. With the Prometheus Operator installed, the chart can create the ServiceMonitor for you: diff --git a/apps/docs/src/content/docs/learn/default/en/introduction.mdx b/apps/docs/src/content/docs/learn/default/en/introduction.mdx index 735fd26..8ac631f 100644 --- a/apps/docs/src/content/docs/learn/default/en/introduction.mdx +++ b/apps/docs/src/content/docs/learn/default/en/introduction.mdx @@ -58,7 +58,7 @@ Every page ends with an "In FerrisKey" section pointing to where the concept liv ## In FerrisKey -FerrisKey implements the protocols described in this section. To see them in practice: +FerrisKey implements OAuth2, OIDC, JWT and SAML. AuthZen is the exception: FerrisKey issues the identity and coarse-grained authorization an AuthZen engine consumes, but does not yet speak the protocol itself. To see the rest in practice: ::::card-group{cols=2} :::card{label="What is FerrisKey?" icon="lucide:shield" href="/en/discover/what-is-ferriskey"} diff --git a/apps/docs/src/content/docs/modules/default/en/abyss/overview.mdx b/apps/docs/src/content/docs/modules/default/en/abyss/overview.mdx index 58885ca..0cab4b3 100644 --- a/apps/docs/src/content/docs/modules/default/en/abyss/overview.mdx +++ b/apps/docs/src/content/docs/modules/default/en/abyss/overview.mdx @@ -20,10 +20,10 @@ Federation meets users where they already have an identity. Rather than making t | OAuth2 | Supported | Google, GitHub, Discord, any OAuth2 provider | | OIDC | Supported | Any OpenID Connect compliant provider | | SAML | Planned | Enterprise IdPs such as Okta or Entra ID | -| LDAP | Planned | Active Directory, OpenLDAP | +| LDAP | Supported | Active Directory, OpenLDAP — see [User Federation](/en/modules/abyss/user-federation) | :::callout{variant="info" title="SAML in the other direction already works"} -Signing in *through* an external SAML provider is what is planned here. FerrisKey acting *as* a SAML identity provider, so other applications can trust it, is shipped: see the [SAML module](/en/modules/saml/overview). +Signing in *through* an external SAML provider is what is planned here. FerrisKey acting *as* a SAML identity provider, so other applications can trust it, is shipped: see [SAML 2.0](/en/discover/core-concepts/saml). ::: ## Provider configuration diff --git a/apps/docs/src/content/docs/modules/default/en/abyss/providers.mdx b/apps/docs/src/content/docs/modules/default/en/abyss/providers.mdx index 8c0a792..aa9e71e 100644 --- a/apps/docs/src/content/docs/modules/default/en/abyss/providers.mdx +++ b/apps/docs/src/content/docs/modules/default/en/abyss/providers.mdx @@ -15,7 +15,7 @@ This guide walks through setting up specific identity providers with Abyss. Each :::step{title="Create a Google OAuth2 app"} Go to the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), create an OAuth 2.0 Client ID with: - **Application type:** Web application -- **Authorized redirect URI:** `https://your-ferriskey.com/realms/{realm}/broker/google/callback` +- **Authorized redirect URI:** `https://your-ferriskey.com/realms/{realm}/broker/google/endpoint` ::: :::step{title="Register in FerrisKey"} @@ -39,7 +39,7 @@ In the FerrisKey admin console, navigate to **Identity Providers → Add Provide ::::step-group :::step{title="Create a GitHub OAuth App"} Go to **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App**: -- **Authorization callback URL:** `https://your-ferriskey.com/realms/{realm}/broker/github/callback` +- **Authorization callback URL:** `https://your-ferriskey.com/realms/{realm}/broker/github/endpoint` ::: :::step{title="Register in FerrisKey"} @@ -62,7 +62,7 @@ Go to **GitHub → Settings → Developer settings → OAuth Apps → New OAuth ::::step-group :::step{title="Create a Discord Application"} Go to the [Discord Developer Portal](https://discord.com/developers/applications), create an application, and add an OAuth2 redirect: -- **Redirect URL:** `https://your-ferriskey.com/realms/{realm}/broker/discord/callback` +- **Redirect URL:** `https://your-ferriskey.com/realms/{realm}/broker/discord/endpoint` ::: :::step{title="Register in FerrisKey"} @@ -95,6 +95,36 @@ For any OpenID Connect compliant provider (Okta, Auth0, Azure AD, Keycloak): Most OIDC providers publish their configuration at `https://provider.com/.well-known/openid-configuration`. Use this to find all the endpoint URLs you need. ::: +## API reference + +``` +GET /realms/{realm_name}/identity-providers +POST /realms/{realm_name}/identity-providers +GET /realms/{realm_name}/identity-providers/{alias} +PUT /realms/{realm_name}/identity-providers/{alias} +DELETE /realms/{realm_name}/identity-providers/{alias} +``` + +Providers are addressed by their **alias**, not by a uuid — the alias is also the segment that appears in the broker URLs, which is why changing it breaks the redirect URI registered at the provider. + +The two broker endpoints are the flow itself, and neither is called by you: + +``` +GET /realms/{realm_name}/broker/{alias}/login +GET /realms/{realm_name}/broker/{alias}/endpoint +``` + +### Links on a user + +``` +GET /realms/{realm_name}/users/{user_id}/identity-provider-links +DELETE /realms/{realm_name}/users/{user_id}/identity-provider-links/{link_id} +``` + +A link binds one external identity to one local account. Deleting it leaves the account intact and simply removes that sign-in route; the removal is audited as `identity_provider_link_removed`. + +**Required permissions:** `view_identity_providers` or `manage_identity_providers` to read, `manage_identity_providers` to write. `manage_realm` grants both. + ## Provider Management ### Disabling a Provider diff --git a/apps/docs/src/content/docs/modules/default/en/abyss/user-federation.mdx b/apps/docs/src/content/docs/modules/default/en/abyss/user-federation.mdx new file mode 100644 index 0000000..152703e --- /dev/null +++ b/apps/docs/src/content/docs/modules/default/en/abyss/user-federation.mdx @@ -0,0 +1,130 @@ +--- +title: User Federation +description: "Connect an LDAP or Active Directory tree to a realm: connection, search, and synchronisation." +icon: folder-tree +order: 43 +--- + +# User Federation + +Federation comes in two shapes, and Abyss covers both. + +**Identity brokering** sends the user to another provider to authenticate. FerrisKey never sees the credential; it receives an assertion and mints its own tokens. That is what [Configuring Providers](/en/modules/abyss/providers) is about. + +**User federation** is the other shape: the accounts live in a directory you already run, and FerrisKey reads them. The user types their password into *your* login page, FerrisKey binds against the directory to check it, and the account is imported so roles, groups and organizations can be attached to it locally. + +Reach for user federation when an existing LDAP or Active Directory tree is the source of truth for who exists, and for brokering when another system is the source of truth for who is signing in. + +## Provider types + +| Type | Status | +|---|---| +| LDAP | Supported. Connection tested, accounts searched, imported and synchronised | +| Kerberos | Stored only. The server keeps the provider record but neither synchronises nor tests it | + +Everything below describes an LDAP provider. + +## Creating a provider + +Open **User Federation** in the admin console and add a provider. The form is split into the four things a directory integration has to settle: who the provider is, how to reach it, how to find an account, and when to walk the tree again. + +FerrisKey new LDAP provider form showing basic settings and connection settings +FerrisKey new LDAP provider form showing basic settings and connection settings + +### Basic settings + +| Field | Description | +|---|---| +| Provider name | Names the provider in the console. Must be unique in the realm | +| Priority | Order in which providers are queried when an account is looked up. A secondary provider is only queried when the primary does not answer | +| Enabled | A disabled provider is no longer queried and no longer synchronises. Accounts it already imported stay linked | + +Disabling rather than deleting is the safe way to take a directory out of the loop: deleting the provider is what severs the link to the accounts it imported. + +### Connection settings + +| Field | Description | +|---|---| +| Connection URL | Directory address. Without a scheme, `ldaps://` is assumed when TLS is on and `ldap://` otherwise | +| Base DN | Root under which accounts are searched, for example `dc=example,dc=com` | +| Bind DN | Service account used to read the directory. Left empty, the directory is read anonymously | +| Bind credential | Stored encrypted and never returned once saved | +| Use TLS | Encrypts the connection to the directory | + +:::callout{variant="warning" title="Anonymous bind is rarely what you want"} +Most directories return a reduced view, or nothing at all, to an anonymous bind. If a connection test passes but a sync imports no account, a missing Bind DN is the first thing to check. +::: + +**Test connection** validates the address and the bind without touching any account, so it is safe to run against production before the first sync: + +``` +POST /realms/{realm_name}/federation/providers/{id}/test-connection +``` + +### User search settings + +| Field | Default | Description | +|---|---|---| +| User search filter | `(objectClass=person)` | LDAP filter applied to the lookup | + +Narrow this to the subtree or the object class that actually holds your people. A filter that matches service or machine objects imports them as users. + +## Synchronisation + +FerrisKey LDAP provider synchronisation settings showing sync mode and interval +FerrisKey LDAP provider synchronisation settings showing sync mode and interval + +| Field | Description | +|---|---| +| Scheduled synchronisation | Turned off, the provider only synchronises when asked from this page or through the API | +| Sync mode | What a scheduled run does with the accounts it meets | +| Sync interval | Time between two runs. The server keeps whole minutes, 60 seconds at the least | + +### Sync modes + +| Mode | Behaviour | +|---|---| +| Import | Creates the accounts that are missing and updates the ones that exist | +| Force | Also **disables** the local accounts the directory no longer returns | +| Link only | Links accounts already present locally, never creates one | + +:::callout{variant="warning" title="Force acts on absence, and a bad filter looks like absence"} +Force disables every local account the directory did not return in that run. A search filter that is briefly too narrow, or a subtree that fails to answer, is indistinguishable from accounts that were deleted upstream. Run Import until you trust the filter, then switch. +::: + +A run started by hand always imports, whatever the configured mode. Triggering one: + +``` +POST /realms/{realm_name}/federation/providers/{id}/sync-users +``` + +`Link only` is the mode for a migration: import the accounts once by another route, then let the provider attach each one to its directory entry without creating anything. + +## API reference + +| Method | Path | Description | +|---|---|---| +| `GET` | `/realms/{realm_name}/federation/providers` | List the realm's providers | +| `POST` | `/realms/{realm_name}/federation/providers` | Create a provider | +| `GET` | `/realms/{realm_name}/federation/providers/{id}` | Read one provider | +| `PUT` | `/realms/{realm_name}/federation/providers/{id}` | Update a provider | +| `DELETE` | `/realms/{realm_name}/federation/providers/{id}` | Delete a provider | +| `POST` | `/realms/{realm_name}/federation/providers/{id}/test-connection` | Validate address and bind | +| `POST` | `/realms/{realm_name}/federation/providers/{id}/sync-users` | Run a synchronisation now | + +**Required permissions:** `view_realm` to read, `manage_realm` to create, update, delete, test or synchronise. + +The bind credential is never returned by a read, so a `PUT` that omits it leaves the stored one in place. + +## What gets recorded + +A synchronisation is not silent. Account creations raise `user_created` in [SeaWatch](/en/modules/seawatch/overview), and the same events feed [Webhooks](/en/modules/webhooks/overview) — which is the practical way to notice that a run imported four thousand accounts it should not have. + +::::card-group{cols=2} +:::card{label="Configuring Providers" icon="lucide:link" href="/en/modules/abyss/providers"} +The other shape of federation: Google, GitHub and any OAuth2 or OIDC provider. +::: +:::card{label="Password Policy" icon="lucide:key-round" href="/en/discover/core-concepts/password-policy"} +Local policy still applies to the passwords FerrisKey stores itself. +::: +:::: diff --git a/apps/docs/src/content/docs/modules/default/en/account/_meta.json b/apps/docs/src/content/docs/modules/default/en/account/_meta.json new file mode 100644 index 0000000..bbb0cc6 --- /dev/null +++ b/apps/docs/src/content/docs/modules/default/en/account/_meta.json @@ -0,0 +1,6 @@ +{ + "icon": "user-cog", + "title": "Account", + "type": "group", + "order": 10 +} diff --git a/apps/docs/src/content/docs/modules/default/en/account/overview.mdx b/apps/docs/src/content/docs/modules/default/en/account/overview.mdx new file mode 100644 index 0000000..bac41d9 --- /dev/null +++ b/apps/docs/src/content/docs/modules/default/en/account/overview.mdx @@ -0,0 +1,89 @@ +--- +title: Overview +description: "The self-service account surface: what a user can change about themselves, without an administrator." +icon: user-cog +order: 70 +--- + +# Account + +Everything else in FerrisKey is an administrator acting on someone. The account module is the opposite: it is the surface a user operates on **themselves**, and the only one where the caller and the target are the same person by construction. + +That distinction is the whole design. These endpoints live under `/users/me`, they resolve the subject from the caller's own token, and they take no user id — so there is no id to tamper with and no permission to hold. A user needs no `manage_users` to change their own password, and holding `manage_users` grants nothing extra here. + +## What a user can do + +| Area | Operations | +|---|---| +| Profile | Read and update their own name and email, set their locale | +| Password | Change it, against the realm's [password policy](/en/discover/core-concepts/password-policy) | +| Authenticator app | Enrol a TOTP authenticator, confirm it, remove it | +| Passkeys | Register a passkey, list credentials, delete one | +| Sessions | Review their active sessions | + +FerrisKey account security tab showing password change with live policy feedback, authenticator app and passkeys sections +FerrisKey account security tab showing password change with live policy feedback, authenticator app and passkeys sections + +## Profile + +``` +GET /realms/{realm_name}/users/me +PUT /realms/{realm_name}/users/me +``` + +What a user may change about their own profile is not fixed: the realm decides. `edit_username_enabled` governs whether the username field is writable, and the console shows it as locked when the realm says no. + +Email is the field to think about, because it is also the recovery address. When `email_verification_enabled` is on, changing it puts the account back through verification rather than trusting the new address immediately. + +### Locale + +``` +PUT /realms/{realm_name}/users/@me/locale +``` + +Sets the locale used for this user's login portal and emails, from the realm's `supported_locales`. With no preference stored, the realm's `default_locale` applies. + +:::callout{variant="warning" title="This one path says @me, not me"} +Every other account endpoint is spelled `/users/me`. The locale endpoint is `/users/@me/locale`, as is `GET /users/@me/realms`. It is an inconsistency in the current API, not a typo in this page — code against it as written. +::: + +## Sensitive operations need an elevation + +Reading your profile needs nothing beyond a valid token. Changing a credential needs proof that the person at the keyboard is still the account holder, and not someone who found an unlocked laptop. + +``` +POST /realms/{realm_name}/users/me/reauthenticate +``` + +Supply **exactly one** of `password` or `otp_code`. The response carries an `elevation_id` and an `expires_at`; the elevation lives **five minutes** and is bound to the calling session. Every sensitive operation then takes that `elevation_id`. + +```json title="Response" +{ + "elevation_id": "01a0d3dd-a750-7f0f-91d9-cf05f19168c6", + "expires_at": "2026-09-24T18:42:11Z" +} +``` + +:::callout{variant="info" title="A second factor cannot rotate the factors"} +Adding, replacing or removing a sign-in credential requires the elevation to have been proved **with a password**, not with an OTP code. Otherwise a stolen second factor could enrol itself as the only factor and lock the owner out — a factor must not be able to rotate the set it belongs to, including itself. + +Changing the password is the exception: it carries the current password in the request body, which is the same proof by another route. +::: + +[Security](/en/modules/account/security) covers each operation and the order to call them in. + +## What this surface deliberately does not do + +- **No credential reset for someone else.** That stays an administrative operation, under `manage_users`. +- **No role or organization changes.** A user cannot grant themselves anything. +- **No account deletion.** Self-service deletion is not exposed. +- **No recovery-code generation.** Recovery codes are issued during the [MFA challenge](/en/modules/trident/recovery-codes), not from the account page. + +::::card-group{cols=2} +:::card{label="Account Security" icon="lucide:shield-check" href="/en/modules/account/security"} +Password, authenticator and passkey operations, endpoint by endpoint. +::: +:::card{label="Trident" icon="lucide:shield" href="/en/modules/trident/overview"} +The other side of MFA: what happens during the login challenge. +::: +:::: diff --git a/apps/docs/src/content/docs/modules/default/en/account/security.mdx b/apps/docs/src/content/docs/modules/default/en/account/security.mdx new file mode 100644 index 0000000..70d215f --- /dev/null +++ b/apps/docs/src/content/docs/modules/default/en/account/security.mdx @@ -0,0 +1,138 @@ +--- +title: Security +description: "Self-service password, authenticator and passkey operations, and the elevation each one needs." +icon: shield-check +order: 71 +--- + +# Account Security + +The credential operations a user performs on their own account. Each one needs an [elevation](/en/modules/account/overview#sensitive-operations-need-an-elevation) — a five-minute proof, bound to the calling session, obtained from `POST /users/me/reauthenticate`. + +Throughout this page, `{realm}` stands for `/realms/{realm_name}`. + +## Changing the password + +``` +PUT {realm}/users/me/password +``` + +The body carries the current password and the new one. This is the one sensitive operation that does not need a password-proved elevation first, because verifying the current password in the body *is* that proof. + +The new password is checked against the realm's [password policy](/en/discover/core-concepts/password-policy). The console evaluates the same rules as you type, against `GET {realm}/password-policy/public`, so the failure is visible before submitting rather than after. + +:::callout{variant="warning" title="Other sessions are signed out"} +A successful change revokes the account's other sessions and keeps the current one. That is the intended behaviour for a password change — if the reason for changing it is that someone else has it, leaving their session alive would defeat the exercise. Warn the user in your own UI if you build one; a silent sign-out of their phone reads as a bug. +::: + +## Listing credentials + +``` +GET {realm}/users/me/credentials +``` + +Returns the sign-in credentials attached to the account — password, authenticator, each passkey — so a UI can show what exists before offering to add or remove anything. Secrets are never returned: a passkey comes back as its metadata and its id, which is what `DELETE` takes. + +## Authenticator app (TOTP) + +Enrolment is two calls, because the server must not trust that the user successfully stored the secret until they prove they can read a code from it. + +::::step-group +:::step{title="Start the enrolment"} +``` +POST {realm}/users/me/mfa/otp +``` + +Returns the secret and the `otpauth://` URI to render as a QR code. The parameters are fixed: SHA-1, six digits, a thirty-second period — see [TOTP](/en/modules/trident/totp). + +Needs a password-proved elevation. +::: + +:::step{title="Confirm with a code"} +``` +PUT {realm}/users/me/mfa/otp +``` + +The user types the current code from their app. Until this call succeeds, the authenticator is not active and the account's sign-in is unchanged — an abandoned enrolment leaves nothing behind. +::: + +:::step{title="Remove it, later"} +``` +DELETE {realm}/users/me/mfa/otp +``` + +Also needs a password-proved elevation. An OTP code is not accepted as the proof for removing OTP. +::: +:::: + +## Passkeys + +Passkey registration follows WebAuthn, so it is also two calls: the browser needs a server challenge, and the server needs the browser's attestation back. + +::::step-group +:::step{title="Ask for registration options"} +``` +POST {realm}/users/me/passkeys/options +``` + +Returns the `PublicKeyCredentialCreationOptions` to hand to `navigator.credentials.create()`. The challenge is stored server-side against the session, so it cannot be replayed. + +Needs a password-proved elevation. +::: + +:::step{title="Send the credential back"} +``` +POST {realm}/users/me/passkeys +``` + +Post the browser's response. The server checks it against the stored challenge and the relying-party id, then attaches the passkey to the account. +::: + +:::step{title="Delete one"} +``` +DELETE {realm}/users/me/passkeys/{credential_id} +``` + +`credential_id` comes from the credentials listing. Needs a password-proved elevation. +::: +:::: + +:::callout{variant="info" title="Passkeys have to be enabled on the realm"} +`passkey_enabled` governs whether passkeys are offered at sign-in. Registering one on a realm where the setting is off stores a credential the login flow will not use. +::: + +## Endpoint summary + +| Method | Path | Elevation required | +|---|---|---| +| `GET` | `{realm}/users/me` | None | +| `PUT` | `{realm}/users/me` | None | +| `PUT` | `{realm}/users/@me/locale` | None | +| `POST` | `{realm}/users/me/reauthenticate` | — it *is* the elevation | +| `PUT` | `{realm}/users/me/password` | Current password, in the body | +| `GET` | `{realm}/users/me/credentials` | None | +| `POST` | `{realm}/users/me/mfa/otp` | Password-proved | +| `PUT` | `{realm}/users/me/mfa/otp` | Password-proved | +| `DELETE` | `{realm}/users/me/mfa/otp` | Password-proved | +| `POST` | `{realm}/users/me/passkeys/options` | Password-proved | +| `POST` | `{realm}/users/me/passkeys` | Password-proved | +| `DELETE` | `{realm}/users/me/passkeys/{credential_id}` | Password-proved | + +No permission governs any of these. The token identifies the subject, and the subject is the target. + +## What gets recorded + +:::callout{variant="warning" title="These operations are not in the audit log yet"} +The self-service credential operations do not currently write a [SeaWatch](/en/modules/seawatch/overview) security event. A user rotating their own password, enrolling an authenticator or deleting a passkey leaves no entry in the realm's audit trail, and no [webhook](/en/modules/webhooks/overview) fires. + +The administrative equivalents do: an admin resetting a password raises `password_reset`, and deleting a user's credential fires the `user.credentials.deleted` webhook trigger. If you need self-service credential changes on the audit trail today, capture them at your own edge. +::: + +::::card-group{cols=2} +:::card{label="Password Policy" icon="lucide:key-round" href="/en/discover/core-concepts/password-policy"} +The rules a new password is checked against, and the public endpoint that exposes them. +::: +:::card{label="WebAuthn" icon="lucide:fingerprint" href="/en/modules/trident/webauthn"} +Passkeys at sign-in, rather than at enrolment. +::: +:::: diff --git a/apps/docs/src/content/docs/modules/default/en/aegis/custom-claims.mdx b/apps/docs/src/content/docs/modules/default/en/aegis/custom-claims.mdx index aee4ced..19d589a 100644 --- a/apps/docs/src/content/docs/modules/default/en/aegis/custom-claims.mdx +++ b/apps/docs/src/content/docs/modules/default/en/aegis/custom-claims.mdx @@ -1,137 +1,183 @@ --- title: Custom Claims -description: "Add business-specific claims to your tokens: real-world examples and patterns." +description: "Real patterns for putting business data in a FerrisKey token, with working mapper configs." icon: plus order: 52 --- # Custom Claims -FerrisKey's default scopes cover standard OIDC claims. But real applications need more, user roles, tenant identifiers, subscription tiers, feature flags. Custom claims let you put exactly the data your application needs into the token, so your backend can make decisions without additional API calls. +FerrisKey's default scopes cover the standard OIDC claims. Real applications need more: roles a gateway can enforce, a tenant identifier, a plan tier, feature flags. Custom claims put exactly that data in the token so your backend decides without a second call. -## Common Patterns +Every config below uses the dotted Keycloak-style keys the mappers actually read. See [Protocol Mappers](/en/modules/aegis/protocol-mappers) for the full key reference. -### Realm roles in the access token +## Realm roles for an API gateway -Your API gateway needs to enforce role-based access control. Add a `user_realm_role_mapper` to include roles directly in the access token. +Your gateway enforces role-based access on routes. The realm role mapper already writes roles into the token; the only decision is where. + +**Scope:** `roles` (default, seeded in every realm) +**Mapper:** `oidc-usermodel-realm-role-mapper` -**Scope:** `roles` (Default) -**Mapper:** `user_realm_role_mapper` ```json { - "claim_name": "roles", - "add_to_id_token": false, - "add_to_access_token": true + "claim.name": "realm_access.roles", + "access.token.claim": "true", + "id.token.claim": "false" } ``` -**Resulting token claim:** -```json +```json title="Resulting claim" { - "roles": ["admin", "editor", "viewer"] + "realm_access": { + "roles": ["admin", "editor", "viewer"] + } } ``` -Your API gateway reads `roles` from the token and allows/denies routes without calling FerrisKey. +The gateway reads `realm_access.roles` and allows or denies the route without calling back to FerrisKey. Setting `id.token.claim` to `false` keeps roles out of the ID token, which the browser can read — the gateway is the only party that needs them. + +:::callout{variant="info" title="Flat claim instead of nested"} +`claim.name` supports dotted paths, so `realm_access.roles` nests. If your gateway expects a flat array under `roles`, set `claim.name` to `roles` instead. The default is the nested form because that is what Keycloak-compatible libraries look for. +::: + +## Tenant identifier for a multi-tenant backend -### Tenant identifier for multi-tenant SaaS +Each realm is a tenant, and the backend needs to know which tenant database to query. -Each realm represents a tenant. Inject the realm name as a claim so your backend knows which tenant database to query. +**Scope:** a `tenant` scope, assigned as default +**Mapper:** `oidc-hardcoded-claim-mapper` -**Scope:** `tenant` (Default) -**Mapper:** `hardcoded_claim_mapper` ```json { - "claim_name": "tenant", - "claim_value": "acme-corp", - "claim_type": "String", - "add_to_id_token": false, - "add_to_access_token": true + "claim.name": "tenant", + "claim.value": "acme-corp", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "false" } ``` -:::callout{variant="info" title="One scope per realm"} -Since the tenant value is realm-specific, you'll create this scope within each realm with the appropriate hardcoded value. Alternatively, use a `user_attribute` mapper if the tenant is stored as a user attribute. +Since the value is realm-specific, you create this scope in each realm with the right value. + +:::callout{variant="info" title="When the tenant is per-user, not per-realm"} +Store it as a user attribute and use `oidc-usermodel-attribute-mapper` with `"user.attribute": "tenant"` instead. And if tenancy is modelled with the [Organizations module](/en/modules/organization/overview), skip both: `oidc-organization-membership-mapper` writes the user's organizations, by alias, with no per-realm setup. ::: -### Permissions Bitmask +## A numeric claim from a user attribute + +User attributes are stored as strings. `jsonType.label` casts on the way into the token, so the consumer gets a number rather than a string holding a number. -FerrisKey uses bitwise permissions internally. Expose the resolved permission bitmask as a claim for fine-grained authorization: +**Mapper:** `oidc-usermodel-attribute-mapper` -**Scope:** `permissions` (Default) -**Mapper:** `user_attribute` ```json { - "attribute": "permissions_bitmask", - "claim_name": "permissions", - "claim_type": "Integer", - "add_to_id_token": false, - "add_to_access_token": true + "user.attribute": "seat_limit", + "claim.name": "seat_limit", + "jsonType.label": "Integer", + "access.token.claim": "true", + "id.token.claim": "false" } ``` -Your backend performs bitwise AND checks against the `permissions` integer, fast and efficient. +`Boolean` works the same way, turning the string `"true"` into a real JSON boolean. -### Audience for several resource servers +## Audience for several resource servers -Your tokens need to be accepted by both `api.yourapp.com` and `admin.yourapp.com`: +Your tokens have to be accepted at both `api.yourapp.com` and `admin.yourapp.com`. Add one mapper per audience. -**Scope:** `multi-audience` (Default) -**Mapper 1:** `audience_mapper` → `api.yourapp.com` -**Mapper 2:** `audience_mapper` → `admin.yourapp.com` +**Mapper:** `oidc-audience-mapper`, twice -**Resulting token claim:** -```json +```json title="Mapper 1" +{ + "included.client.audience": "api.yourapp.com", + "access.token.claim": "true", + "id.token.claim": "false" +} +``` + +```json title="Mapper 2" +{ + "included.client.audience": "admin.yourapp.com", + "access.token.claim": "true", + "id.token.claim": "false" +} +``` + +```json title="Resulting claim" { "aud": ["my-client", "api.yourapp.com", "admin.yourapp.com"] } ``` -### Feature Flags +:::callout{variant="warning" title="aud needs this mapper specifically"} +`aud` is a reserved claim name: it is stripped after mappers run, so a hardcoded claim mapper pointed at `aud` silently does nothing. The audience mapper contributes audiences through a separate channel, which is why it works. +::: + +## Feature flags in the ID token + +The frontend decides which features to render, so the flags belong in the ID token it can read — not in the access token the API validates. -Inject feature flags into tokens so your frontend knows which features to enable: +**Scope:** a `features` scope, assigned as **optional** so only the clients that ask for it pay the token size +**Mapper:** `oidc-hardcoded-claim-mapper` -**Scope:** `features` (Optional, client requests it when needed) -**Mapper:** `hardcoded_claim_mapper` ```json { - "claim_name": "features", - "claim_value": "{\"new_dashboard\": true, \"beta_export\": false}", - "claim_type": "JSON", - "add_to_id_token": true, - "add_to_access_token": false + "claim.name": "features.new_dashboard", + "claim.value": "true", + "jsonType.label": "Boolean", + "access.token.claim": "false", + "id.token.claim": "true" } ``` -## Scope assignment strategy +```json title="Resulting claim" +{ + "features": { + "new_dashboard": true + } +} +``` -### Default vs Optional +The dotted `claim.name` nests each flag under one `features` object, so a second mapper writing `features.beta_export` merges into the same object instead of overwriting it. -| Assign as Default when... | Assign as Optional when... | -|---|---| -| Every request from this client needs the claim | The claim contains sensitive data | -| The claim is needed for authorization decisions | The claim is large (increases token size) | -| Missing the claim would break the client | Only specific flows need the data | +## Group membership for a hierarchy-aware app -### Per-client scope overrides +**Mapper:** `oidc-group-membership-mapper` -The same scope can have different types per client: +```json +{ + "claim.name": "groups", + "membership": "direct", + "full.path": "true", + "access.token.claim": "true", + "id.token.claim": "false" +} +``` + +`direct` emits only the groups the user is a direct member of. The default, `effective`, also emits every ancestor path — pick that one when the consumer checks for a parent group without reconstructing the hierarchy itself, and `direct` when you want smaller tokens and less org structure in them. + +## Scope assignment strategy -- `roles` scope is **Default** for your admin dashboard (always needs roles) -- `roles` scope is **Optional** for your public API (only needs roles for specific endpoints) -- `roles` scope is **None** for a third-party integration (never gets roles) +| Assign as Default when… | Assign as Optional when… | +|---|---| +| Every request from this client needs the claim | The claim carries sensitive data | +| The claim is needed for an authorization decision | The claim is large and would bloat every token | +| Missing the claim would break the client | Only some flows need the data | -This lets you define scopes once and control exposure per client. +## Before you debug the claim, check the key -## Debugging Claims +A mapper whose config key is misspelled is accepted by the API and then ignored at token time — the `config` field is free-form JSON and nothing validates it. When a claim does not show up: -To verify which claims end up in your tokens: +1. Check the keys are the dotted ones (`claim.name`, not `claim_name`). +2. Check the claim name is not [reserved](/en/modules/aegis/protocol-mappers#reserved-claim-names). +3. Check the scope is actually active on the client, default or requested by name. +4. Use `POST /realms/{realm_name}/clients/{client_id}/evaluate-scopes` to see the claims a client would get, without a full login. -1. Authenticate through the client -2. Decode the access token (it's a JWT, use `jwt.io` or `base64 -d`) -3. Check which claims are present -4. If a claim is missing, verify: - - The scope is assigned to the client (as Default or Optional) - - If Optional, the client requested it in the `scope` parameter - - The mapper is configured with `add_to_access_token: true` (or `add_to_id_token` for ID tokens) - - The mapper's source data exists on the user (e.g., the attribute is set) +::::card-group{cols=2} +:::card{label="Protocol Mappers" icon="lucide:arrow-right-left" href="/en/modules/aegis/protocol-mappers"} +All ten mapper types and every config key they read. +::: +:::card{label="JWT Claims" icon="lucide:file-json" href="/en/learn/jwt/claims"} +What the standard claims mean before you add your own. +::: +:::: diff --git a/apps/docs/src/content/docs/modules/default/en/aegis/overview.mdx b/apps/docs/src/content/docs/modules/default/en/aegis/overview.mdx index 6c1a2c6..8a29ac9 100644 --- a/apps/docs/src/content/docs/modules/default/en/aegis/overview.mdx +++ b/apps/docs/src/content/docs/modules/default/en/aegis/overview.mdx @@ -50,18 +50,19 @@ FerrisKey ships with the standard OpenID Connect scopes pre-configured: | `openid` | `sub` | Default | | `profile` | `preferred_username`, `given_name`, `family_name` | Default | | `email` | `email`, `email_verified` | Default | +| `roles` | `realm_access.roles` | Default | | `address` | `address` | Optional | -| `phone` | `phone_number`, `phone_number_verified` | Optional | +| `phone` | `phone_number` | Optional | | `offline_access` | Enables refresh token issuance | Optional | | `introspect` | Allows token introspection | Optional | ## Real-World Patterns ### API gateway with role-based access -Create a custom scope `api_access` with a `user_realm_role_mapper` that includes `realm_roles` in the token. Your API gateway reads the roles claim and enforces route-level authorization without calling back to FerrisKey. +Create a custom scope `api_access` with an `oidc-usermodel-realm-role-mapper` that writes `realm_access.roles` into the token. Your API gateway reads the roles claim and enforces route-level authorization without calling back to FerrisKey. ### Multi-Tenant SaaS -Create a custom scope with a `hardcoded_claim_mapper` that injects the realm name as a `tenant` claim. Your backend uses this to route requests to the correct tenant database. +Create a custom scope with an `oidc-hardcoded-claim-mapper` that injects the realm name as a `tenant` claim. Your backend uses this to route requests to the correct tenant database. ### Third-Party Integrations Assign only `openid` and `email` as default scopes for third-party clients. They get the minimum data needed. Your first-party clients get `profile`, `roles`, and custom scopes as defaults. diff --git a/apps/docs/src/content/docs/modules/default/en/aegis/protocol-mappers.mdx b/apps/docs/src/content/docs/modules/default/en/aegis/protocol-mappers.mdx index 4a914d5..0eb7069 100644 --- a/apps/docs/src/content/docs/modules/default/en/aegis/protocol-mappers.mdx +++ b/apps/docs/src/content/docs/modules/default/en/aegis/protocol-mappers.mdx @@ -1,149 +1,255 @@ --- title: Protocol Mappers -description: "Protocol mapper types in FerrisKey: how user data becomes JWT claims." +description: "The ten protocol mapper types in FerrisKey, and the config keys each one reads." icon: arrow-right-left order: 51 --- # Protocol Mappers -Protocol mappers are the rules that transform user and client data into JWT claims. Each mapper belongs to a client scope and runs during token generation. Aegis provides six built-in mapper types that cover the most common claim patterns. +Protocol mappers are the rules that turn user, group, organization, and client data into JWT claims. Each mapper belongs to a client scope and runs during token generation. -## Mapper Types +A mapper is identified by a `mapper_type` string and carries a JSON `config`. FerrisKey keeps the Keycloak names for both, so configuration written for Keycloak is recognizable here: keys are dotted, and their values are strings, including the booleans. -### `user_property` +:::callout{variant="warning" title="Keys are dotted, not snake_case"} +The config keys are `claim.name`, `access.token.claim`, `user.attribute` — not `claim_name`, `add_to_access_token`, `attribute`. The `config` field is free-form JSON, so the API accepts an unknown key without complaining and the mapper then ignores it. A mapper that silently does nothing, or writes to a claim you did not name, is almost always a key spelled the wrong way. +::: + +## Keys every mapper reads + +| Config key | Default | Description | +|---|---|---| +| `claim.name` | per mapper | Target claim. Dotted paths nest, so `realm_access.roles` produces `{"realm_access": {"roles": […]}}` rather than a flat key with a dot in it | +| `access.token.claim` | `true` | Include the claim in the access token. Accepts `true`/`false` as a boolean or as a string | +| `id.token.claim` | `true` | Include the claim in the ID token | + +Both token flags default to **true** when the key is absent, so a mapper with no flags writes to both tokens. + +`token.claim.name` is accepted as a legacy alias for `claim.name`, because some earlier consoles stored mappers that way. Prefer `claim.name` in anything new. + +## Mapper types + +### `oidc-usermodel-property-mapper` -Maps a built-in user field directly to a token claim. +Maps a built-in user field to a claim. -| Config Field | Description | +| Config key | Description | |---|---| -| `property` | User field to read (`email`, `username`, `firstname`, `lastname`) | -| `claim_name` | Target claim name in the JWT | -| `claim_type` | Value type (`String`, `JSON`, `Integer`, `Boolean`) | -| `add_to_id_token` | Include in ID token | -| `add_to_access_token` | Include in access token | - -**Example:** Map the user's email to the `email` claim: -```json title="user_property mapper config" +| `user.attribute` | Field to read: `username`, `email`, `firstName`, `lastName`, or `emailVerified`. The snake_case spellings `firstname`, `lastname` and `email_verified` are also accepted | +| `claim.name` | Target claim. Defaults to the value of `user.attribute` | +| `jsonType.label` | Cast the value: `String` (default), `Boolean`, or `Integer`. `Long` and `int` are accepted too | + +```json title="Username into preferred_username" { - "property": "email", - "claim_name": "email", - "claim_type": "String", - "add_to_id_token": true, - "add_to_access_token": true + "user.attribute": "username", + "claim.name": "preferred_username", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true" } ``` -### `user_attribute` +### `oidc-usermodel-attribute-mapper` -Maps a custom user attribute (key-value metadata) to a token claim. Use this for business-specific data like department, plan tier, or feature flags. +Maps a custom user attribute to a claim. Use it for business data: department, plan tier, feature flags. -| Config Field | Description | +| Config key | Description | |---|---| -| `attribute` | Custom attribute key on the user | -| `claim_name` | Target claim name | -| `claim_type` | Value type | -| `add_to_id_token` | Include in ID token | -| `add_to_access_token` | Include in access token | +| `user.attribute` | Attribute key on the user | +| `claim.name` | Target claim | +| `jsonType.label` | Cast the value, as above | -### `user_realm_role_mapper` +```json title="A department attribute into a department claim" +{ + "user.attribute": "department", + "claim.name": "department", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true" +} +``` -Includes the user's realm-level role names in the token. +### `oidc-usermodel-realm-role-mapper` -| Config Field | Description | +Writes the user's realm role names into the token. + +| Config key | Description | |---|---| -| `claim_name` | Target claim name (e.g., `realm_roles`) | -| `add_to_id_token` | Include in ID token | -| `add_to_access_token` | Include in access token | +| `claim.name` | Target claim. Defaults to `realm_access.roles` | + +```json title="Realm roles, default placement" +{ + "claim.name": "realm_access.roles", + "access.token.claim": "true", + "id.token.claim": "true" +} +``` -**Output example:** -```json +```json title="Resulting claim" { - "realm_roles": ["admin", "user-manager", "viewer"] + "realm_access": { + "roles": ["admin", "user-manager", "viewer"] + } } ``` -### `user_client_role_mapper` +### `oidc-usermodel-client-role-mapper` -Includes the user's client-specific role names. Useful when different clients have their own role hierarchies. +Writes client-scoped roles under `resource_access..roles`. The placement is fixed — this mapper does not take a `claim.name`. -| Config Field | Description | +| Config key | Description | |---|---| -| `client_id` | Which client's roles to include | -| `claim_name` | Target claim name (e.g., `resource_access`) | -| `add_to_id_token` | Include in ID token | -| `add_to_access_token` | Include in access token | +| `client.id` | Which client's roles to include. Left empty or absent, roles from **every** client are included | + +```json title="Resulting claim" +{ + "resource_access": { + "backend": { "roles": ["read", "write"] } + } +} +``` + +### `oidc-group-membership-mapper` + +Writes the groups the user belongs to. + +| Config key | Default | Description | +|---|---|---| +| `claim.name` | `groups` | Target claim | +| `membership` | `effective` | `effective` includes every group **plus all its ancestors**, so a member of `/novotel/EN/leaf` gets all three paths. `direct` includes only groups the user is a direct member of, which matches Keycloak, keeps tokens smaller, and discloses less of the org structure | +| `full.path` | `true` | Emit full paths (`/parent/child`). Set to `false` for bare group names | +| `prefix.org` | `false` | Prepend the group's organization alias (`/acme/finance`) | + +`prefix.org` exists because a FerrisKey token can carry groups from several organizations at once, and paths are relative to their org — so `/finance` from two organizations would collide. It only applies to full paths. + +### `oidc-organization-membership-mapper` + +Writes the organizations the user belongs to, as a JSON object keyed by alias. Keying by alias means a consumer can look up one organization without scanning an array. + +| Config key | Default | Description | +|---|---|---| +| `claim.name` | `organizations` | Target claim | +| `include.domain` | `false` | Add each organization's domain | +| `include.attributes` | `false` | Add each organization's attributes | + +`id`, `name` and `alias` are always present. Domain and attributes are opt-in so tokens stay compact by default. -### `audience_mapper` +### `oidc-organization-detail-mapper` -Adds audience (`aud`) values to the token. Use this when your token needs to be accepted by multiple resource servers. +Writes detail about a **single** organization. -| Config Field | Description | +| Config key | Default | Description | +|---|---|---| +| `claim.name` | `organization` | Target claim | +| `organization.alias` | unset | Only emit a claim if the user belongs to this organization. Omitted, the first organization in the user's membership list is used | +| `include.attributes` | `false` | Add the organization's attributes | + +If the user belongs to no matching organization, the mapper emits nothing. + +### `oidc-organization-role-mapper` + +Writes the roles a user holds **within the scope of each organization**, keyed by organization alias. Realm roles land under `roles`, client roles under `clients..roles`. + +| Config key | Default | Description | +|---|---|---| +| `claim.name` | `organizations` | Target claim | + +Org-scoped roles come from two sources, merged during token assembly: roles assigned directly to the membership, and roles inherited from the user's groups in that organization. + +:::callout{variant="info" title="This mapper shares a claim with the membership mapper"} +Both default to the `organizations` claim, and their objects deep-merge when both are enabled: the membership mapper contributes `id`, `name` and `alias`, this one contributes `roles` and `clients`. Match on the membership mapper's `id` field rather than the alias, which can be renamed. +::: + +### `oidc-audience-mapper` + +Adds an entry to the audience (`aud`) claim. Use it when a token has to be accepted by more than one resource server. + +| Config key | Description | |---|---| -| `audience` | Audience value to add | -| `add_to_id_token` | Include in ID token | -| `add_to_access_token` | Include in access token | +| `included.client.audience` | Audience value to add. Empty or absent, the mapper emits nothing | + +```json title="Accept this token at another client" +{ + "included.client.audience": "another-client", + "access.token.claim": "true", + "id.token.claim": "false" +} +``` -### `hardcoded_claim_mapper` +### `oidc-hardcoded-claim-mapper` -Injects a static value as a claim. Useful for environment indicators, feature flags, or tenant identifiers that don't come from user data. +Injects a static value. Useful for environment indicators or tenant identifiers that do not come from user data. -| Config Field | Description | +| Config key | Description | |---|---| -| `claim_name` | Target claim name | -| `claim_value` | Static value to inject | -| `claim_type` | Value type | -| `add_to_id_token` | Include in ID token | -| `add_to_access_token` | Include in access token | - -**Example:** Add an environment indicator: -```json title="hardcoded_claim_mapper config" +| `claim.name` | Target claim | +| `claim.value` | Static value to inject | +| `jsonType.label` | Cast the value: `String` (default), `Boolean`, `Integer` | + +```json title="An environment indicator, access token only" { - "claim_name": "environment", - "claim_value": "production", - "claim_type": "String", - "add_to_id_token": false, - "add_to_access_token": true + "claim.name": "environment", + "claim.value": "production", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "false" } ``` -## Execution Order +## Reserved claim names + +Ten claim names are removed after every mapper has run, so a mapper cannot overwrite them: + +``` +sub iat jti iss typ azp aud scope exp client_id +``` + +Target one of these and the mapper appears to be configured correctly yet changes nothing. `aud` is on the list, which is exactly why adding an audience needs `oidc-audience-mapper` — it contributes audiences through a separate channel — and not a hardcoded mapper pointed at `aud`. + +## Execution order -Protocol mappers execute in the order they appear within their scope. When multiple scopes are active, mappers from each scope run in scope order. +Mappers execute in the order they appear within their scope. When several scopes are active, the mappers of each scope run in scope order. :::callout{variant="warning" title="Claim collisions"} -If two mappers target the same claim name, the later one overwrites the earlier one. Be intentional about claim names across scopes to avoid unintended overrides. +If two mappers target the same claim name, the later one overwrites the earlier one. The organization mappers are the deliberate exception: they deep-merge into a shared claim. Be intentional about claim names across scopes. ::: ## Token generation pipeline -When a token is generated, Aegis follows this pipeline: +1. **Resolve scopes** — the client's default scopes, plus any optional scopes named in the request's `scope` parameter. +2. **Gather mappers** — every protocol mapper on the active scopes. +3. **Execute mappers** — each one runs against the authenticated user and the client context. +4. **Filter by token type** — `access.token.claim` and `id.token.claim` decide which token each claim lands in. +5. **Strip reserved claims** — the ten names above are removed. +6. **Build and sign** — mapper output is merged with the standard claims and the JWT is signed with the realm's signing key. -1. **Resolve scopes**: Collect all default scopes for the client, plus any optional scopes requested in the `scope` parameter -2. **Gather mappers**: Collect all protocol mappers from the active scopes -3. **Execute mappers**: Run each mapper against the authenticated user and client context -4. **Build payload**: Merge mapper outputs with standard claims (`sub`, `iss`, `exp`, etc.) -5. **Filter by token type**: Include only claims marked for the target token type (access token vs ID token) -6. **Sign**: Sign the JWT with the realm's signing key - -## Creating custom mappers - -To add a custom claim to your tokens: +## Adding a mapper ::::step-group :::step{title="Create or select a scope"} -Navigate to **Client Scopes** in the admin console. Create a new scope or select an existing one. +Open **Client Scopes** in the admin console. Create a scope, or select the one that should carry the claim. ::: :::step{title="Add a protocol mapper"} -On the scope's **Mappers** tab, click **Add Mapper**. Select the mapper type and configure it. +On the scope's mappers tab, add a mapper. Pick its `mapper_type` and fill the config keys documented above. ::: :::step{title="Assign the scope to a client"} -Navigate to the client's **Client Scopes** tab. Add the scope as Default or Optional. +On the client's **Client Scopes** tab, add the scope as Default or Optional. Default scopes apply to every request; an optional scope only applies when the request asks for it by name. ::: -:::step{title="Test"} -Authenticate through the client and inspect the resulting token. The custom claim should appear in the payload. +:::step{title="Verify against a real token"} +Authenticate through the client and decode the token. If the claim is missing, check the config keys before anything else — a misspelled key is accepted and then ignored. + +`POST /realms/{realm_name}/clients/{client_id}/evaluate-scopes` returns the claims a client would receive without requiring a full login. +::: +:::: + +::::card-group{cols=2} +:::card{label="Custom Claims" icon="lucide:plus" href="/en/modules/aegis/custom-claims"} +Worked patterns: roles for a gateway, tenant identifiers, feature flags. +::: +:::card{label="Client Scopes" icon="lucide:list" href="/en/discover/core-concepts/client-scopes"} +How scopes resolve, and the claims the built-in scopes carry. ::: :::: 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 659ecce..8092095 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 @@ -18,6 +18,11 @@ With Compass you get this instead: > Flow `01914b3c-...` for client `my-frontend` via `authorization_code`: > ✓ authorize (12ms) → ✓ credential_validation (85ms) → ✗ mfa_challenge (0ms, error: `invalid_otp`) → Flow failed at 97ms +FerrisKey Compass dashboard listing authentication executions with grant type, outcome, step count and duration +FerrisKey Compass dashboard listing authentication executions with grant type, outcome, step count and duration + +Each row is one authentication execution: its grant type, its outcome, how many steps it went through and how long it took. A `pending` row with zero steps is an authorization request that was started and never completed — normal in small numbers, and a signal when it is not. + ## How it works With Compass on, every authentication request opens a `CompassFlow`. As the request moves through credential validation, the MFA challenge, token exchange, and finalization, each step is written as a `CompassFlowStep` with its own status and duration. diff --git a/apps/docs/src/content/docs/modules/default/en/compass/querying.mdx b/apps/docs/src/content/docs/modules/default/en/compass/querying.mdx index 1414d6d..d47e2f0 100644 --- a/apps/docs/src/content/docs/modules/default/en/compass/querying.mdx +++ b/apps/docs/src/content/docs/modules/default/en/compass/querying.mdx @@ -13,13 +13,15 @@ Compass stores every authentication flow in PostgreSQL, making them queryable th The stats endpoint returns aggregate metrics for a realm at a glance: -```json title="GET /admin/realms/{realm}/compass/stats" +```json title="GET /realms/{realm_name}/compass/v1/stats" { - "total": 15234, - "success_count": 14102, - "failure_count": 987, - "pending_count": 145, - "avg_duration_ms": 142.5 + "data": { + "total": 15234, + "success_count": 14102, + "failure_count": 987, + "pending_count": 145, + "avg_duration_ms": 142.5 + } } ``` @@ -43,31 +45,33 @@ The flows endpoint supports rich filtering to narrow down to exactly the flows y | `user_id` | `UUID` | Flows for a specific user | | `grant_type` | `String` | Filter by grant type (`password`, `authorization_code`, etc.) | | `status` | `String` | Filter by outcome (`success`, `failure`, `pending`, `expired`) | -| `from_timestamp` | `DateTime` | Flows started after this time | -| `to_timestamp` | `DateTime` | Flows started before this time | | `limit` | `u32` | Maximum results (default: 50) | | `offset` | `u32` | Pagination offset (default: 0) | +:::callout{variant="warning" title="No time range on this endpoint"} +`GET /compass/v1/flows` takes no `from_timestamp` or `to_timestamp`: the handler always queries the full history and paginates it, newest first. Narrow with `limit`, or use `GET /compass/v1/activity/daily` when you need a window. +::: + ### Example Queries -**All failed flows in the last hour:** +**The most recent failed flows:** ```bash -curl "http://localhost:3333/admin/realms/my-app/compass/flows?\ +curl "http://localhost:3333/realms/my-app/compass/v1/flows?\ status=failure&\ -from_timestamp=2026-03-17T13:30:00Z" \ +limit=50" \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` **All flows for a specific user:** ```bash -curl "http://localhost:3333/admin/realms/my-app/compass/flows?\ +curl "http://localhost:3333/realms/my-app/compass/v1/flows?\ user_id=01914b3c-5678-7f5a-b456-000000000002" \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` **Password grant flows for a specific client:** ```bash -curl "http://localhost:3333/admin/realms/my-app/compass/flows?\ +curl "http://localhost:3333/realms/my-app/compass/v1/flows?\ client_id=my-frontend&\ grant_type=password&\ limit=10" \ @@ -78,42 +82,70 @@ limit=10" \ Fetch a single flow by ID to see all its steps: -```json title="GET /admin/realms/{realm}/compass/flows/{flow_id}" +```json title="GET /realms/{realm_name}/compass/v1/flows/{flow_id}" { - "id": "01914b3c-7e8a-7f5a-b456-789012345678", - "realm_id": "01914b3c-1234-7f5a-b456-000000000001", - "client_id": "my-frontend", - "user_id": "01914b3c-5678-7f5a-b456-000000000002", - "grant_type": "password", - "status": "failure", - "ip_address": "203.0.113.42", - "user_agent": "Mozilla/5.0 ...", - "started_at": "2026-03-17T14:30:00.000Z", - "completed_at": "2026-03-17T14:30:00.097Z", - "duration_ms": 97, - "steps": [ - { - "step_name": "credential_validation", - "status": "success", - "duration_ms": 85, - "error_code": null, - "error_message": null, - "started_at": "2026-03-17T14:30:00.000Z" - }, - { - "step_name": "mfa_challenge", - "status": "failure", - "duration_ms": 12, - "error_code": "invalid_otp", - "error_message": "The provided TOTP code is invalid", - "started_at": "2026-03-17T14:30:00.085Z" - } - ] + "data": { + "id": "01914b3c-7e8a-7f5a-b456-789012345678", + "realm_id": "01914b3c-1234-7f5a-b456-000000000001", + "client_id": "my-frontend", + "user_id": "01914b3c-5678-7f5a-b456-000000000002", + "grant_type": "password", + "status": "failure", + "ip_address": "203.0.113.42", + "user_agent": "Mozilla/5.0 ...", + "started_at": "2026-03-17T14:30:00.000Z", + "completed_at": "2026-03-17T14:30:00.097Z", + "duration_ms": 97, + "steps": [ + { + "step_name": "credential_validation", + "status": "success", + "duration_ms": 85, + "error_code": null, + "error_message": null, + "started_at": "2026-03-17T14:30:00.000Z" + }, + { + "step_name": "mfa_challenge", + "status": "failure", + "duration_ms": 12, + "error_code": "invalid_otp", + "error_message": "The provided TOTP code is invalid", + "started_at": "2026-03-17T14:30:00.085Z" + } + ] + } } ``` This tells the complete story: the user's password was correct (85ms for Argon2 verification), but the TOTP code was wrong. +## Daily activity + +The third read endpoint aggregates per day, and it is the one that takes a date window. + +``` +GET /realms/{realm_name}/compass/v1/activity/daily +``` + +| Parameter | Type | Description | +|---|---|---| +| `from_date` | `Date` | First day in the window, `YYYY-MM-DD` | +| `to_date` | `Date` | Last day in the window | +| `client_id` | `String` | Restrict to one client | +| `user_id` | `UUID` | Restrict to one account | +| `grant_type` | `String` | Restrict to one grant type | + +```json title="GET /realms/my-app/compass/v1/activity/daily?from_date=2026-09-01&to_date=2026-09-03" +{ + "data": [ + { "date": "2026-09-01", "signups": 12, "logins": 431 }, + { "date": "2026-09-02", "signups": 9, "logins": 402 }, + { "date": "2026-09-03", "signups": 14, "logins": 455 } + ] +} +``` + ## Analytics people actually build ### Authentication latency dashboard @@ -144,7 +176,6 @@ Query failed flows and group by `error_code` to find the most common failure pat async function getTopFailureReasons(realmName: string) { const flows = await ferriskey.getCompassFlows(realmName, { status: 'failure', - from_timestamp: subDays(new Date(), 7).toISOString(), limit: 500, }) @@ -177,12 +208,11 @@ Find flows where a specific step took unusually long: ```bash # Find flows where credential_validation took > 500ms # (might indicate database performance issues) -curl "http://localhost:3333/admin/realms/my-app/compass/flows?\ +curl "http://localhost:3333/realms/my-app/compass/v1/flows?\ status=success&\ -from_timestamp=2026-03-17T00:00:00Z&\ limit=100" \ -H "Authorization: Bearer $ADMIN_TOKEN" | \ - jq '.[] | select(.steps[] | select(.step_name == "credential_validation" and .duration_ms > 500))' + jq '.data[] | select(.steps[] | select(.step_name == "credential_validation" and .duration_ms > 500))' ``` ### IdP performance monitoring @@ -197,7 +227,7 @@ When using [Abyss](/en/modules/abyss/overview) for external IdP federation, Comp Investigate a specific user's authentication patterns: ```bash -curl "http://localhost:3333/admin/realms/my-app/compass/flows?\ +curl "http://localhost:3333/realms/my-app/compass/v1/flows?\ user_id=USER_UUID&\ limit=20" \ -H "Authorization: Bearer $ADMIN_TOKEN" diff --git a/apps/docs/src/content/docs/modules/default/en/maintenance/_meta.json b/apps/docs/src/content/docs/modules/default/en/maintenance/_meta.json new file mode 100644 index 0000000..058e08c --- /dev/null +++ b/apps/docs/src/content/docs/modules/default/en/maintenance/_meta.json @@ -0,0 +1,6 @@ +{ + "icon": "wrench", + "title": "Maintenance", + "type": "group", + "order": 12 +} diff --git a/apps/docs/src/content/docs/modules/default/en/maintenance/overview.mdx b/apps/docs/src/content/docs/modules/default/en/maintenance/overview.mdx new file mode 100644 index 0000000..f516290 --- /dev/null +++ b/apps/docs/src/content/docs/modules/default/en/maintenance/overview.mdx @@ -0,0 +1,119 @@ +--- +title: Overview +description: "Close a client to everyone but a whitelist, without touching its configuration." +icon: wrench +order: 100 +--- + +# Maintenance Mode + +Maintenance mode temporarily restricts who can authenticate through a client, without touching the client's configuration. It exists for the window where an application is mid-migration and should not be letting users in, but you do not want to disable the client and then try to remember exactly how it was set up. + +Two things make it different from disabling a client: + +- **It is reversible by one switch**, and it changes nothing else. Redirect URIs, scopes, secrets all stay as they are. +- **It has a hole in it, on purpose.** A whitelist of users and roles keeps authenticating, so your own team can verify the migration on the real client before reopening it. + +## Closing a client + +FerrisKey client maintenance tab showing the maintenance switch, reason, session strategy and allowed users +FerrisKey client maintenance tab showing the maintenance switch, reason, session strategy and allowed users + +``` +PUT /realms/{realm_name}/clients/{client_id}/maintenance +``` + +```json title="Request body" +{ + "enabled": true, + "reason": "Migration under way, back at 2pm.", + "session_strategy": "expire" +} +``` + +| Field | Default | Description | +|---|---|---| +| `enabled` | required | `true` closes the client to everyone but the whitelist | +| `reason` | none | Message displayed to blocked users on the login page | +| `session_strategy` | `expire` | What happens to sessions that are already open | + +The switch applies as soon as you flip it. There is no drain period. + +### Session strategy + +| Value | Behaviour | +|---|---| +| `expire` | Open sessions live until they run out. The default | +| `terminate` | Every session is cut on the spot | + +`expire` is the polite option and the right default: people already working keep working, and nobody new gets in. Reach for `terminate` when the reason for closing the client is that the sessions themselves are the problem — a leaked token, a compromised integration — and letting them run to expiry is the thing you are trying to prevent. + +:::callout{variant="info" title="Write the reason as the user will read it"} +`reason` is shown on the login page, to someone who was trying to get on with their day. "Migration under way, back at 2pm" answers their actual question. "MAINT-4417" does not. +::: + +## The whitelist + +Whitelist entries name a user or a role. Everyone named, and every holder of a named role, authenticates normally while the client is in maintenance. + +Entries exist at two levels, and they **add up**: a client's effective whitelist is its own entries plus the realm's. + +### Per client + +``` +GET /realms/{realm_name}/clients/{client_id}/maintenance/whitelist +POST /realms/{realm_name}/clients/{client_id}/maintenance/whitelist +DELETE /realms/{realm_name}/clients/{client_id}/maintenance/whitelist/{entry_id} +``` + +### Per realm + +Realm-level entries are inherited by every client of the realm placed under maintenance — the standing list of people who should never be locked out. + +FerrisKey realm settings maintenance tab showing default maintenance members for users and roles +FerrisKey realm settings maintenance tab showing default maintenance members for users and roles + +``` +GET /realms/{realm_name}/settings/maintenance/whitelist +POST /realms/{realm_name}/settings/maintenance/whitelist +DELETE /realms/{realm_name}/settings/maintenance/whitelist/{entry_id} +``` + +```json title="Adding an entry — supply one or the other" +{ + "role_id": "01a0d3dd-a750-7817-aa2b-7e8b98f68e66" +} +``` + +Both `user_id` and `role_id` are optional in the payload; supply the one that describes the entry you want. + +:::callout{variant="warning" title="Put the role on the realm whitelist before you need it"} +A role-based realm entry — your platform team's role, say — is the difference between a planned maintenance window and an incident. Adding a whitelist entry requires authenticating to the admin console, and if the console's own client is the one you just closed, you have locked yourself out of the tool you need to unlock it. +::: + +## Permissions + +| Action | Permission | +|---|---| +| Read a whitelist | `view_clients`, or `manage_clients` | +| Toggle maintenance, add or remove entries | `manage_clients` | + +`manage_realm` also grants all of them. + +## What gets recorded + +Both transitions are observable, which matters because maintenance mode is a thing that gets left on by accident: + +- [SeaWatch](/en/modules/seawatch/event-types) records `client_maintenance_enabled` and `client_maintenance_disabled`. +- [Webhooks](/en/modules/webhooks/triggers) fire `client.maintenance.enabled` and `client.maintenance.disabled`. + +A webhook on the enabled trigger that opens a reminder, and one on disabled that closes it, is the cheapest way to stop a Friday-afternoon window from lasting until Monday. + +::::card-group{cols=2} +:::card{label="Clients" icon="lucide:app-window" href="/en/discover/core-concepts/clients"} +The configuration maintenance mode deliberately leaves alone. +::: +:::card{label="Webhook Triggers" icon="lucide:webhook" href="/en/modules/webhooks/triggers"} +Wiring the two maintenance transitions to something that notices. +::: +:::: diff --git a/apps/docs/src/content/docs/modules/default/en/overview.mdx b/apps/docs/src/content/docs/modules/default/en/overview.mdx index 4b090ed..d42dac9 100644 --- a/apps/docs/src/content/docs/modules/default/en/overview.mdx +++ b/apps/docs/src/content/docs/modules/default/en/overview.mdx @@ -31,8 +31,14 @@ Event-driven integrations: subscribe to lifecycle events and push them outward. :::card{label="Organizations" icon="lucide:building-2" href="/en/modules/organization/overview"} B2B tenancy: organizations, groups, membership, roles, and custom attributes. ::: -:::card{label="SAML" icon="lucide:file-badge" href="/en/modules/saml/overview"} -FerrisKey as a SAML 2.0 identity provider, for applications that speak nothing else. +:::card{label="Account" icon="lucide:user-cog" href="/en/modules/account/overview"} +Self-service: what a user changes about themselves, with no administrator and no permission. +::: +:::card{label="Portal" icon="lucide:palette" href="/en/modules/portal/overview"} +Themes and layouts for the public authentication pages a realm renders. +::: +:::card{label="Maintenance" icon="lucide:wrench" href="/en/modules/maintenance/overview"} +Close a client to everyone but a whitelist, without touching its configuration. ::: :::: diff --git a/apps/docs/src/content/docs/modules/default/en/portal/_meta.json b/apps/docs/src/content/docs/modules/default/en/portal/_meta.json new file mode 100644 index 0000000..7f2d831 --- /dev/null +++ b/apps/docs/src/content/docs/modules/default/en/portal/_meta.json @@ -0,0 +1,6 @@ +{ + "icon": "palette", + "title": "Portal", + "type": "group", + "order": 11 +} diff --git a/apps/docs/src/content/docs/modules/default/en/portal/login-actions.mdx b/apps/docs/src/content/docs/modules/default/en/portal/login-actions.mdx new file mode 100644 index 0000000..a6dcac9 --- /dev/null +++ b/apps/docs/src/content/docs/modules/default/en/portal/login-actions.mdx @@ -0,0 +1,112 @@ +--- +title: Login Actions +description: "The endpoints the portal itself calls — what you need if you replace its UI with your own." +icon: terminal +order: 91 +--- + +# Login Actions + +Everything else in this documentation is the surface an *application* integrates with: the OIDC endpoints, the admin API. This page is the other kind. `login-actions` is the family the **portal itself** calls — the 21 endpoints behind the login page, the MFA challenge, the password reset and the passkey prompts. + +You do not touch them when you connect an application. You touch them when you **replace the portal** with your own UI and need to drive the same flows. + +:::callout{variant="warning" title="This is an internal contract, not a stable public API"} +These endpoints exist to serve FerrisKey's own portal, and they move with it. If a themed portal gets you where you need to go, use [themes and layouts](/en/modules/portal/overview) instead — you get the flows for free and they keep working across upgrades. Reach for this family only when theming genuinely cannot express what you need. +::: + +Paths below are relative to `/realms/{realm_name}`. + +## Password sign-in + +| Method | Path | What it does | +|---|---|---| +| `POST` | `/login-actions/authenticate` | Authenticate a user in the realm | +| `POST` | `/login-actions/update-password` | Set a new password, for the `update_password` required action | + +`authenticate` is the entry point. When the realm or the user requires a second factor, it does not return full tokens — it returns a temporary token and a status saying which challenge to run. That temporary token authorizes the challenge and nothing else. + +## MFA challenge + +| Method | Path | What it does | +|---|---|---| +| `GET` | `/login-actions/setup-otp` | Start authenticator enrolment: returns the secret to render as a QR code | +| `POST` | `/login-actions/verify-otp` | Verify the code during enrolment | +| `POST` | `/login-actions/challenge-otp` | Ask for an authenticator code during sign-in | +| `POST` | `/login-actions/generate-recovery-codes` | Generate recovery codes that let the user bypass a challenge | +| `POST` | `/login-actions/burn-recovery-code` | Spend one recovery code instead of the challenge | + +Recovery codes are issued **here**, during the flow — not from the account page. See [Recovery Codes](/en/modules/trident/recovery-codes). + +## Passkeys and WebAuthn + +| Method | Path | What it does | +|---|---|---| +| `POST` | `/login-actions/passkey-request-options` | Start passkey sign-in. With a username, the challenge is scoped to that user's passkeys; without one, it is discoverable and the browser proposes what it has | +| `POST` | `/login-actions/passkey-authenticate` | Submit the browser's assertion. On success returns a login URL carrying an authorization code | +| `POST` | `/login-actions/webauthn-public-key-create-options` | Full `PublicKeyCredentialCreationOptions`, challenge in base64url | +| `POST` | `/login-actions/webauthn-public-key-create` | Store a new WebAuthn public key | +| `POST` | `/login-actions/webauthn-public-key-request-options` | Full `PublicKeyCredentialRequestOptions` | +| `POST` | `/login-actions/webauthn-public-key-authenticate` | Authenticate with a `WebAuthnAssertionResponse` | + +The discoverable variant of `passkey-request-options` is what makes usernameless sign-in possible: no identifier is submitted, so the page cannot be used to probe which accounts exist. + +## Password reset + +| Method | Path | What it does | +|---|---|---| +| `POST` | `/login-actions/forgot-password` | Send a reset email if the address exists | +| `POST` | `/login-actions/verify-reset-token` | Check a token is valid and unexpired, **without consuming it** | +| `POST` | `/login-actions/reset-password` | Complete the reset and return tokens, signing the user straight in | + +:::callout{variant="info" title="Two deliberate details"} +`forgot-password` **always answers 204**, whether the address exists or not — that is what stops the endpoint being an account-enumeration oracle. Do not "improve" your UI by reporting unknown addresses. + +`verify-reset-token` exists so a reset page can render its form knowing the token is good, without spending it. Check on load, consume on submit. +::: + +## Email verification + +| Method | Path | What it does | +|---|---|---| +| `POST` | `/login-actions/verify-email` | Verify an address with the token from the email | +| `POST` | `/login-actions/resend-verification-email` | Resend the link. Requires a Bearer token | + +## Magic links + +| Method | Path | What it does | +|---|---|---| +| `POST` | `/login-actions/send-magic-link` | Email a one-time sign-in link | +| `GET` | `/login-actions/verify-magic-link` | Consume the token and return the redirect URL with an authorization code | + +## Device flow + +The [device code grant](/en/discover/core-concepts/authentication#device-code) needs three endpoints beyond the token endpoint, and they are the pages the *user* visits rather than the device: + +| Method | Path | What it does | +|---|---|---| +| `GET` | `/device` | The `verification_uri` itself. Redirects to the web app, pre-filling the code when passed as `?user_code=` | +| `GET` | `/device/preview` | The client and scopes a pending device session is asking for, so the page can show what is being approved (RFC 8628 §5.3) | +| `POST` | `/device/verify` | Approve or deny, once the user is authenticated | + +Both `preview` and `verify` need the `FERRISKEY_IDENTITY` cookie. When it is absent, `verify` answers `401` with a `redirect_uri` hint pointing back at the verification page — so a custom page can send the user to sign in and return, rather than dead-ending. + +`preview` also refuses codes belonging to another session, which is what stops one user approving a device code that was displayed to someone else. + +## What you still get for free + +Replacing the portal does not mean reimplementing the whole IAM. These keep working underneath, whatever UI calls them: + +- The [authentication chain](/en/discover/core-concepts/authentication#authentication-chain) — lockout, required actions, realm policy. +- [Compass](/en/modules/compass/overview) flow recording, step by step, including the steps your UI drove. +- [SeaWatch](/en/modules/seawatch/overview) audit events and [webhooks](/en/modules/webhooks/overview). +- The [password policy](/en/discover/core-concepts/password-policy), including its public endpoint so your form can evaluate the rules as the user types. + +::::card-group{cols=2} +:::card{label="Portal themes" icon="lucide:palette" href="/en/modules/portal/overview"} +The supported way to change what the login pages look like. +::: +:::card{label="Trident" icon="lucide:shield-check" href="/en/modules/trident/overview"} +What each MFA challenge means before you drive it yourself. +::: +:::: diff --git a/apps/docs/src/content/docs/modules/default/en/portal/overview.mdx b/apps/docs/src/content/docs/modules/default/en/portal/overview.mdx new file mode 100644 index 0000000..02e8a0a --- /dev/null +++ b/apps/docs/src/content/docs/modules/default/en/portal/overview.mdx @@ -0,0 +1,163 @@ +--- +title: Overview +description: "Theme and lay out the public authentication pages a realm renders: colours, typography, and the twelve page types." +icon: palette +order: 90 +--- + +# Portal + +The portal is what an end user actually sees: the login page, the MFA challenge, the password reset, the email-verified confirmation. It is served by FerrisKey, on FerrisKey's domain, which makes it the one surface where your users meet the IAM directly — and the one place where an unstyled default is immediately visible. + +The portal module is how you stop it looking like someone else's product. It splits in two: + +- **Themes** carry the appearance: colours, typography, and the content of each page. +- **Layouts** carry the arrangement: the structure the pages are composed into. + +Both are per realm, so two realms in one deployment can look nothing alike. + +FerrisKey portal themes tab showing theme counts, the active theme and the import action +FerrisKey portal themes tab showing theme counts, the active theme and the import action + +## Themes + +A realm can hold any number of themes, and **exactly one is active** at a time — the active one is what the portal renders. Everything else is a draft you can work on without touching what users see. + +### The twelve page types + +A theme carries content for twelve pages. All of them: + +| Page type | Rendered when | +|---|---| +| `login` | The sign-in form | +| `register` | Self-service registration, if the realm enables it | +| `totp` | An authenticator code is being asked for | +| `totp_setup` | The user is enrolling an authenticator through a required action | +| `forgot_password` | The user asks to reset their password | +| `reset_password` | The user follows the reset link | +| `magic_link_request` | The user asks for a magic link | +| `magic_link_verify` | The magic link is being consumed | +| `verify_email` | An email address is awaiting confirmation | +| `email_verified` | Confirmation succeeded | +| `device_verify` | A device-flow user code is being entered | +| `device_verified` | The device flow was approved | + +Editing one page at a time: + +``` +PUT /realms/{realm_name}/portal/themes/{theme_id}/pages/{page_type} +``` + +### Activation is gated on validity + +A theme can only be activated when **every** page is valid — the console calls this "activatable", and counts them for you. This is deliberate: a partially filled theme would render a broken page for whichever flow happens to hit the gap, and that flow might be password reset at 2am. + +``` +GET /realms/{realm_name}/portal/page-requirements +POST /realms/{realm_name}/portal/themes/{theme_id}/activate +``` + +`page-requirements` tells you what each page still needs, which is what to show a designer rather than a bare "not activatable". + +:::callout{variant="info" title="No theme is a valid state"} +With no theme at all, or none activated, the portal falls back to its built-in appearance. Nothing breaks — you simply get FerrisKey's defaults. The console shows this as an active count of zero. +::: + +### Theme API + +| Method | Path | Description | +|---|---|---| +| `GET` | `/realms/{realm_name}/portal/themes` | List the realm's themes | +| `POST` | `/realms/{realm_name}/portal/themes` | Create a theme | +| `GET` | `/realms/{realm_name}/portal/themes/{theme_id}` | Read one theme | +| `PUT` | `/realms/{realm_name}/portal/themes/{theme_id}` | Update a theme's metadata | +| `DELETE` | `/realms/{realm_name}/portal/themes/{theme_id}` | Delete a theme | +| `PUT` | `/realms/{realm_name}/portal/themes/{theme_id}/pages/{page_type}` | Replace one page | +| `POST` | `/realms/{realm_name}/portal/themes/{theme_id}/activate` | Make this theme the active one | +| `GET` | `/realms/{realm_name}/portal/theme` | Read the realm's theme settings | +| `PUT` | `/realms/{realm_name}/portal/theme` | Update the realm's theme settings | +| `GET` | `/realms/{realm_name}/portal/page-requirements` | What each page still needs to be valid | + +**Required permissions:** `manage_realm`, for reads as well as writes. + +:::callout{variant="info" title="There is no read-only access to themes"} +Unlike most of the admin surface, the portal endpoints do not accept `view_realm`: listing themes needs `manage_realm`, the same permission as activating one. A designer who should be able to look at a theme without being able to activate it cannot be granted that today. The public endpoints below are the read path that needs no permission at all. +::: + +### The public endpoint + +``` +GET /realms/{realm_name}/portal/active +``` + +Unauthenticated, because the login page has to be able to render before anyone has signed in. It returns the active theme's appearance and nothing else — a theme is public by nature, so treat what you put in one accordingly. + +## Layouts + +FerrisKey portal layouts tab with layout counters and the Import action +FerrisKey portal layouts tab with layout counters and the Import action + +A theme with no layout renders its pages bare, and that is a valid composition rather than an error — the counters distinguish layouts that are **attached** to a theme from those that are merely **deletable** because nothing holds them. + +Layouts hold the arrangement rather than the colours. Each carries a `tree`, and one layout per realm can be marked as the **default**, which is the one used when nothing else is specified. + +| Method | Path | Description | +|---|---|---| +| `GET` | `/realms/{realm_name}/portal-layouts` | List the realm's layouts | +| `POST` | `/realms/{realm_name}/portal-layouts` | Create a layout | +| `GET` | `/realms/{realm_name}/portal-layouts/{layout_id}` | Read one layout | +| `PUT` | `/realms/{realm_name}/portal-layouts/{layout_id}` | Update a layout | +| `DELETE` | `/realms/{realm_name}/portal-layouts/{layout_id}` | Delete a layout | +| `PUT` | `/realms/{realm_name}/portal-layouts/{layout_id}/default` | Mark it as the realm's default | +| `GET` | `/realms/{realm_name}/portal-layouts/public/{layout_id}` | Public read of one layout | +| `GET` | `/realms/{realm_name}/portal-layouts/public/default` | Public read of the default layout | + +The two `public/` endpoints are unauthenticated for the same reason as `portal/active`. + +## Moving a theme between realms + +``` +GET /realms/{realm_name}/portal/themes/{theme_id}/export +POST /realms/{realm_name}/portal/themes/import +GET /realms/{realm_name}/portal-layouts/{layout_id}/export +POST /realms/{realm_name}/portal-layouts/import +``` + +A theme exports as a self-contained JSON envelope: its design tokens, all twelve page trees, **and the layout it is framed by, carried by value**. Importing it into another realm or another deployment recreates that layout and binds it to the new theme, so the file does not depend on anything already existing at the destination. + +Layout import validates the tree before storing it, which the ordinary create endpoint does not — create is fed by the console's builder and trusts its input. If you generate layouts from a script, import is the endpoint that will catch your mistakes. + +[Export & Import](/en/discover/guides/portability) covers the whole portability surface, including email templates. + +## Working on a theme safely + +::::step-group +:::step{title="Duplicate rather than edit live"} +Create a new theme instead of editing the active one. The active theme is serving real sign-ins while you work. +::: + +:::step{title="Fill every page"} +Twelve page types, all required for activation. `page-requirements` lists what is still missing. +::: + +:::step{title="Check the flows you do not think about"} +`login` gets looked at. `device_verified` and `email_verified` do not, and they are exactly where an unstyled page survives for months. +::: + +:::step{title="Activate"} +One call, and it takes effect for the next request. Activation is reversible — activate the previous theme to roll back. +::: +:::: + +:::callout{variant="warning" title="Theme activation is not a realm setting"} +The realm does store which theme is active, but it is not a field you can write through `PUT /realms/{realm_name}/settings`. Activation goes through the activate endpoint, which is what enforces the validity gate. +::: + +::::card-group{cols=2} +:::card{label="Email & Templates" icon="lucide:mail" href="/en/discover/guides/email"} +The other branded surface: the transactional emails the portal's flows send. +::: +:::card{label="Realms" icon="lucide:layers" href="/en/discover/core-concepts/realms"} +The settings that decide which portal pages are reachable at all. +::: +:::: diff --git a/apps/docs/src/content/docs/modules/default/en/saml/_meta.json b/apps/docs/src/content/docs/modules/default/en/saml/_meta.json deleted file mode 100644 index 4c36ca4..0000000 --- a/apps/docs/src/content/docs/modules/default/en/saml/_meta.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "icon": "file-badge", - "title": "SAML", - "type": "group", - "order": 9 -} 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 0849af1..36c49f3 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 @@ -45,6 +45,7 @@ When a login fails, the user is not yet authenticated, so `actor_id` is typicall | `password_reset` | Password was reset by an admin | Admin | User | | `password_reset_requested` | User requested a password reset email | System | User | | `password_reset_completed` | User completed the password reset flow | User | User | +| `password_imported` | A password hash was imported from another IAM and re-hashed to argon2id on first login | System | User | These three events trace the full password reset lifecycle. A `password_reset_requested` without a matching `password_reset_completed` might indicate a user who abandoned the flow, or an attacker testing email enumeration. @@ -66,6 +67,8 @@ User creation events record whether the account came from an admin, from self-re | `role_unassigned` | Role removed from a user | Admin | User | | `role_created` | New role created | Admin | Role | | `role_removed` | Role deleted | Admin | Role | +| `role_updated` | A role's name or description changed | Admin | Role | +| `role_permission_updated` | A role's permission set changed | Admin | Role | Role events are critical for access control audits. They answer: "Who granted this user admin permissions, and when?" diff --git a/apps/docs/src/content/docs/modules/default/en/seawatch/overview.mdx b/apps/docs/src/content/docs/modules/default/en/seawatch/overview.mdx index 0c04277..5fccbea 100644 --- a/apps/docs/src/content/docs/modules/default/en/seawatch/overview.mdx +++ b/apps/docs/src/content/docs/modules/default/en/seawatch/overview.mdx @@ -11,6 +11,11 @@ SeaWatch records the security-relevant actions in FerrisKey. A login, a password Without it, a compromised account is a mystery. With it, you can trace one back to the exact login attempt, IP address, and user agent. The same trail is what SOC 2, ISO 27001, GDPR, and HIPAA audits ask for, and what makes "repeated failures from one IP" a query rather than a hunch. +FerrisKey SeaWatch dashboard showing event counters, the authentication traffic chart and the event stream +FerrisKey SeaWatch dashboard showing event counters, the authentication traffic chart and the event stream + +The dashboard leads with what you would otherwise have to query for: failures over the window, the success rate, the number of distinct actors, and an authentication traffic curve. The banners above it call out the two things worth interrupting you for — failed events, and events whose actor could not be identified. + ## Event structure Every SeaWatch event captures a complete picture: @@ -68,6 +73,18 @@ Every SeaWatch event captures a complete picture: **Alerting.** Pair SeaWatch with [Webhooks](/en/modules/webhooks/overview) to fire on the patterns that matter: repeated `login_failure` from one IP, a `client_secret_rotated` outside a maintenance window, a `realm_config_changed` from an actor nobody expected. +## Tamper evidence + +Every event carries two hashes: `event_hash`, computed over the event's own fields, and `prev_hash`, the `event_hash` of the event before it in that realm's chain. The first event of a realm chains from a fixed genesis value. + +That makes the log append-only in a way you can check rather than trust. Edit a stored event and its hash no longer matches its contents; delete one and the chain breaks at the gap. Either way, verification points at the exact position where the log stopped being consistent. + +The hash covers the actor, the event type, the target, the resource, the timestamp and the `trace_id` — the fields an attacker would want to rewrite. + +:::callout{variant="info" title="What this does and does not give you"} +The chain detects tampering by anyone who can write to the database, including someone who edits rows directly. It does not prevent it, and it is not a notary: an attacker able to recompute the whole chain from the point of the edit forward can produce a consistent log. Anchoring the head hash somewhere outside the database — your SIEM, a WORM bucket — is what closes that gap, and is worth doing where the log is evidence. +::: + ## Privacy Events carry IP addresses and user agents, which are personal data in some jurisdictions. The realm setting `seawatch_pii_mode` decides what is stored: diff --git a/apps/docs/src/content/docs/modules/default/en/seawatch/querying.mdx b/apps/docs/src/content/docs/modules/default/en/seawatch/querying.mdx index adf5f1f..1753362 100644 --- a/apps/docs/src/content/docs/modules/default/en/seawatch/querying.mdx +++ b/apps/docs/src/content/docs/modules/default/en/seawatch/querying.mdx @@ -7,36 +7,59 @@ order: 22 # Querying & Integration -SeaWatch events are stored in PostgreSQL and queryable through the FerrisKey admin API. You can filter, paginate, and export events for compliance reporting, incident investigation, or SIEM integration. +SeaWatch events are stored in PostgreSQL and read back through one endpoint. You can filter, paginate, and export them for compliance reporting, incident investigation, or SIEM integration. -## Querying Events +## The endpoint -The admin API exposes endpoints to list and filter security events within a realm: +``` +GET /realms/{realm_name}/seawatch/v1/security-events +``` + +Reading events needs `view_events`, `manage_events` or `manage_realm`. + +:::callout{variant="warning" title="Mind the root path"} +Paths here are written as the server exposes them with no root path, which is the default. Set `SERVER_ROOT_PATH` — the Helm chart sets `/api` — and every path below gains that prefix. +::: + +### Query parameters -### Available Filters +| Parameter | Type | Description | +|---|---|---| +| `event_types` | `String` | Comma-separated list of event types. Note the plural | +| `actor_id` | `UUID` | Events triggered by this account | +| `client_id` | `UUID` | Events attributed to this client | +| `ip_address` | `String` | Events from this address | +| `from_timestamp` | `DateTime` | Events at or after this instant, RFC 3339 | +| `to_timestamp` | `DateTime` | Events at or before this instant | +| `limit` | `u32` | Page size | +| `offset` | `u32` | Pagination offset | -| Filter | Description | -|---|---| -| `event_type` | Filter by specific event type (e.g., `login_failure`) | -| `status` | Filter by `success` or `failure` | -| `actor_id` | Events triggered by a specific user | -| `actor_type` | Events by actor classification (`user`, `admin`, `system`, `service_account`) | -| `target_type` | Events affecting a specific resource type | -| `target_id` | Events affecting a specific resource | -| `ip_address` | Events from a specific IP | -| Time range | Events within a date/time window | +:::callout{variant="info" title="Fields you can read but not filter on"} +An event carries more than you can query: `actor_type`, `status`, `target_type`, `target_id`, `resource`, `user_agent`, `trace_id` and a free-form `details` object are all in the payload, but none of them is a query parameter. Filter on them in your SIEM after ingestion, or narrow with `event_types` first. +::: + +### Example: recent failed logins + +```bash +curl -X GET "http://localhost:3333/realms/my-app/seawatch/v1/security-events?event_types=login_failure&limit=50" \ + -H "Authorization: Bearer $ADMIN_TOKEN" +``` -### Example: every login failure for one user +### Example: everything one account did in a window ```bash -curl -X GET "http://localhost:3333/admin/realms/my-app/events?event_type=login_failure&target_id=USER_UUID" \ +curl -X GET "http://localhost:3333/realms/my-app/seawatch/v1/security-events?\ +actor_id=USER_UUID&\ +from_timestamp=2026-09-01T00:00:00Z&\ +to_timestamp=2026-09-30T23:59:59Z" \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` -### Example: recent admin actions +### Example: several event types at once ```bash -curl -X GET "http://localhost:3333/admin/realms/my-app/events?actor_type=admin" \ +curl -X GET "http://localhost:3333/realms/my-app/seawatch/v1/security-events?\ +event_types=client_secret_viewed,client_secret_rotated,role_permission_updated" \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` @@ -73,7 +96,7 @@ Use [Webhooks](/en/modules/webhooks/overview) to react to SeaWatch-tracked event SeaWatch provides the audit trail required for the "Security" and "Availability" trust service criteria. Export events by time range to produce evidence for auditors. ### GDPR -Track when user data is accessed (`login_success`), modified (`user_updated`), or deleted (`user_deleted`). SeaWatch makes it possible to answer data subject access requests with precise records. +Track when an account is created (`user_created`), when its email is confirmed (`user_email_verified`), when its roles change (`role_assigned`, `role_unassigned`) and when it is deleted (`user_deleted`). SeaWatch makes it possible to answer data subject access requests with precise records. ### PCI DSS For environments handling payment data, SeaWatch captures all authentication and authorization events needed for PCI DSS Requirement 10 (Track and monitor all access to network resources and cardholder data). diff --git a/apps/docs/src/content/docs/modules/default/en/webhooks/deliveries.mdx b/apps/docs/src/content/docs/modules/default/en/webhooks/deliveries.mdx new file mode 100644 index 0000000..b5d5eb5 --- /dev/null +++ b/apps/docs/src/content/docs/modules/default/en/webhooks/deliveries.mdx @@ -0,0 +1,192 @@ +--- +title: Deliveries & Retries +description: "Follow every webhook delivery attempt: status codes, error codes, automatic backoff, manual replay, and secret rotation." +icon: repeat +order: 64 +--- + +# Deliveries & Retries + +A webhook that fires into a void is worse than no webhook: you believe the other system knows, and it does not. FerrisKey records **every delivery attempt** as a row you can query, with the HTTP status it got back, the error class when there was no status at all, and when it will try again. + +FerrisKey webhooks list showing endpoint count, subscription count, never-triggered and without-event counters +FerrisKey webhooks list showing endpoint count, subscription count, never-triggered and without-event counters + +## The delivery record + +``` +GET /realms/{realm_name}/webhooks/{webhook_id}/deliveries +GET /realms/{realm_name}/webhooks/{webhook_id}/deliveries/{delivery_id} +``` + +| Field | Description | +|---|---| +| `id` | Delivery identifier, used for replay | +| `event` | The [trigger](/en/modules/webhooks/triggers) that produced it, for example `user.created` | +| `resource_id` | The id of the object the event is about | +| `status` | `pending`, `delivering`, `succeeded` or `failed` | +| `attempt_count` | How many attempts have been made so far | +| `next_attempt_at` | When the worker will try again. Absent once the delivery is terminal | +| `last_attempt_at` | When the last attempt ran | +| `last_status_code` | HTTP status returned by your endpoint, when it answered at all | +| `last_error_code` | Why the attempt failed, when there was no usable HTTP response | +| `last_error_detail` | Free-text detail for that error | +| `created_at`, `updated_at` | Row timestamps | + +The single-delivery read wraps the same summary alongside the **payload** that was sent, which is what you compare against what your endpoint logged: + +```json title="GET …/deliveries/{delivery_id}" +{ + "data": { + "summary": { "id": "…", "event": "user.created", "status": "failed", "attempt_count": 5, "last_status_code": 500 }, + "payload": { "…": "the body that was posted" } + } +} +``` + +### Status, and what it means for you + +| Status | Meaning | +|---|---| +| `pending` | Queued, waiting for its turn or for `next_attempt_at` | +| `delivering` | A worker holds a lease on it right now | +| `succeeded` | Your endpoint answered with a success status | +| `failed` | Terminal. The attempts ran out, or the error is not worth retrying | + +:::callout{variant="info" title="Two fields, two kinds of failure"} +`last_status_code` and `last_error_code` answer different questions. A `500` in `last_status_code` means your endpoint was reached and broke — the problem is on your side of the wire. An error code with no status means the request never got that far: DNS, TLS, a refused address. Read them in that order. +::: + +## Error codes + +When there is no HTTP response to report, the delivery carries one of ten error codes. They separate the causes that need different fixes: + +| Code | What happened | Where to look | +|---|---|---| +| `dns_resolution_failed` | The endpoint host did not resolve | DNS, or a typo in the endpoint | +| `no_usable_address` | It resolved, but to nothing FerrisKey may call | Often a private address with the guard on — see below | +| `cleartext_not_allowed` | The endpoint is `http://` and cleartext is refused | Use `https://` | +| `malformed_endpoint` | The stored endpoint is not a usable URL | The webhook's configuration | +| `missing_host` | The URL carries no host | Same | +| `reserved_header` | A custom header collides with one FerrisKey sets itself | Remove it from the webhook's headers | +| `header_encoding_failed` | A custom header value is not encodable | Same | +| `client_build_failed` | The HTTP client could not be constructed for this endpoint | Usually a TLS or proxy problem server-side | +| `transport_error` | The request failed in transit: connection reset, timeout | Network, or your endpoint dropping the connection | +| `http_` | Your endpoint answered with a failing status. The code is interpolated, so a 500 reads `http_500` | `last_status_code` has the number on its own | + +:::callout{variant="warning" title="Private addresses are refused by default"} +An endpoint that resolves to loopback or a private range is refused with `no_usable_address`, because a webhook that can reach inside the deployment's own network is an SSRF primitive. For local development, `WEBHOOK_ALLOW_PRIVATE_ENDPOINTS=true` lifts the restriction — never in production. Link-local addresses stay refused regardless of that setting. +::: + +## Automatic retries + +A failed delivery is retried with exponential backoff and jitter. Four numbers govern it: + +| Setting | System default | Bounds | +|---|---|---| +| `max_attempts` | 5 | 1 to 20 | +| `base_delay_ms` | 500 ms | 100 ms to 60 s | +| `max_delay_ms` | 30 s | at least `base_delay_ms` | +| `max_total_delay_ms` | 120 s | — | + +`base_delay_ms` is the wait before the first retry; each subsequent wait grows and is capped by `max_delay_ms`. `max_total_delay_ms` caps the whole sequence, so a delivery stops being retried when either the attempt count or the total elapsed budget runs out — whichever comes first. + +Jitter is applied to each interval, which is what stops a hundred deliveries that failed together from retrying in lockstep and hammering your endpoint the moment it comes back. + +### Where the numbers come from + +The policy resolves in three layers, each field independently: + +1. The **webhook's** own `retry_policy`, when it sets that field. +2. Otherwise the **realm's** setting — `webhook_retry_max_attempts`, `webhook_retry_base_delay_ms`, `webhook_retry_max_delay_ms`, `webhook_retry_max_total_delay_ms`. +3. Otherwise the **system default** above. + +Because the resolution is per field, a webhook can raise only `max_attempts` and inherit the three delays. A webhook read returns both `retry_policy` (what this webhook overrides) and `effective_retry_policy` (what will actually be applied) — use the second one when you want to know what the worker will do. + +### When the attempts run out + +The delivery goes `failed`, and SeaWatch records `webhook_delivery_exhausted`. That event is the one to alert on: it is the difference between "a delivery failed and recovered" and "this endpoint has been unreachable long enough that we gave up". + +## Manual replay + +``` +POST /realms/{realm_name}/webhooks/{webhook_id}/deliveries/{delivery_id}/retry +``` + +Puts a terminal delivery back in the queue so the worker attempts it again. This is the tool for the ordinary case: the receiving service was down for an hour, the deliveries exhausted their attempts, the service is back and you want the events it missed. + +Replay re-sends the **stored payload**, unchanged. It is not a fresh snapshot of the resource — the event described the world as it was, and replaying it says the same thing again. If the resource has since changed twice more, your endpoint will also receive those deliveries, and the order it sees them in is the order they are replayed. + +:::callout{variant="info" title="Make your endpoint idempotent"} +Between automatic retries and manual replay, an endpoint will eventually receive the same delivery twice. The delivery `id` is stable across attempts and replays, which makes it the natural deduplication key. +::: + +## Rotating the signing secret + +``` +POST /realms/{realm_name}/webhooks/{webhook_id}/secret/rotate +``` + +Generates a new signing secret and returns it. **This is the only response that ever contains the secret** — it cannot be read back afterwards, so capture it from this call or rotate again. + +:::callout{variant="danger" title="Rotation breaks in-flight deliveries"} +Deliveries already signed with the previous secret will fail verification at your endpoint. Rotate when you have somewhere to put the new secret immediately, and expect the deliveries queued at that moment to fail and retry — they will be re-signed on the next attempt. +::: + +## Reading the console + +The webhooks list summarises the state of every endpoint at a glance: + +| Counter | Reads as | +|---|---| +| Total | Endpoints configured in this realm | +| Subscriptions | How many triggers are subscribed across all webhooks | +| Never triggered | Endpoints that have no delivery yet — either new, or subscribed to something that never happens | +| Without event | Endpoints subscribed to nothing. They will never fire | + +"Without event" above zero is almost always a mistake: a webhook was created and its triggers were never picked. + +## API reference + +| Method | Path | Description | +|---|---|---| +| `GET` | `/realms/{realm_name}/webhooks` | List the realm's webhooks | +| `POST` | `/realms/{realm_name}/webhooks` | Create a webhook | +| `GET` | `/realms/{realm_name}/webhooks/{webhook_id}` | Read one, with its effective retry policy | +| `PUT` | `/realms/{realm_name}/webhooks/{webhook_id}` | Update endpoint, headers, triggers, retry policy | +| `DELETE` | `/realms/{realm_name}/webhooks/{webhook_id}` | Delete a webhook | +| `GET` | `/realms/{realm_name}/webhooks/{webhook_id}/deliveries` | List deliveries | +| `GET` | `/realms/{realm_name}/webhooks/{webhook_id}/deliveries/{delivery_id}` | One delivery, with its payload | +| `POST` | `/realms/{realm_name}/webhooks/{webhook_id}/deliveries/{delivery_id}/retry` | Replay a delivery | +| `POST` | `/realms/{realm_name}/webhooks/{webhook_id}/secret/rotate` | Rotate the signing secret | + +**Required permissions:** `view_webhooks` or `manage_webhooks` to read, `manage_webhooks` to create, update, delete, replay or rotate. `manage_realm` grants all of them. Note that `query_webhooks` exists as a permission but is not what the read endpoints accept. + +## Debugging a webhook that is not arriving + +::::step-group +:::step{title="Check it is subscribed to anything"} +"Without event" on the list, or an empty `subscribers`, means it will never fire whatever you do next. +::: + +:::step{title="Look for a delivery at all"} +No delivery row means the event never matched a subscription. Check the [trigger name](/en/modules/webhooks/triggers) — they are exact strings, and `user.updated` is not `user.update`. +::: + +:::step{title="Read the last error"} +A `last_status_code` points at your endpoint. A `last_error_code` points at the path to it. The table above says which is which. +::: + +:::step{title="Fix, then replay"} +Do not wait for the next event to find out whether the fix worked. Replay the failed delivery: same payload, same signature contract, immediate answer. +::: +:::: + +::::card-group{cols=2} +:::card{label="Payload" icon="lucide:file-json" href="/en/modules/webhooks/payload"} +The envelope your endpoint receives, and how to verify its signature. +::: +:::card{label="Triggers" icon="lucide:webhook" href="/en/modules/webhooks/triggers"} +The 38 events a webhook can subscribe to. +::: +:::: diff --git a/apps/docs/src/content/docs/modules/default/en/webhooks/overview.mdx b/apps/docs/src/content/docs/modules/default/en/webhooks/overview.mdx index 55e5fb3..5be429f 100644 --- a/apps/docs/src/content/docs/modules/default/en/webhooks/overview.mdx +++ b/apps/docs/src/content/docs/modules/default/en/webhooks/overview.mdx @@ -70,3 +70,16 @@ On `user.updated` and `user.deleted`, sync changes to your CRM, data warehouse, Forward all events to Splunk, Elastic, or a custom SIEM for long-term storage and compliance reporting. ::: :::: + +## Then watch what actually happens + +Wiring a webhook is the easy half. [Deliveries & Retries](/en/modules/webhooks/deliveries) covers the other one: every attempt is recorded with the status code it got back, failures retry with exponential backoff and jitter, and a delivery that exhausted its attempts can be replayed by hand once you have fixed the endpoint. + +::::card-group{cols=2} +:::card{label="Deliveries & Retries" icon="lucide:repeat" href="/en/modules/webhooks/deliveries"} +Status codes, error codes, automatic backoff, manual replay, secret rotation. +::: +:::card{label="Triggers" icon="lucide:webhook" href="/en/modules/webhooks/triggers"} +The 38 events a webhook can subscribe to. +::: +:::: diff --git a/apps/docs/src/styles/globals.css b/apps/docs/src/styles/globals.css index 4df4813..a5cd856 100644 --- a/apps/docs/src/styles/globals.css +++ b/apps/docs/src/styles/globals.css @@ -112,3 +112,24 @@ pre .highlighted-word { .prose tbody td code { font-size: 0.8125rem; } + +/* Theme-paired screenshots. + Publish two files, `name-light.jpg` and `name-dark.jpg`, and mark the + tags with .only-light / .only-dark so the reader sees the capture matching + the theme they are reading in. The theme is a `dark` class on , so + these rules work without relying on Tailwind scanning MDX content. */ +.only-light { + display: block; +} + +.only-dark { + display: none; +} + +.dark .only-light { + display: none; +} + +.dark .only-dark { + display: block; +}