From 4b781f2d36f2af0e1bd0e3ca09eccb9f3f94f54a Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Sun, 6 Sep 2026 18:15:37 +0200 Subject: [PATCH 1/8] docs(cli): document realm roles, user roles and password commands ferris-ctl gained `realm role` (create/list/get/delete, realm- or client-scoped) and `user set-password`, `assign-role`, `remove-role` and `roles`. None of them were documented, so the reference silently described a smaller CLI than the one shipped, and `user create` still told readers to set passwords from the admin console. Also correct the import reference against ferriskey-cli-core: - the config blueprint accepts web origins, post-logout redirect URIs, the device authorization grant, PKCE and per-client token lifetimes; - a user role entry may be prefixed with a client id to pick a client role rather than a realm one; - permissions are snake_case (`manage_realm`). `Permissions::from_names` filters unknown names out silently, so `realm:manage` produced a role granting nothing. The example in the docs used exactly that form. - the Keycloak importer also reads each client's roles, which the "what gets imported" list omitted. --- .../docs/cli/default/en/commands/index.mdx | 10 +-- .../docs/cli/default/en/commands/realm.mdx | 67 ++++++++++++++++- .../docs/cli/default/en/commands/user.mdx | 70 +++++++++++++++++- .../cli/default/en/import/config-file.mdx | 72 ++++++++++++++++--- .../docs/cli/default/en/import/keycloak.mdx | 27 ++++--- 5 files changed, 217 insertions(+), 29 deletions(-) diff --git a/apps/docs/src/content/docs/cli/default/en/commands/index.mdx b/apps/docs/src/content/docs/cli/default/en/commands/index.mdx index 596d707..fa88199 100644 --- a/apps/docs/src/content/docs/cli/default/en/commands/index.mdx +++ b/apps/docs/src/content/docs/cli/default/en/commands/index.mdx @@ -8,7 +8,7 @@ order: 0 # Commands -`ferris-ctl` exposes seven top-level commands. Every one of them accepts the [global flags](/en/cli/overview#global-flags), and every one resolves where to connect, who you are, and which realm to act on before it sends anything — see [Authentication](/en/cli/authentication). +`ferris-ctl` has seven top-level commands. All of them accept the [global flags](/en/cli/overview#global-flags), and all of them resolve where to connect, who you are, and which realm to act on before sending anything. [Authentication](/en/cli/authentication) explains how. ## Connect and authenticate @@ -28,13 +28,13 @@ Drop the stored session by deleting the credentials file. ::::card-group{cols=3} :::card{label="realm" icon="lucide:layers" href="/en/cli/commands/realm"} -Create, inspect, and delete realms — and import one from an external source. +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"} Create, inspect, and delete OAuth2 clients within a realm. ::: :::card{label="user" icon="lucide:users" href="/en/cli/commands/user"} -Create, inspect, and delete users within a realm. +Create, inspect, and delete users, set their passwords, and assign roles. ::: :::: @@ -54,9 +54,9 @@ The import guide: from a description file, a live Keycloak, or a live Zitadel in | Command | Purpose | |---------|---------| | [`context`](/en/cli/commands/context) | Manage connection contexts | -| [`realm`](/en/cli/commands/realm) | Manage realms | +| [`realm`](/en/cli/commands/realm) | Manage realms, their roles, and realm imports | | [`client`](/en/cli/commands/client) | Manage OAuth2 clients | -| [`user`](/en/cli/commands/user) | Manage users | +| [`user`](/en/cli/commands/user) | Manage users, their passwords, and their role assignments | | [`source`](/en/cli/commands/source) | Manage reusable import sources | | [`login`](/en/cli/commands/login) | Sign in via the OAuth 2.0 Device Authorization Grant | | [`logout`](/en/cli/commands/logout) | Remove the stored login session | 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 15e790e..35a332f 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,13 +1,13 @@ --- title: realm -description: "Create, inspect, delete, and import realms." +description: "Create, inspect, delete, and import realms, and manage realm and client roles." icon: layers order: 2 --- # `realm` -Manage realms, the isolated tenants that contain clients, users, roles, and credentials. +Manage realms, the isolated tenants that hold clients, users, roles, and credentials. ```bash ferris-ctl realm @@ -66,6 +66,69 @@ ferris-ctl realm delete [--force] Without `--force`, the command refuses to run in a non-interactive shell rather than deleting silently. Pass `--force` (or `-f`) in CI and automation. ::: +## `realm role` + +Manage the roles of a realm, or the roles scoped to one of its clients. Every subcommand takes `--realm` to target a realm other than the context default, and `--client ` to work on that client's roles instead of the realm's. + +### `realm role create` + +```bash +ferris-ctl realm role create [--description ] [--permission ]... [--realm ] [--client ] +``` + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | yes | Role name | +| `--description` | no | Free-text description | +| `--permission` | no | A permission granted by the role. Repeat the flag for several | +| `--realm` | no | Realm (defaults to the context realm) | +| `--client` | no | Create a client role scoped to this client id, instead of a realm role | + +Permissions are passed by their snake_case name, for example `manage_users`. See the [permissions reference](/en/discover/core-concepts/roles#permissions-reference) for the full list. + +```bash title="A realm role that can manage users" +ferris-ctl realm role create user-admin \ + --description "Create and update users" \ + --permission manage_users \ + --permission query_users \ + --permission view_users +``` + +```bash title="A role scoped to one client" +ferris-ctl realm role create service-reader --client backend +``` + +:::callout{variant="warning" title="Unknown permission names are dropped"} +A permission name FerrisKey does not recognize is ignored rather than rejected, so a typo produces a role that grants nothing. Check the created role with `realm role get` after creating it. +::: + +### `realm role list` + +```bash +ferris-ctl realm role list [--realm ] [--client ] +``` + +Lists realm roles, or the roles of `--client` when given. Table columns: NAME, ID. + +### `realm role get` + +```bash +ferris-ctl realm role get [--realm ] [--client ] +``` + +### `realm role delete` + +```bash +ferris-ctl realm role delete [--realm ] [--client ] [--force] +``` + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | yes | Role name | +| `--realm` | no | Realm (defaults to the context realm) | +| `--client` | no | Delete a client role instead of a realm role | +| `--force` / `-f` | no | Skip the confirmation prompt (required in non-interactive shells) | + ## `realm import` Import a realm (its settings, roles, clients, and users) from a description file, a live Keycloak, or a live Zitadel instance. 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 77c6c74..677ce1e 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,13 +1,13 @@ --- title: user -description: "Create, inspect, and delete users within a realm." +description: "Create, inspect, and delete users, set passwords, and manage role assignments." icon: users order: 4 --- # `user` -Manage users within a realm. +Manage the users of a realm, their passwords, and the roles assigned to them. ```bash ferris-ctl user @@ -64,9 +64,73 @@ ferris-ctl user create alice \ ``` :::callout{variant="note" title="Setting a password"} -`user create` does not set credentials. Set the user's password from the FerrisKey admin console, or have the user complete a recovery flow. +`user create` does not set credentials. Give the new account a password with [`user set-password`](#user-set-password), or let the user go through a recovery flow. ::: +## `user set-password` + +Set a user's password. + +```bash +ferris-ctl user set-password (--password | --stdin) [--temporary] [--realm ] +``` + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | yes | Username | +| `--password` | one of the two | The new password, inline | +| `--stdin` | one of the two | Read the password from stdin, trailing newline trimmed | +| `--temporary` | no | Force the user to change this password at next login | +| `--realm` | no | Realm (defaults to context realm) | + +Exactly one of `--password` and `--stdin` must be given; passing both, or neither, is an error. + +```bash title="Prefer stdin" +printf '%s' "$NEW_PASSWORD" | ferris-ctl user set-password alice --stdin --temporary +``` + +:::callout{variant="warning" title="--password leaks into history"} +A value passed to `--password` ends up in your shell history and in the process list, where anyone on the machine can read it. Use `--stdin` outside of throwaway environments. +::: + +## `user assign-role` + +Assign a realm role, or a role of one client, to a user. + +```bash +ferris-ctl user assign-role [--client ] [--realm ] +``` + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | yes | Username | +| `` | yes | Role name | +| `--client` | no | Resolve the role among this client's roles instead of the realm's | +| `--realm` | no | Realm (defaults to context realm) | + +```bash +ferris-ctl user assign-role alice user-admin +ferris-ctl user assign-role alice service-reader --client backend +``` + +## `user remove-role` + +The inverse of `assign-role`, with the same arguments. + +```bash +ferris-ctl user remove-role [--client ] [--realm ] +``` + +## `user roles` + +List the roles assigned to a user. + +```bash +ferris-ctl user roles [--realm ] +``` + +Table columns: NAME, ID. + ## `user delete` Delete a user. Prompts for confirmation unless `--force` is passed. 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 eb6d8be..f1bbf49 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 @@ -7,7 +7,7 @@ order: 2 # Import from a file -`--from config` imports a realm from a FerrisKey-native description file. The format is detected from the extension: `.yaml`, `.yml`, or `.toml`. No external system or credentials are involved. +`--from config` imports a realm from a FerrisKey-native description file. The format comes from the extension: `.yaml`, `.yml`, or `.toml`. No external system and no credentials are involved. ```bash ferris-ctl realm import --from config --file realm.yaml @@ -21,7 +21,7 @@ ferris-ctl realm import --from config --file realm.yaml --dry-run -o yaml ## Format -A description has a `name` and optional `settings`, `roles`, `clients`, and `users` sections. +A description carries a `name`, plus optional `settings`, `roles`, `clients`, and `users` sections. The repository ships a working example at [`cli/examples/realm.yaml`](https://github.com/ferriskey/ferriskey/blob/main/cli/examples/realm.yaml). ```yaml title="realm.yaml" name: acme @@ -36,9 +36,13 @@ roles: - name: admin description: Realm administrators permissions: - - realm:manage + - manage_realm + - manage_users - name: viewer description: Read-only access + permissions: + - view_users + - view_clients clients: - client_id: web-app @@ -73,7 +77,7 @@ users: ## Settings -All settings are optional; omitted fields keep the FerrisKey default. +Every setting is optional. Anything you leave out keeps the FerrisKey default. | Field | Description | |-------|-------------| @@ -91,12 +95,60 @@ All settings are optional; omitted fields keep the FerrisKey default. | `email_verification_enabled` | Require email verification | | `email_verification_ttl_hours` | Email verification link TTL (hours) | -## Roles, clients, users - -- **roles:** `name`, optional `description`, optional `permissions` list. -- **clients:** `client_id`, optional `name`, `client_type` (`public` / `confidential` / `system`), `public_client`, `service_account_enabled`, `direct_access_grants_enabled`, `protocol`, `enabled`, `redirect_uris`, and per-client `roles`. -- **users:** `username`, optional `email`, `firstname`, `lastname`, `email_verified`, and a `roles` list referencing realm role names. +## Roles + +| Field | Default | Description | +|-------|---------|-------------| +| `name` | required | Role name | +| `description` | none | Free-text description | +| `permissions` | `[]` | Permission names, in snake_case | + +Permissions use the names from the [permissions reference](/en/discover/core-concepts/roles#permissions-reference), so `manage_realm`, not `ManageRealm` and not `realm:manage`. Unrecognized names are dropped silently when the role is created. + +## Clients + +| Field | Default | Description | +|-------|---------|-------------| +| `client_id` | required | Client identifier | +| `name` | none | Display name | +| `client_type` | `public` | `public`, `confidential`, or `system` | +| `protocol` | `openid-connect` | Protocol used by the client | +| `enabled` | `true` | Whether the client can be used | +| `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 | +| `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 | +| `require_pkce` | inherit | Require PKCE on the authorization code flow | +| `access_token_lifetime` | inherit | Override the realm access token TTL, in seconds | +| `refresh_token_lifetime` | inherit | Override the realm refresh token TTL | +| `id_token_lifetime` | inherit | Override the realm ID token TTL | +| `temporary_token_lifetime` | inherit | Override the realm temporary token TTL | +| `roles` | `[]` | Roles scoped to this client, same shape as realm roles | + +## Users + +| Field | Default | Description | +|-------|---------|-------------| +| `username` | required | Username | +| `email` | none | Email address | +| `firstname` | none | First name | +| `lastname` | none | Last name | +| `email_verified` | none | Mark the email as already verified | +| `roles` | `[]` | Roles to assign | + +A plain name in `roles` refers to a realm role. Prefix it with a client id to pick a role scoped to that client: + +```yaml +users: + - username: alice + roles: + - admin # realm role + - backend:service-reader # role of the "backend" client +``` :::callout{variant="note" title="Passwords are not in the blueprint"} -User credentials are never part of an import. Users are created without a password; set one afterward via the admin console or a recovery flow. +Credentials are never part of an import. Users come out without a password: set one afterwards with [`ferris-ctl user set-password`](/en/cli/commands/user#user-set-password), from the admin console, or through a recovery flow. ::: diff --git a/apps/docs/src/content/docs/cli/default/en/import/keycloak.mdx b/apps/docs/src/content/docs/cli/default/en/import/keycloak.mdx index 7db3dad..18d7dbf 100644 --- a/apps/docs/src/content/docs/cli/default/en/import/keycloak.mdx +++ b/apps/docs/src/content/docs/cli/default/en/import/keycloak.mdx @@ -27,10 +27,12 @@ ferris-ctl realm import --from keycloak \ ## Authentication -Choose one of: +Two options: -- **Client credentials:** pass `--source-client-id` and `--source-client-secret`. The CLI performs a client-credentials grant against Keycloak. -- **A ready token:** pass `--source-token` with an existing bearer token, if you already have one. +- Client credentials: pass `--source-client-id` and `--source-client-secret`, and the CLI runs a client-credentials grant against the source realm's token endpoint. +- A ready token: pass `--source-token` when you already have a bearer token in hand. + +When `--source-token` is set, the client id and secret are ignored. ```bash title="Using a stored source" ferris-ctl source add kc-prod --kind keycloak \ @@ -42,13 +44,20 @@ ferris-ctl realm import --source-ref kc-prod --target-realm acme ## What gets imported -The importer reads clients, realm roles, and users (paginated 100 per page) and maps them to a FerrisKey realm. +The importer reads, from `/admin/realms/{realm}`: + +- realm settings, mapped onto the FerrisKey realm settings it has an equivalent for +- clients, with their redirect URIs and flags +- the roles of each client +- realm roles +- users, paged 100 at a time + +:::callout{variant="warning" title="What does not come across"} +Keycloak never exports password hashes, so users arrive without credentials. -:::callout{variant="warning" title="Limitations"} -- **Passwords are never exported** by Keycloak, so users are recreated without credentials. -- In the current iteration, realm roles are created but **per-user role mappings are not imported**. +Role mappings per user are not imported either. The roles themselves are created, but who holds them is not carried over. -Review the imported realm and re-establish credentials and role assignments as needed. +Go through the imported realm afterwards and re-establish credentials and role assignments. ::: -Preview first with `--dry-run -o yaml` to see exactly what will be created. +Run `--dry-run -o yaml` first to see precisely what would be created. From e04368765c04917700492a17ebd490c5b9097c08 Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Sun, 6 Sep 2026 18:15:49 +0200 Subject: [PATCH 2/8] docs(discover): correct license, env vars and architecture against source Several statements no longer matched the ferriskey repository: - the project was described as MIT-licensed; LICENSE is Apache-2.0; - the TLS variables were `TLS_CERT_PATH` / `TLS_KEY_PATH`, which do not exist. The real names are `SERVER_TLS_CERT` / `SERVER_TLS_KEY`, and `SERVER_PUBLIC_URL`, `ACTIVE_OBSERVABILITY`, `OTLP_ENDPOINT`, `METRICS_ENDPOINT` and the `gen-api` subcommand were missing entirely; - the `libs/` listing predated the split into feature and api crates, and the domain module list was missing saml, organization, compass, password policy, portal theming and email; - the contributing guide told readers to clone ferriskey/ferriskey to work on the docs, which live in ferriskey/website; - realm settings were missing lockout, login aliases, require_mfa, passkeys, email verification, SeaWatch PII mode and template ids; - protocol mapper types were invented names; the real ones keep the Keycloak spelling (`oidc-usermodel-property-mapper` and friends); - token claims listed `realm_roles`/`permissions`, where the payload actually carries `azp`, `typ`, `sid` and `realm_access.roles`; - required actions were PascalCase and missing `configure_passkey`. Adds the standalone image, which serves the API and the console behind one nginx on port 8090 and runs its own migrations at startup. Prose is rewritten throughout: sentence-case headings (slugs are unchanged, so anchors still resolve), no em dashes, and the usual AI-writing tells removed. --- .../en/core-concepts/authentication.mdx | 64 +++++----- .../en/core-concepts/client-scopes.mdx | 96 +++++++------- .../default/en/core-concepts/clients.mdx | 34 ++--- .../default/en/core-concepts/credentials.mdx | 38 ++---- .../default/en/core-concepts/realms.mdx | 85 +++++++++---- .../default/en/core-concepts/roles.mdx | 87 +++++++------ .../default/en/core-concepts/tokens.mdx | 79 ++++++------ .../default/en/core-concepts/users.mdx | 86 +++++++------ .../discover/default/en/getting-started.mdx | 59 ++++++--- .../default/en/guides/application-sso.mdx | 34 ++--- .../default/en/guides/architecture.mdx | 120 +++++++++++------- .../default/en/guides/configuration.mdx | 75 +++++++---- .../default/en/guides/contributing.mdx | 38 +++--- .../docs/discover/default/en/guides/email.mdx | 44 +++---- .../discover/default/en/what-is-ferriskey.mdx | 29 +++-- 15 files changed, 553 insertions(+), 415 deletions(-) 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 0520402..6cd8a6e 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 @@ -7,9 +7,9 @@ order: 16 # Authentication -FerrisKey implements the OAuth 2.0 and OpenID Connect specifications. Authentication always produces tokens, the grant type determines how the user (or client) proves their identity. +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. -## OpenID Connect Discovery +## OpenID Connect discovery OpenID Connect applications need to know where the authorization server lives and which endpoints it exposes. FerrisKey publishes that information through the discovery endpoint: @@ -34,69 +34,69 @@ This endpoint returns a JSON document that OIDC clients can read automatically. | `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` | -Most applications use discovery so you do not have to paste every endpoint by hand. You give the application either the discovery endpoint or the issuer URL, and it learns the rest from FerrisKey. +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="info" title="Issuer and discovery are not always the same setting"} -If an application asks for the **issuer** or **authority**, use the realm URL, for example `https://sso.example.com/realms/home`. If it asks for the **discovery endpoint** or **OpenID configuration URL**, use the full `/.well-known/openid-configuration` URL. +:::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 +## Grant types -### Authorization Code +### Authorization code -The most secure flow for web applications. The user is redirected to FerrisKey, authenticates, and is sent back to the client with an authorization code that gets exchanged for tokens. +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. -**Flow:** +The steps: 1. Client redirects user to `/realms/{realm}/protocol/openid-connect/auth` 2. User authenticates (credentials, MFA if required) 3. FerrisKey redirects back with a `code` parameter 4. Client exchanges the code at the token endpoint (server-side) 5. FerrisKey returns access, refresh, and ID tokens -**Best for:** Web applications, SPAs with a backend. +Best for web applications and single-page apps with a backend. -### Password (Resource Owner) +### Password (resource owner) -The client collects credentials directly and sends them to the token endpoint. Simple but less secure, the client handles the user's password. +The client collects the credentials itself and posts them to the token endpoint. Simpler, and weaker: the client sees the user's password. -**Flow:** +The steps: 1. Client sends `grant_type=password`, `username`, `password` to the token endpoint 2. FerrisKey validates credentials 3. If MFA is required, returns a temporary token with `requires_otp_challenge` status 4. Client completes MFA challenge with the temporary token 5. FerrisKey returns full tokens -**Best for:** Trusted first-party applications, testing, CLI tools. +Best for trusted first-party applications, testing, and CLI tools. :::callout{variant="warning" title="Direct access grants required"} -The client must have `direct_access_grants_enabled` to use this flow. +The client needs `direct_access_grants_enabled` for this flow to work. ::: -### Client Credentials +### Client credentials -Machine-to-machine authentication. The client authenticates with its own credentials (client ID + secret), no user involved. +Machine to machine. The client authenticates with its own client id and secret, and no user is involved. -**Flow:** +The steps: 1. Client sends `grant_type=client_credentials`, `client_id`, `client_secret` 2. FerrisKey validates client credentials 3. Returns an access token (no refresh token, no ID token) -**Best for:** Backend services, cron jobs, microservice communication. +Best for backend services, cron jobs, and calls between microservices. -### Refresh Token +### Refresh token -Renew an expired access token without re-authentication. +Renew an expired access token without sending the user back through login. -**Flow:** +The steps: 1. Client sends `grant_type=refresh_token` with the refresh token 2. FerrisKey validates the refresh token 3. Returns new access and refresh tokens -**Best for:** Any flow that issued a refresh token and needs to maintain a session. +Best for any flow that issued a refresh token and needs to keep a session alive. -## Authentication Chain +## Authentication chain -When a user authenticates, FerrisKey follows a strict chain: +Every user authentication runs through the same chain: ```mermaid graph TD @@ -112,14 +112,14 @@ graph TD MFA -->|No| T ``` -1. **Credential Validation**: Username and password are verified -2. **Required Actions Check**: If the user has pending actions (ConfigureOtp, VerifyEmail, UpdatePassword), a temporary token is returned -3. **MFA Check**: If TOTP or WebAuthn is configured, the user must complete the challenge -4. **Token Issuance**: Full access, refresh, and ID tokens are generated +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. -## Auth Sessions +## Auth sessions -An **auth session** tracks the state of an in-progress authentication. It holds: +An auth session tracks a login in progress. It holds: - The client and realm context - The redirect URI and OAuth2 parameters (`state`, `nonce`, `scope`) @@ -127,4 +127,4 @@ An **auth session** tracks the state of an in-progress authentication. It holds: - WebAuthn challenge data (if applicable) - The linked Compass flow (if the flow engine is enabled) -Auth sessions are short-lived and expire automatically. +Auth sessions are short-lived and expire on their own. 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 6419c1d..3b43c67 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 @@ -7,57 +7,63 @@ order: 15 # Client Scopes -Client scopes control what information appears in tokens. A scope groups a set of **protocol mappers**: rules that extract user or client data and inject it as JWT claims. +A client scope decides what ends up inside a token. It groups protocol mappers, which are the rules that pull data off a user or a client and write it into the JWT as claims. -## Scope Types +## Scope types | Type | Behavior | |---|---| -| **Default** | Automatically included in every token issued for a client | -| **Optional** | Only included when explicitly requested in the `scope` parameter | -| **None** | Not assigned to any client by default | +| Default | Included in every token issued for the client | +| Optional | Included only when the client asks for it in the `scope` parameter | +| None | Not assigned to the client at all | -## Standard OIDC Scopes +The same scope can be default for one client and optional for another. That is decided by the client scope mapping, not by the scope itself. -FerrisKey includes the standard OpenID Connect scopes: +## Scopes seeded by default -| Scope | Claims | -|---|---| -| `openid` | `sub` (subject identifier) | -| `profile` | `name`, `family_name`, `given_name`, `preferred_username` | -| `email` | `email`, `email_verified` | -| `address` | `address` | -| `phone` | `phone_number`, `phone_number_verified` | -| `offline_access` | Enables refresh token issuance | -| `introspect` | Allows token introspection | - -## Protocol Mappers - -Protocol mappers define how data flows into tokens. Each mapper has a type that determines its behavior: - -| Mapper Type | Description | -|---|---| -| `user_attribute` | Maps a custom user attribute to a token claim | -| `user_property` | Maps a built-in user property (username, email, etc.) to a claim | -| `user_realm_role_mapper` | Includes the user's realm roles in the token | -| `user_client_role_mapper` | Includes the user's client-specific roles in the token | -| `audience_mapper` | Adds an audience (`aud`) value to the token | -| `hardcoded_claim_mapper` | Adds a static value as a claim | - -Each mapper is configured with a JSON object specifying the source property, target claim name, and claim type. +Every realm is seeded with a standard set of OIDC scopes, and every client in the realm gets them assigned. -## Client Scope Mapping +| Scope | Assigned as | Claims it produces | +|---|---|---| +| `openid` | default | none of its own; marks the request as OIDC | +| `profile` | default | `given_name`, `family_name`, `preferred_username` | +| `email` | default | `email`, `email_verified` | +| `roles` | default | `realm_access.roles` | +| `offline_access` | optional | none of its own | +| `phone` | optional | `phone_number` | +| `address` | optional | `address` | -Scopes are linked to clients through **client scope mappings**. Each mapping specifies: +## Protocol mappers -- Which client the scope applies to -- The scope type for that client (Default, Optional, or None) +A mapper is identified by a `mapper_type` string. FerrisKey keeps the Keycloak names, so configuration written for Keycloak is recognizable here. -This means the same scope can be a default for one client and optional for another. +| `mapper_type` | What it does | +|---|---| +| `oidc-usermodel-property-mapper` | Maps a built-in user property (username, email, first name) to a claim | +| `oidc-usermodel-attribute-mapper` | Maps a custom user attribute to a claim | +| `oidc-usermodel-realm-role-mapper` | Writes the user's realm roles into the token | +| `oidc-usermodel-client-role-mapper` | Writes the user's client roles into the token | +| `oidc-group-membership-mapper` | Writes the user's group memberships | +| `oidc-organization-membership-mapper` | Writes the user's organization memberships | +| `oidc-organization-detail-mapper` | Writes details of the active organization | +| `oidc-organization-role-mapper` | Writes the user's roles within an organization | +| `oidc-audience-mapper` | Adds a value to the `aud` claim | +| `oidc-hardcoded-claim-mapper` | Adds a fixed value as a claim | + +Each mapper carries a JSON `config`. The keys follow the Keycloak convention: + +```json title="Mapping the username into preferred_username" +{ + "user.attribute": "username", + "claim.name": "preferred_username", + "access.token.claim": "true", + "id.token.claim": "true" +} +``` -## How Scopes Shape Tokens +`claim.name` supports dotted paths, so `realm_access.roles` produces a nested object rather than a flat key with a dot in its name. -The token generation chain: +## How scopes shape a token ```mermaid graph LR @@ -68,12 +74,12 @@ graph LR J --> T[Sign Token] ``` -1. User authenticates to a client -2. FerrisKey resolves which scopes apply (default + requested optional scopes) -3. Protocol mappers from each active scope execute in order -4. Mapper outputs become JWT claims -5. The token is signed with the realm's signing key +1. The user authenticates against a client. +2. FerrisKey resolves which scopes apply: the client's default scopes, plus any optional ones the request asked for. +3. The mappers on each active scope run. +4. Their output becomes JWT claims, alongside the standard ones. +5. The token is signed with the realm's key. -:::callout{variant="info" title="Scope = claim contract"} -Think of a scope as a contract between the client and the authorization server: "If you grant me the `profile` scope, I expect to receive the user's name and username in the token." +:::callout{variant="info" title="A scope is a claim contract"} +Read a scope as an agreement between the client and the authorization server: grant me `profile`, and I expect the user's name and username to come back in the token. ::: 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 b2b245b..2c4ad72 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 @@ -7,23 +7,23 @@ order: 11 # Clients -A client represents an application that uses FerrisKey for authentication. Every OAuth2/OIDC flow starts with a client, it identifies which application is requesting access and determines what authentication methods and token configurations apply. +A client represents an application that authenticates through FerrisKey. Every OAuth2 and OIDC flow starts with one: it says which application is asking for access, and it determines which authentication methods and token settings apply. -## Client Types +## Client types -FerrisKey supports three client types: +There are three: -| Type | Secret | Use Case | +| Type | Secret | Use case | |---|---|---| -| **Confidential** | Yes | Server-side applications that can securely store a client secret | -| **Public** | No | Single-page applications (SPAs), mobile apps, or CLI tools | -| **System** | None | Internal FerrisKey clients (auto-created, not user-managed) | +| Confidential | Yes | Server-side applications that can keep a secret | +| Public | No | Single-page apps, mobile apps, CLI tools | +| System | None | Internal FerrisKey clients, created automatically and not user-managed | :::callout{variant="info" title="Choosing a client type"} -Use **Confidential** when your application has a backend that can keep the client secret safe. Use **Public** for browser-based or mobile applications where the secret would be exposed to the user. +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. ::: -## Client Properties +## Client properties | Property | Description | |---|---| @@ -35,9 +35,9 @@ Use **Confidential** when your application has a backend that can keep the clien | `direct_access_grants_enabled` | Allow the password grant type | | `service_account_enabled` | Enable client credentials grant | -## Token Lifetime Overrides +## Token lifetime overrides -By default, clients inherit token lifetimes from their realm. You can override these per-client for fine-grained control: +Clients inherit token lifetimes from their realm. Override them per client when one application needs a different window: | Override | Description | |---|---| @@ -46,14 +46,14 @@ By default, clients inherit token lifetimes from their realm. You can override t | `id_token_lifetime` | ID token TTL in seconds | | `temporary_token_lifetime` | Temporary token TTL in seconds | -When set, the client's value takes precedence over the realm default. When `null`, the realm default applies. +A value set on the client wins over the realm default. Leave it `null` and the realm default applies. -## Direct Access Grants +## Direct access grants -When enabled, a client can use the **Resource Owner Password Credentials** grant, sending username and password directly to the token endpoint. This is useful for trusted first-party applications and testing, but should not be used for third-party clients. +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. -## Service Accounts +## Service accounts -A client with `service_account_enabled` can authenticate using the **Client Credentials** grant, no user involved. FerrisKey creates a linked service account user for the client, which can be assigned roles and permissions just like a regular user. +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. -This is the standard pattern for machine-to-machine communication. +This is the usual pattern for machine to machine calls. 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 babc640..18eeec3 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,13 +7,13 @@ order: 13 # Credentials -A credential is proof of identity. FerrisKey supports multiple credential types that can be combined for multi-factor authentication. +A credential is proof of identity. A user can hold several at once, which is what makes multi-factor authentication possible. -## Credential Types +## Credential types ### Password -The most common credential type. Passwords are hashed using **Argon2**: a memory-hard algorithm designed to resist brute-force and GPU attacks. +Passwords are hashed with Argon2, a memory-hard algorithm chosen because it makes GPU-accelerated brute forcing expensive. Stored data: - `hash_iterations`. Argon2 iteration count @@ -22,24 +22,19 @@ Stored data: Passwords are never stored in plaintext and cannot be retrieved, only verified. -### TOTP (Time-based One-Time Password) +### TOTP -A shared secret used with authenticator apps (Google Authenticator, Authy, 1Password). Configuration includes: - -- **Algorithm**: Hash function (SHA-1, SHA-256, SHA-512) -- **Digits**: Code length (typically 6) -- **Period**: Time step in seconds (typically 30) -- **Issuer**: Display name in authenticator apps +A shared secret paired with an authenticator app such as Google Authenticator, Authy, or 1Password. The credential records the hash function (SHA-1, SHA-256, or SHA-512), the code length, usually 6 digits, the time step, usually 30 seconds, and the issuer name the app displays. TOTP credentials are managed by the [Trident](/en/modules/trident/overview) module. -### WebAuthn (FIDO2 Passkeys) +### WebAuthn passkeys -Hardware security keys and platform authenticators (Touch ID, Windows Hello, Android biometrics). WebAuthn credentials store the public key and credential metadata, the private key never leaves the user's device. +Hardware security keys and platform authenticators: Touch ID, Windows Hello, Android biometrics. FerrisKey stores the public key and the credential metadata. The private key never leaves the user's device. -### Recovery Codes +### Recovery codes -One-time backup codes generated when MFA is configured. Each code can be used exactly once to bypass the normal MFA challenge. Recovery codes are hashed before storage. +Backup codes generated when MFA is set up. Each one works exactly once in place of the normal MFA challenge, and they are hashed before storage like any other secret. ### Federated @@ -48,17 +43,12 @@ Credentials linked to an external identity provider (Google, GitHub, etc.) throu - `provider_id`. External provider identifier - `provider_type`. Provider type (OAuth2, OIDC) -## Temporary Credentials - -A credential can be marked as **temporary**. Temporary credentials (typically passwords) trigger the `UpdatePassword` required action, the user must set a new permanent password before gaining full access. +## Temporary credentials -## Credential Lifecycle +A credential can be flagged temporary. That is almost always a password, and it triggers the `update_password` required action: the user has to set a permanent one before getting full access. -Users can have multiple credentials of different types simultaneously. A typical setup might include: +## Credential lifecycle -1. One **password** credential -2. One **TOTP** credential (after MFA setup) -3. One set of **recovery codes** (generated with TOTP) -4. One or more **WebAuthn** passkeys +A user typically ends up with a password, a TOTP credential once MFA is set up, the set of recovery codes generated alongside it, and possibly one or more WebAuthn passkeys. -Each credential type can be added, updated, or removed independently through the admin console or user self-service endpoints. +Each can be added, replaced, or removed on its own, either from the admin console or through the user's self-service endpoints. 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 93a3a25..771ca13 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 @@ -9,7 +9,7 @@ order: 10 A realm is the top-level isolation boundary in FerrisKey. Users, clients, roles, credentials, scopes, and sessions each belong to exactly one realm. Realms make multi-tenancy possible: one FerrisKey deployment can serve many independent organizations. -## What a Realm Isolates +## What a realm isolates Each realm is a self-contained identity domain: @@ -21,7 +21,7 @@ Each realm is a self-contained identity domain: - **Sessions**: active user sessions - **Configuration**: token lifetimes, registration policies, feature toggles -There is no data leakage between realms. A user in realm A cannot authenticate to a client in realm B. +Nothing leaks between realms. A user in realm A cannot authenticate against a client in realm B. ```mermaid graph TD @@ -42,37 +42,74 @@ graph TD R3 --> C3[Staging Clients] ``` -## The Master Realm +## The master realm -Every FerrisKey deployment includes a **master** realm. This is a protected realm that: - -- Is created automatically on first boot -- Contains the initial admin user -- Cannot be deleted -- Manages cross-realm administration +Every deployment has a `master` realm. It is created on first boot, holds the initial admin user, cannot be deleted, and is where cross-realm administration happens. :::callout{variant="warning" title="Do not use master for applications"} -The master realm is for administration only. Create dedicated realms for your applications. +Keep `master` for administration. Give each application its own realm. ::: -## Realm Settings +## Realm settings + +Every realm carries its own settings. They control how sign-in behaves, how long tokens live, and which optional features are on. -Each realm has configurable settings that control authentication behavior: +### Sign-in and registration | Setting | Default | Description | |---|---|---| -| `default_signing_algorithm` | `RS256` | JWT signing algorithm | -| `user_registration_enabled` | `false` | Allow self-service user registration | -| `forgot_password_enabled` | `false` | Enable password reset flow | +| `user_registration_enabled` | `false` | Allow self-service registration | +| `forgot_password_enabled` | `false` | Enable the password reset flow | | `remember_me_enabled` | `false` | Support remember-me sessions | -| `magic_link_enabled` | `false` | Enable email-based magic link login | -| `magic_link_ttl` | `15` min | Magic link expiration time | -| `compass_enabled` | `true` | Enable the Compass authentication flow engine | -| `access_token_lifetime` | `300`s (5 min) | Default access token TTL | -| `refresh_token_lifetime` | `86400`s (24 hr) | Default refresh token TTL | -| `id_token_lifetime` | `300`s (5 min) | Default ID token TTL | -| `temporary_token_lifetime` | `300`s (5 min) | Temporary token TTL (for required actions) | +| `magic_link_enabled` | `false` | Enable magic link sign-in by email | +| `magic_link_ttl_minutes` | `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 | +| `edit_username_enabled` | `false` | Let users change their own username | +| `email_verification_enabled` | `false` | Require users to verify their email | +| `email_verification_ttl_hours` | `24` | How long a verification link stays valid | +| `compass_enabled` | `true` | Record authentication flows with Compass | + +### Token lifetimes + +| 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 | + +### Lockout + +After too many failed attempts, an account is locked for a fixed window. + +| Setting | Default | Description | +|---|---|---| +| `lockout_threshold` | `10` | Failed attempts before the account locks | +| `lockout_duration_seconds` | `900` | How long the lock lasts | + +### Audit privacy + +SeaWatch can strip or pseudonymise personal data before an event is stored. + +| Setting | Default | Description | +|---|---|---| +| `seawatch_pii_mode` | `off` | One of `off`, `mask`, `pseudonymise` | +| `seawatch_pseudo_key` | unset | HMAC key used when the mode is `pseudonymise` | + +### 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. -## SMTP Configuration +## SMTP configuration -Each realm can configure its own SMTP settings for email delivery (magic links, password resets, email verification). This allows different realms to use different mail providers or sender addresses. +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 13598c5..3176499 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 @@ -7,9 +7,9 @@ order: 14 # Roles & Permissions -FerrisKey uses a **bitwise permission system** where each permission is a single bit in a 64-bit integer. Roles are named bundles of permissions. Authorization checks reduce to fast bitwise AND operations. +Permissions in FerrisKey are bits in a 64-bit integer. A role is a named bundle of them, and an authorization check is a single bitwise AND. -## How It Works +## How it works Each permission maps to a unique power of two: @@ -28,58 +28,65 @@ fn has_permission(user_permissions: u64, required: u64) -> bool { } ``` -## Permissions Reference +## Permissions reference -### Manage Permissions +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 | Permission | Description | |---|---| -| `CreateClient` | Create new OAuth2 clients | -| `ManageAuthorization` | Manage authorization policies | -| `ManageClients` | Update and delete clients | -| `ManageEvents` | Manage audit event configuration | -| `ManageIdentityProviders` | Configure external identity providers | -| `ManageRealm` | Update realm settings | -| `ManageUsers` | Create, update, and delete users | -| `ManageRoles` | Create, update, and delete roles | -| `ManageWebhooks` | Configure webhook subscriptions | -| `ManageClientScopes` | Manage client scopes and protocol mappers | - -### Query Permissions +| `create_client` | Create new OAuth2 clients | +| `manage_authorization` | Manage authorization policies | +| `manage_clients` | Update and delete clients | +| `manage_events` | Manage audit event configuration | +| `manage_identity_providers` | Configure external identity providers | +| `manage_realm` | Update realm settings | +| `manage_users` | Create, update, and delete users | +| `manage_roles` | Create, update, and delete roles | +| `manage_webhooks` | Configure webhook subscriptions | +| `manage_client_scopes` | Manage client scopes and protocol mappers | +| `manage_email_templates` | Create, update, and delete email templates | + +### Query | Permission | Description | |---|---| -| `QueryClients` | List and search clients | -| `QueryGroups` | List and search groups | -| `QueryRealms` | List and search realms | -| `QueryUsers` | List and search users | -| `QueryWebhooks` | List and search webhooks | -| `QueryClientScopes` | List and search client scopes | +| `query_clients` | List and search clients | +| `query_groups` | List and search groups | +| `query_realms` | List and search realms | +| `query_users` | List and search users | +| `query_webhooks` | List and search webhooks | +| `query_client_scopes` | List and search client scopes | -### View Permissions +### View | Permission | Description | |---|---| -| `ViewAuthorization` | View authorization details | -| `ViewClients` | View client details | -| `ViewEvents` | View audit events | -| `ViewIdentityProviders` | View identity provider details | -| `ViewRealm` | View realm details | -| `ViewUsers` | View user details | -| `ViewRoles` | View role details | -| `ViewWebhooks` | View webhook details | -| `ViewClientScopes` | View client scope details | +| `view_authorization` | View authorization details | +| `view_clients` | View client details | +| `view_events` | View audit events | +| `view_identity_providers` | View identity provider details | +| `view_realm` | View realm details | +| `view_users` | View user details | +| `view_roles` | View role details | +| `view_webhooks` | View webhook details | +| `view_client_scopes` | View client scope details | +| `view_email_templates` | View email templates | + +## Role mappings + +Roles are attached to users, including service account users, through role mappings. A user's effective permissions are the bitwise OR of every role bitmask assigned to them. -## Role Mappings +Take a user with two roles: -Roles are assigned to users (or service account users) through role mappings. A user's effective permissions are the **union** (bitwise OR) of all assigned role bitmasks. +- Viewer, holding `view_users | view_clients` = `0b...10100` +- User Manager, holding `manage_users | query_users` = `0b...01010` -For example, if a user has two roles: -- **Viewer** with permissions `ViewUsers | ViewClients` = `0b...10100` -- **User Manager** with permissions `ManageUsers | QueryUsers` = `0b...01010` +The effective mask is `0b...11110`: they can view and manage users, view clients, and query users. -Their effective bitmask is `0b...11110`, they can view and manage users, view clients, and query users. +## Realm and client roles -## Realm-Scoped 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. -Roles are defined within a realm and apply to all clients in that realm. When a user authenticates, their roles are resolved and included in the access token as claims (via protocol mappers), allowing resource servers to make authorization decisions without calling back to FerrisKey. +When a user authenticates, their roles are resolved and written into the access token as claims through protocol mappers, so a resource server can authorize a request without calling back to FerrisKey. 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 d1419f7..8010de9 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 @@ -7,65 +7,68 @@ order: 17 # Tokens -FerrisKey issues JSON Web Tokens (JWTs) as the result of successful authentication. Each token type serves a specific purpose in the OAuth2/OIDC protocol. +A successful authentication produces JSON Web Tokens. Each type has one job in the OAuth2 and OIDC protocol. -## Token Types +## Token types -| Token | Purpose | Typical Lifetime | +| Token | Purpose | Default lifetime | |---|---|---| -| **Access Token** | Authorize API requests to resource servers | 300s (5 min) | -| **Refresh Token** | Obtain new access tokens without re-authentication | 86400s (24 hr) | -| **ID Token** | Convey user identity to the client (OIDC) | 300s (5 min) | -| **Temporary Token** | Authorize required action completion only | 300s (5 min) | +| Access token | Authorize API requests against a resource server | 300s | +| Refresh token | Get new access tokens without asking the user again | 86400s | +| ID token | Carry the user's identity to the client, per OIDC | 300s | +| Temporary token | Authorize the completion of required actions, nothing else | 300s | -## JWT Structure +## JWT structure -Every FerrisKey token is a signed JWT with three parts: header, payload, and signature. +Every token is a signed JWT: header, payload, signature. -### Standard Claims +### Standard claims | Claim | Description | |---|---| -| `sub` | Subject, the user's unique ID | -| `aud` | Audience, the client ID | -| `iss` | Issuer, the realm's token endpoint URL | +| `sub` | Subject, the user's id | +| `aud` | Audience, a list of client ids | +| `azp` | Authorized party, the client that requested the token | +| `iss` | Issuer, the realm URL | +| `typ` | Token type | | `exp` | Expiration timestamp | | `iat` | Issued-at timestamp | -| `nbf` | Not-before timestamp | -| `jti` | JWT ID, unique token identifier | +| `jti` | Unique token identifier | | `scope` | Space-separated list of granted scopes | +| `sid` | OIDC session id, the user session this token was issued against | -### Custom Claims +`sid` is absent for flows that establish no SSO session, `client_credentials` among them. Treat a missing `sid` as "not session-bound", not as an invalid token. -Protocol mappers (configured through [client scopes](/en/discover/core-concepts/client-scopes)) inject additional claims: +### Identity and mapper claims + +`preferred_username` and `email` are written directly when the matching scope is granted. Everything else comes from protocol mappers configured on [client scopes](/en/discover/core-concepts/client-scopes), and is flattened into the payload: | Claim | Source | |---|---| -| `realm_roles` | User's realm role names | -| `client_roles` | User's client-specific role names | -| `permissions` | Resolved permission bitmask | -| `preferred_username` | Username | -| `email` | Email address | -| `given_name` | First name | -| `family_name` | Last name | +| `preferred_username` | `profile` scope | +| `given_name`, `family_name` | `profile` scope | +| `email`, `email_verified` | `email` scope | +| `realm_access.roles` | `roles` scope, through `oidc-usermodel-realm-role-mapper` | + +Because mappers are configurable, the exact claim set depends on the scopes assigned to the client. -## Token Lifetimes +## Token lifetimes -Token lifetimes are configured at two levels: +Lifetimes come from two places: -1. **Realm defaults**: Apply to all clients in the realm -2. **Client overrides**: Take precedence when set +1. Realm defaults, which apply to every client in the realm. +2. Client overrides, which win when they are set. -| Token | Realm Default | Client Override | +| Token | Realm default | Client override | |---|---|---| -| Access Token | `access_token_lifetime` (300s) | `access_token_lifetime` | -| Refresh Token | `refresh_token_lifetime` (86400s) | `refresh_token_lifetime` | -| ID Token | `id_token_lifetime` (300s) | `id_token_lifetime` | -| Temporary Token | `temporary_token_lifetime` (300s) | `temporary_token_lifetime` | +| Access token | `access_token_lifetime_secs` (300s) | `access_token_lifetime` | +| Refresh token | `refresh_token_lifetime_secs` (86400s) | `refresh_token_lifetime` | +| ID token | `id_token_lifetime_secs` (300s) | `id_token_lifetime` | +| Temporary token | `temporary_token_lifetime_secs` (300s) | `temporary_token_lifetime` | -The resolution rule is simple: if the client defines an override, use it. Otherwise, fall back to the realm default. +The rule: if the client sets an override, use it, otherwise fall back to the realm. -## Token Generation Chain +## Token generation chain ```mermaid graph LR @@ -83,9 +86,9 @@ graph LR 5. The token is signed using the realm's signing key and algorithm 6. Access, refresh, and (optionally) ID tokens are returned -## Token Introspection +## Token introspection -Resource servers can validate tokens by calling the introspection endpoint: +A resource server validates a token by calling the introspection endpoint: ```bash curl -X POST http://localhost:3333/realms/{realm}/protocol/openid-connect/token/introspect \ @@ -95,4 +98,4 @@ curl -X POST http://localhost:3333/realms/{realm}/protocol/openid-connect/token/ -d "client_secret=my-secret" ``` -The response includes `active: true/false` and the full set of token claims when active. +The response carries `active: true` or `active: false`, plus the full claim set when the token is still live. 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 472af84..893bc34 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 @@ -9,11 +9,11 @@ order: 12 A user represents an identity within a [realm](/en/discover/core-concepts/realms). Users authenticate through [clients](/en/discover/core-concepts/clients), prove who they are with [credentials](/en/discover/core-concepts/credentials), receive [tokens](/en/discover/core-concepts/tokens), and are authorized through [role](/en/discover/core-concepts/roles) assignments. -Everything that happens at runtime — login, token issuance, authorization — ultimately resolves to a user. +Login, token issuance, authorization: at runtime it all resolves back to a user. -## Anatomy of a User +## Anatomy of a user -A user is a small record. The core fields describe who they are; everything else (credentials, roles, sessions) lives in related objects. +The user record itself is small. It describes who someone is; credentials, roles, and sessions live in related objects. | Property | Description | |---|---| @@ -46,9 +46,9 @@ A representative JSON payload returned by the admin API: Use `id` to reference a user from other resources (role assignments, audit logs, tokens). `username` is a human-facing handle and may change; `id` is immutable. ::: -## Realm Scoping +## Realm scoping -Users are fully scoped to their realm. The same email address can exist in multiple realms as completely independent accounts. There is no cross-realm user resolution — authentication always happens within a single realm context. +Users belong to exactly one realm. The same email address can exist in several realms as entirely separate accounts. There is no cross-realm lookup: authentication always happens inside one realm. ```mermaid graph LR @@ -61,11 +61,11 @@ graph LR U1 -. no shared state .- U2 ``` -If you need a single identity that spans multiple realms, federate it through the [Abyss](/en/modules/abyss/overview) module — each realm keeps its own user record, linked to the same external provider. +For one identity across several realms, federate it through the [Abyss](/en/modules/abyss/overview) module. Each realm still keeps its own user record, linked to the same external provider. -## User Lifecycle +## User lifecycle -From creation to deletion, a user moves through a small number of states. +A user moves through a small number of states between creation and deletion. ```mermaid stateDiagram-v2 @@ -78,20 +78,24 @@ stateDiagram-v2 Deleted --> [*] ``` -- **Provisioned** — The account exists but may have pending [required actions](#required-actions) (verify email, set a permanent password, configure MFA). Authentication produces only a temporary token until the user clears them. -- **Active** — Authentication produces full tokens. Roles and group memberships apply. -- **Disabled** — `enabled = false`. The account is preserved but cannot authenticate. Tokens already issued continue to be valid until they expire; revoke sessions if you need immediate cutoff. -- **Deleted** — The user record is removed. Sessions and refresh tokens linked to it are invalidated. +**Provisioned.** The account exists but may still have pending [required actions](#required-actions): verify an email, set a permanent password, enroll in MFA. Until those are cleared, authentication only produces a temporary token. -## Required Actions +**Active.** Authentication produces full tokens, and role assignments apply. -Required actions are tasks a user must complete before full authentication is granted. When a user has pending required actions, the authentication chain returns a **temporary token** instead of full access tokens. +**Disabled.** `enabled = false`. The account is kept but cannot authenticate. Tokens already issued stay valid until they expire, so revoke sessions when you need an immediate cutoff. + +**Deleted.** The record is removed, and the sessions and refresh tokens attached to it are invalidated. + +## Required actions + +A required action is something a user has to finish before full authentication is granted. While any are pending, the authentication chain hands back a temporary token instead of a full token set. | Action | When it is added | What the user must do | |---|---|---| -| `VerifyEmail` | Email changes, or realm policy requires verification | Click the link sent by the [Trident](/en/modules/trident/overview) module | -| `UpdatePassword` | Admin marks the credential as temporary, or password expires | Set a new password | -| `ConfigureOtp` | Realm requires MFA and the user has no TOTP credential yet | Enroll a TOTP authenticator | +| `verify_email` | The email changed, or realm policy requires verification | Click the link sent by the [Trident](/en/modules/trident/overview) module | +| `update_password` | An admin marked the credential temporary, or the password expired | Set a new password | +| `configure_otp` | The realm requires MFA and the user has no TOTP credential yet | Enroll a TOTP authenticator | +| `configure_passkey` | The realm requires a passkey and the user has none registered | Register a WebAuthn passkey | ### How they flow @@ -114,23 +118,23 @@ sequenceDiagram ``` :::callout{variant="info" title="Temporary tokens"} -A temporary token is a short-lived JWT that authorizes **only** the required-action completion endpoints. It cannot be used to access protected resources, call userinfo, or refresh into a full session. +A temporary token is a short-lived JWT that authorizes the required-action endpoints and nothing else. It cannot reach a protected resource, call userinfo, or refresh into a full session. ::: :::callout{variant="warning" title="Order matters"} -Required actions are evaluated in a fixed order. `VerifyEmail` typically runs first, then `UpdatePassword`, then `ConfigureOtp`. The client should always read the `required_actions` array rather than hard-coding the order. +Required actions are evaluated in a fixed order, usually `verify_email`, then `update_password`, then the MFA enrollment actions. Read the `required_actions` array in the response rather than hard-coding that order in a client. ::: -## Service Account Users +## Service account users -When a [client](/en/discover/core-concepts/clients) has `service_account_enabled`, FerrisKey automatically creates a linked service account user. This user is just a regular user record, with a few distinctions: +When a [client](/en/discover/core-concepts/clients) has `service_account_enabled`, FerrisKey creates a linked service account user. It is an ordinary user record with a few distinguishing traits: - `username` is derived from the client (`service-account-`). - `client_id` points back to the owning client. -- It has no password — it authenticates exclusively through the `client_credentials` grant. +- It has no password, and authenticates only through the `client_credentials` grant. - It can be assigned roles and permissions like any other user. -It exists so that machine-to-machine calls have a real subject for authorization decisions and audit logs. +It exists so machine to machine calls have a real subject for authorization decisions and audit logs. ### Example @@ -145,11 +149,11 @@ curl -X POST https://sso.example.com/realms/home/protocol/openid-connect/token \ The resulting access token has `sub` set to the service account user's `id`, and carries the `billing:read` role. The billing API can authorize the call exactly as it would for a human user. -See [Authentication → Client Credentials](/en/discover/core-concepts/authentication#client-credentials) for the full grant. +See [Authentication, client credentials](/en/discover/core-concepts/authentication#client-credentials) for the full grant. ## Sessions -A user can have multiple concurrent sessions — one per browser, device, or service that holds a valid refresh token. +A user can hold several sessions at once, one per browser, device, or service holding a valid refresh token. | Concept | Lifetime | What it represents | |---|---|---| @@ -157,21 +161,25 @@ A user can have multiple concurrent sessions — one per browser, device, or ser | **User session** | Hours to days | An authenticated session. Backed by a refresh token. | | **Access token** | Minutes | Short-lived bearer token derived from a user session. | -Revoking a refresh token ends the user session immediately; access tokens issued from it cannot be refreshed and will simply expire. For full details see [Tokens](/en/discover/core-concepts/tokens) and [Authentication → Auth Sessions](/en/discover/core-concepts/authentication#auth-sessions). +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. + +## 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. + +**Force a password rotation** by marking the password credential temporary. The next login triggers the `update_password` required action. + +**Force MFA enrollment** by setting `require_mfa` on the realm. Existing users without a TOTP credential get `configure_otp` on their next login. -## Security Considerations +**Revoke active sessions.** Disabling a user leaves already-issued access tokens working. Revoke the refresh tokens to cut access straight away. -- **Disable, don't delete** — Disabling preserves audit history and role assignments. Delete only when you are sure the identity will never return. -- **Force a password rotation** — Mark the password credential as temporary; the next login will trigger the `UpdatePassword` required action. -- **Force MFA enrollment** — Configure the realm to require MFA. Existing users without TOTP will receive `ConfigureOtp` on next login. -- **Revoke active sessions** — Disabling a user does not invalidate already-issued access tokens. Revoke the user's refresh tokens to cut access immediately. -- **Audit identifiers** — Reference users by `id` in logs and external systems. Usernames and emails can change; `id` cannot. +**Reference users by `id`** in logs and external systems. Usernames and emails change; the id does not. -## Related Concepts +## Related concepts -- [Realms](/en/discover/core-concepts/realms) — the boundary a user lives in. -- [Clients](/en/discover/core-concepts/clients) — what users authenticate through. -- [Credentials](/en/discover/core-concepts/credentials) — how users prove identity. -- [Roles](/en/discover/core-concepts/roles) — how users get authorized. -- [Tokens](/en/discover/core-concepts/tokens) — what users receive after login. -- [Authentication](/en/discover/core-concepts/authentication) — the full chain that ties it together. +- [Realms](/en/discover/core-concepts/realms): the boundary a user lives in. +- [Clients](/en/discover/core-concepts/clients): what users authenticate through. +- [Credentials](/en/discover/core-concepts/credentials): how users prove identity. +- [Roles](/en/discover/core-concepts/roles): how users get authorized. +- [Tokens](/en/discover/core-concepts/tokens): what users receive after login. +- [Authentication](/en/discover/core-concepts/authentication): the chain that ties it together. 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 a4ed028..af6d470 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 @@ -7,7 +7,7 @@ order: 2 # Getting Started -This guide gets you to a working FerrisKey instance with your first authenticated user. You will start the Docker stack, create a realm, register a client, create a user, and request tokens. +This guide takes you from nothing to a working FerrisKey instance with an authenticated user. You will start the Docker stack, create a realm, register a client, create a user, and request tokens. ## Prerequisites @@ -46,7 +46,7 @@ Open `http://localhost:5555` in your browser and sign in with the default creden - **Username:** `admin` - **Password:** `admin` -You'll land in the **master** realm, the built-in realm that manages all other realms. +You land in the `master` realm, the built-in realm that manages all the others. ::: :::: @@ -54,9 +54,9 @@ You'll land in the **master** realm, the built-in realm that manages all other r The default admin credentials are for local development only. Change them immediately in any non-local environment by setting the `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `ADMIN_EMAIL` environment variables. ::: -## Deploy Without Cloning the Repository +## Deploy without cloning the repository -The quickstart above clones the repository, but you don't need the source code to run FerrisKey. The API, web app, and database migrations all ship as published images on `ghcr.io/ferriskey`. To deploy on a server, save the following as `docker-compose.yml` and start it directly — no checkout required: +The quickstart above clones the repository, but you don't need the source to run FerrisKey. The API, web app, and database migrations all ship as published images on `ghcr.io/ferriskey`. To deploy on a server, save the following as `docker-compose.yml` and start it directly, with no checkout involved: ```yaml title="docker-compose.yml" services: @@ -122,12 +122,39 @@ 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:vX.Y.Z` — 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.7.0`) on both the `api` and `db-migrations` services, so they always run the same binary and schema. ::: +## Run everything in one container + +There is a third image, `ghcr.io/ferriskey/ferriskey-standalone`, that bundles the API and the web app behind a single nginx process. It is the quickest way to get an instance up on one host, and it is what the repository's `standalone` profile starts: + +```bash +docker compose --profile standalone-registry up -d +``` + +Everything is served from `http://localhost:8090`, with the API mounted under `/api`: + +```yaml title="Standalone service" +standalone: + image: ghcr.io/ferriskey/ferriskey-standalone + environment: + DATABASE_HOST: db + DATABASE_NAME: ferriskey + DATABASE_USER: ferriskey + DATABASE_PASSWORD: ferriskey + SERVER_ROOT_PATH: /api + ALLOWED_ORIGINS: http://localhost:8090 + WEBAPP_URL: http://localhost:8090 + ports: + - 8090:80 +``` + +The standalone image runs its own migrations at startup, so there is no separate migration service. It still needs an external PostgreSQL. + ## Upgrading FerrisKey -Schema changes ship inside the API image, so upgrading a running stack is "pull, migrate, restart": +Schema changes ship inside the API image, so upgrading a running stack means pull, migrate, restart. ::::step-group :::step{title="Pull the new images"} @@ -137,7 +164,7 @@ docker compose pull ::: :::step{title="Apply new migrations"} -The `db-migrations` service runs `sqlx migrate run`, which only applies migrations that have not run yet — so it is safe to run on every upgrade: +The `db-migrations` service runs `sqlx migrate run`, which only applies migrations that have not run yet, so it is safe on every upgrade: ```bash docker compose up -d db-migrations @@ -154,10 +181,10 @@ docker compose up -d api webapp :::: :::callout{variant="warning" title="Keep api and migrations on the same tag"} -Always pull the `api` and `db-migrations` services from the **same image tag**. Running a newer API against an older schema — or an older API against a newer schema — can fail at startup. +Always pull the `api` and `db-migrations` services from the same image tag. A newer API against an older schema, or an older API against a newer one, can fail at startup. ::: -## Create Your First Realm +## Create your first realm A realm is an isolated tenant. Users, clients, roles, and credentials all live inside a realm. @@ -171,7 +198,7 @@ Use the realm selector in the sidebar to switch to `my-app`. All subsequent oper ::: :::: -## Register a Client +## Register a client A client represents an application that uses FerrisKey for authentication. @@ -191,7 +218,7 @@ If you want to test authentication directly via the API (password grant), enable ::: :::: -## Create a User +## Create a user ::::step-group :::step{title="Create a user"} @@ -223,15 +250,15 @@ curl -X POST http://localhost:3333/realms/my-app/protocol/openid-connect/token \ -d "password=your-password" ``` -The response contains an `access_token`, `refresh_token`, and optionally an `id_token`. That means the user authenticated successfully. +A response containing an `access_token`, a `refresh_token`, and optionally an `id_token` means the user authenticated. -## Connect a Real Application +## Connect a real application -The password grant above is useful for a quick API test. For real applications, use browser-based SSO with the OpenID Connect authorization code flow. +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. -If you want to connect a dashboard, internal service, or self-hosted tool, follow the application SSO guide next. It shows which dashboard screens to use, what values to copy, and how to debug common setup mistakes. +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. -## What's Next? +## What's next? ::::card-group{cols=2} :::card{label="Core Concepts" icon="lucide:layers" href="/en/discover/core-concepts/realms"} 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 368882a..ad349ce 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 @@ -7,9 +7,9 @@ order: 19 # Connect an Application with SSO -Use this guide when an application supports OpenID Connect and you want FerrisKey to handle sign-in. +Use this guide when an application speaks OpenID Connect and you want FerrisKey to handle sign-in. -By the end, you will have: +By the end you will have: - One FerrisKey realm dedicated to the application or organization - One OpenID Connect client @@ -17,7 +17,7 @@ By the end, you will have: - The redirect URI registered in FerrisKey - The issuer URL and discovery endpoint your application needs -## Before You Start +## Before you start Before opening FerrisKey, find these values in the application you want to connect: @@ -31,7 +31,7 @@ Before opening FerrisKey, find these values in the application you want to conne The redirect URI must match what the application sends during login. If the application says the callback is `/identity/connect/oidc-signin`, do not replace it with the application homepage. ::: -## Example Values +## Example values The walkthrough uses these values. Replace them with your own domains and realm name. @@ -44,7 +44,7 @@ The walkthrough uses these values. Replace them with your own domains and realm | Redirect URI | `https://vault.example.com/identity/connect/oidc-signin` | | Scopes | `openid profile email` | -## Create or Select the Realm +## Create or select the realm A realm keeps users, clients, roles, and scopes together. Avoid using `master` for applications. @@ -60,7 +60,7 @@ After switching, check the realm indicator before creating the client. The clien ::: :::: -## Create the Client +## Create the client The client is the application entry in FerrisKey. It tells FerrisKey which app is asking users to sign in. @@ -85,7 +85,7 @@ Enable **Client Authentication** for server-side applications. FerrisKey will tr ::: :::: -## Configure the Redirect URI +## Configure the redirect URI The redirect URI is where FerrisKey sends the browser after a successful login. @@ -109,7 +109,7 @@ Keep **Direct Access Grants** disabled unless the application explicitly asks fo ::: :::: -## Copy the Client Secret +## Copy the client secret ::::step-group :::step{title="Open Credentials"} @@ -127,7 +127,7 @@ Copy the **Client Secret** and put it in the application's OIDC configuration. T If a client secret is pasted into a public issue, log, screenshot, or repository, rotate it and update the application immediately. ::: -## Check the Client Scopes +## Check the client scopes Most OIDC applications need `openid`, `profile`, and `email`. @@ -149,7 +149,7 @@ If the application needs roles, also assign `roles`. ::: :::: -## Configure the Application +## Configure the application Paste these values into the application. @@ -162,9 +162,9 @@ Paste these values into the application. | Scopes | `openid profile email` | | Redirect URI | `https://vault.example.com/identity/connect/oidc-signin` | -### What the Discovery Endpoint Does +### What the discovery endpoint does -The discovery endpoint is the URL an OpenID Connect application uses to learn how to talk to FerrisKey. +The discovery endpoint is where an OpenID Connect application learns how to talk to FerrisKey. ```text https://sso.example.com/realms/home/.well-known/openid-configuration @@ -178,13 +178,13 @@ When the application opens that URL, FerrisKey returns a JSON document with the - Where to fetch the public keys used to verify token signatures - Which scopes and response types are supported -This is why many applications only ask for an **issuer**, **authority**, or **discovery URL** instead of asking you to configure every endpoint manually. +That is why so many applications only ask for an issuer, an authority, or a discovery URL, instead of making you fill in every endpoint by hand. -:::callout{variant="info" title="Authority vs discovery endpoint"} -Some applications ask for the issuer or authority URL. Others ask directly for the discovery endpoint. If the application asks for the authority, usually use the realm URL without `/.well-known/openid-configuration`. +:::callout{variant="info" title="Authority or discovery endpoint"} +Some applications ask for the issuer or authority URL, others for the discovery endpoint. When it asks for the authority, give it the realm URL without the `/.well-known/openid-configuration` suffix. ::: -## Test the Login +## Test the login 1. Open the application in a private browser window. 2. Click the SSO or OpenID Connect login button. @@ -192,7 +192,7 @@ Some applications ask for the issuer or authority URL. Others ask directly for t 4. Sign in with a user from the same realm. 5. Confirm that FerrisKey redirects back to the application. -If the user can sign in but the application says the account is not allowed, check the application's own signup, domain, group, or role rules. FerrisKey has authenticated the user, but the application can still decide whether that user may enter. +If the user signs in but the application says the account is not allowed, look at the application's own signup, domain, group, or role rules. FerrisKey authenticated the user; the application still gets to decide whether that user is welcome. ## Troubleshooting 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 4c7efb7..c9125ec 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 @@ -7,7 +7,7 @@ order: 22 # Architecture -FerrisKey follows a **hexagonal architecture** (ports & adapters) that cleanly separates business logic from infrastructure. This design makes the system testable, modular, and resilient to change. +FerrisKey follows a hexagonal architecture, also called ports and adapters. Business logic sits in the middle and knows nothing about HTTP, SQL, or SMTP. Everything else plugs into it. That is what keeps the domain testable and lets infrastructure change without rewriting rules. ## Layers @@ -28,73 +28,101 @@ graph TD APP --> INF ``` -### Domain Layer +### Domain layer -The core of FerrisKey. Contains pure business logic with no dependencies on frameworks, databases, or HTTP. Each domain module defines: +Pure business logic, with no dependency on a framework, a database, or a transport. Each domain module is laid out the same way: -- **Entities**: Immutable value objects representing domain concepts -- **Ports**: Trait definitions (interfaces) that declare what the domain needs -- **Services**: Business logic that operates on entities through ports -- **Value Objects**: DTOs for use cases and data transfer -- **Policies**: Authorization rules +- `entities.rs` for the immutable value objects that represent domain concepts +- `ports.rs` for the traits the domain needs someone else to implement +- `services.rs` for the logic that operates on entities through those ports +- `value_objects.rs` for the use case inputs and outputs +- `policies.rs` for authorization rules, where a module has them -### Application Layer +### Application layer -Orchestrates domain services. The `ApplicationService` struct wires together all domain services with their concrete implementations through dependency injection. +Orchestration. `ApplicationService` wires every domain service to a concrete implementation through dependency injection, and is the single place where the object graph is assembled. -### Infrastructure Layer +### Infrastructure layer -Implements the ports defined by the domain: +The implementations behind the ports: repositories backed by SeaORM and PostgreSQL, plus the outbound integrations for SMTP, webhook delivery, and identity provider calls. -- **Repositories**: Database access via SeaORM (PostgreSQL) -- **External services**: SMTP, webhook delivery, identity provider communication +### API layer -### API Layer +The HTTP surface, built with Axum. Each feature mirrors a domain module and brings its own router, handlers, validators, and error types. OpenAPI documentation is generated from `utoipa` attributes on the handlers and served at `/swagger-ui`, `/redoc`, `/rapidoc`, and `/scalar`. -HTTP interface built with Axum. Each feature mirrors a domain module with its own router, handlers, validators, and error types. OpenAPI documentation is generated automatically via `utoipa`. - -## Domain Modules +## Domain modules ``` core/src/domain/ -├── authentication/ # OAuth2/OIDC flows -├── user/ # User lifecycle -├── client/ # OAuth2 clients -├── realm/ # Multi-tenant realms -├── credential/ # Password, OTP, WebAuthn -├── role/ # Bitwise permissions -├── session/ # User sessions -├── jwt/ # Token generation & validation -├── seawatch/ # Audit logging -├── abyss/ # Identity provider federation -└── webhook/ # Event-driven hooks +├── authentication/ # OAuth2 / OIDC flows +├── user/ # User lifecycle and required actions +├── account/ # Self-service account operations +├── client/ # OAuth2 clients +├── realm/ # Multi-tenant realms and settings +├── credential/ # Passwords, OTP, WebAuthn +├── password_policy/ # Password strength rules +├── role/ # Bitwise permissions +├── session/ # User and auth sessions +├── jwt/ # Token issuance and validation +├── crypto/ # Key material and signing +├── saml/ # SAML 2.0 identity provider +├── trident/ # MFA +├── seawatch/ # Audit logging +├── compass/ # Authentication flow recording +├── abyss/ # Identity provider federation +├── aegis/ # Client scopes and protocol mappers +├── organization/ # B2B tenancy +├── email_template/ # Transactional email templates +├── email_verification/ # Email verification flow +├── portal_theme/ # Login portal theming +├── portal_layouts/ # Login portal layouts +├── webhook/ # Event-driven hooks +├── health/ # Liveness and readiness +└── maintenance/ # Maintenance mode ``` -Standalone library crates extend the core: +## Workspace crates + +The workspace is being pulled apart into focused crates. Two families live under `libs/`. + +Feature crates hold domain and infrastructure logic for one module: ``` libs/ -├── ferriskey-domain/ # Shared domain types (Realm, Client, User, Token) -├── ferriskey-trident/ # MFA (TOTP, WebAuthn, magic links, recovery codes) -├── ferriskey-compass/ # Authentication flow engine -└── ferriskey-aegis/ # Client scopes & protocol mappers +├── ferriskey-domain/ # Shared domain types +├── ferriskey-security/ # Hashing and crypto primitives +├── ferriskey-trident/ # MFA +├── ferriskey-abyss/ # Identity provider federation +├── ferriskey-aegis/ # Scopes and protocol mappers +├── ferriskey-compass/ # Authentication flow engine +├── ferriskey-saml/ # SAML 2.0 +├── ferriskey-seawatch/ # Audit events +├── ferriskey-organization/ # Organizations +├── ferriskey-password-policy/ # Password policies +├── ferriskey-portal-theme/ # Portal theming +├── ferriskey-portal-layouts/ # Portal layouts +├── ferriskey-webhook/ # Webhook delivery +├── ferriskey-mail/ # SMTP transport +└── ferriskey-migrate/ # Migration runner ``` -## Dependency Flow +`ferriskey-api-*` crates carry the HTTP layer for the matching feature, so a module's routes and handlers can move out of the monolithic API crate one at a time. `ferriskey-api-core` holds the shared pieces: CLI arguments, application state, and error conversion. + +## Dependency flow -The dependency rule is strict: **inner layers never depend on outer layers**. +The rule is strict: inner layers never depend on outer layers. -- Domain depends on nothing -- Application depends on domain (uses ports/traits) -- Infrastructure implements domain ports -- API depends on application +- The domain depends on nothing +- The application depends on the domain, through ports +- The infrastructure implements the domain's ports +- The API depends on the application -This means you can swap PostgreSQL for another database, replace Axum with a different HTTP framework, or test domain logic entirely in memory, without touching business rules. +Which means you can swap PostgreSQL for another store, replace Axum with a different HTTP framework, or run domain tests entirely in memory, without touching a business rule. -## Error Propagation +## Error propagation -Errors flow outward through automatic conversions: +Errors travel outward through automatic conversions: -1. **`CoreError`**: Domain-level errors (not found, validation, conflict) -2. **`ApiError`**: HTTP response format with status codes -3. **`From for ApiError`**: Automatic conversion at the boundary +1. `CoreError` for domain failures: not found, validation, conflict. +2. `ApiError` for the HTTP response, with a status code attached. +3. `From for ApiError` at the boundary, so handlers can use `?` and get the right status for free. 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 5547dd3..4817c2b 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 @@ -7,12 +7,16 @@ order: 20 # Configuration -FerrisKey is configured through environment variables. Every setting has a sensible default for local development, override only what you need. +The FerrisKey API is configured entirely through command-line flags, each of which has a matching environment variable. Every setting has a default that works for local development, so you only override what your deployment needs. -## Environment Variables +Run `ferriskey-api --help` to see the flags and their long descriptions straight from the binary. + +## Environment variables ### Admin +The admin account is created on first boot in the `master` realm. + | Variable | Default | Description | |---|---|---| | `ADMIN_USERNAME` | `admin` | Initial admin username | @@ -36,29 +40,57 @@ FerrisKey is configured through environment variables. Every setting has a sensi |---|---|---| | `SERVER_HOST` | `0.0.0.0` | Bind address | | `SERVER_PORT` | `3333` | HTTP port | -| `SERVER_ROOT_PATH` | None | URL path prefix (for reverse proxies) | -| `ALLOWED_ORIGINS` | None | Comma-separated CORS origins | -| `WEBAPP_URL` | None | Frontend URL (used for redirects) | +| `SERVER_ROOT_PATH` | empty | URL path prefix, for deployments behind a reverse proxy. A leading `/` is added if you omit it | +| `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 | + +`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. + +:::callout{variant="warning" title="SERVER_PUBLIC_URL and SAML"} +Leave `SERVER_PUBLIC_URL` unset and FerrisKey derives the public origin from each request's `Host` header, which is the historical behaviour. SAML needs it set: the entity id is signed into every assertion, so it must not change when a request arrives through a different hostname. +::: ### TLS +Both variables are required together. Set neither to serve plain HTTP and terminate TLS at your proxy. + | Variable | Default | Description | |---|---|---| -| `TLS_CERT_PATH` | None | Path to TLS certificate | -| `TLS_KEY_PATH` | None | Path to TLS private key | +| `SERVER_TLS_CERT` | unset | Path to the certificate file, in PEM format | +| `SERVER_TLS_KEY` | unset | Path to the private key file, in PEM format | ### Logging | Variable | Default | Description | |---|---|---| -| `LOG_FILTER` | `info` | Log level filter (trace, debug, info, warn, error) | -| `LOG_JSON` | `false` | Output logs in JSON format | +| `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 | -## Deployment Examples +### Observability -### Docker Compose +| Variable | Default | Description | +|---|---|---| +| `ACTIVE_OBSERVABILITY` | `false` | Turn on tracing and metrics export | +| `OTLP_ENDPOINT` | unset | OTLP collector endpoint for traces | +| `METRICS_ENDPOINT` | unset | Collector endpoint for metrics | + +Prometheus metrics are also exposed directly on `/metrics`, with no collector needed. -Configure FerrisKey by setting environment variables in your `docker-compose.yaml`: +## Generating the OpenAPI spec + +The API binary carries a subcommand that prints its OpenAPI document without touching the database: + +```bash +ferriskey-api gen-api --output openapi.json +``` + +Omit `--output` to write the spec to stdout. + +## Deployment examples + +### Docker Compose ```yaml title="docker-compose.override.yaml" services: @@ -73,12 +105,13 @@ services: - ADMIN_PASSWORD=a-strong-admin-password - ADMIN_EMAIL=admin@yourorg.com - ALLOWED_ORIGINS=https://iam.yourorg.com + - WEBAPP_URL=https://iam.yourorg.com - LOG_FILTER=info ``` -### Bare Metal +### Bare metal -Set environment variables directly or use a `.env` file alongside the binary: +Set the variables directly, or drop a `.env` file next to the binary: ```bash title=".env" DATABASE_HOST=localhost @@ -90,25 +123,21 @@ ADMIN_USERNAME=admin ADMIN_PASSWORD=a-strong-admin-password ADMIN_EMAIL=admin@yourorg.com ALLOWED_ORIGINS=https://iam.yourorg.com +WEBAPP_URL=https://iam.yourorg.com SERVER_PORT=3333 LOG_FILTER=info ``` -Then run the API server: +Then run the server: ```bash ./ferriskey-api ``` -## SMTP Configuration - -Email delivery (magic links, password reset, email verification) is configured per-realm through the admin console. Navigate to **Realm Settings → Email** and configure: +## SMTP configuration -- **SMTP Host** and **Port** -- **From address** -- **Authentication credentials** (if required) -- **TLS/STARTTLS** settings +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. :::callout{variant="info" title="No global SMTP"} -SMTP is configured at the realm level, not globally. Each realm can use a different mail provider. +Every realm carries its own SMTP configuration and can point at a different mail provider. The [Email & Templates guide](/en/discover/guides/email) has the full field reference and the API endpoints. ::: 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 c8e29a4..8275310 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 @@ -7,11 +7,11 @@ order: 23 # Contributing -FerrisKey is open source and welcomes contributions of every size: bug reports, documentation fixes, examples, tests, design feedback, and code changes. +FerrisKey is open source and takes contributions of every size: bug reports, documentation fixes, examples, tests, design feedback, and code changes. -The main rule is simple: **keep pull requests small and reviewable**. A contribution should be easy to understand, test, and merge without forcing maintainers to review unrelated layers at the same time. +One rule matters more than the rest: keep pull requests small and reviewable. A contribution should be easy to understand, test, and merge, without forcing a maintainer to review four unrelated layers in one sitting. -## Before You Start +## Before you start - Check the [GitHub repository](https://github.com/ferriskey/ferriskey) for existing issues or pull requests related to your change. - Open an issue first for large features, architecture changes, security-sensitive behavior, or breaking changes. @@ -22,7 +22,7 @@ The main rule is simple: **keep pull requests small and reviewable**. A contribu Do not report vulnerabilities in public issues. Use the repository's private security reporting flow when available, or contact the maintainers before sharing exploit details. ::: -## Contribution Process +## Contribution process Every pull request must have a reduced scope. Avoid submitting one large PR that changes every layer of the product at once. @@ -50,7 +50,7 @@ Each PR should stand on its own when possible. If a PR depends on a previous one Maintainers may ask you to split a pull request before reviewing it. This is not about blocking the contribution; it keeps review quality high and reduces the chance of regressions. ::: -## How to Split a Change +## How to split a change Use these rules when deciding what belongs in the same PR: @@ -70,13 +70,13 @@ Good PR descriptions include: - How the change was tested. - Screenshots or recordings for visible UI changes. -## Set Up the Documentation Website +## Set up the documentation website -The documentation, website, and blog live in a pnpm workspace. +The documentation, marketing site, and blog live in their own repository, [`ferriskey/website`](https://github.com/ferriskey/website), as a pnpm workspace driven by Turborepo. The Rust code stays in [`ferriskey/ferriskey`](https://github.com/ferriskey/ferriskey). ```bash title="Clone and install" -git clone https://github.com/ferriskey/ferriskey.git -cd ferriskey +git clone https://github.com/ferriskey/website.git +cd website pnpm install --frozen-lockfile ``` @@ -103,9 +103,9 @@ order: 10 --- ``` -## Writing Documentation +## Writing documentation -Good documentation should help readers complete a task or understand a concept without guessing. +Good documentation helps a reader finish a task or understand a concept without guessing. - Start with the outcome the reader wants. - Use concrete examples, URLs, environment variables, and commands. @@ -122,13 +122,13 @@ apps/docs/src/content/docs/discover/default/en/guides/ Navigation order is controlled by the `order` field in page frontmatter and by `_meta.json` files for groups. -## Code Contributions +## Code contributions -Before changing code, read the nearby implementation and follow the existing patterns. FerrisKey uses a modular architecture: domain logic, infrastructure, API boundaries, and documentation should stay separated. +Before changing code, read the surrounding implementation and follow the patterns already there. FerrisKey is modular by design: domain logic, infrastructure, API boundaries, and documentation stay separate. For user-facing changes, update the relevant docs in the same pull request. For bug fixes, add or update tests when the behavior can regress. -## Validate Your Changes +## Validate your changes Run formatting before opening a pull request: @@ -148,7 +148,7 @@ For documentation-only changes, you can also build only the docs app: pnpm --filter @explainer/docs build ``` -## Pull Request Checklist +## Pull request checklist Before requesting review, check that: @@ -159,9 +159,9 @@ Before requesting review, check that: - Formatting and build commands pass locally. - The PR does not include unrelated rewrites, generated noise, or local environment files. -## Review Process +## Review process -Maintainers review for correctness, maintainability, security, and consistency with FerrisKey's direction. Expect questions and requested changes, especially for public APIs, authentication flows, deployment behavior, and documentation that affects production usage. +Maintainers review for correctness, maintainability, security, and fit with where FerrisKey is going. Expect questions and requested changes, particularly on public APIs, authentication flows, deployment behavior, and documentation people will follow in production. When feedback arrives: @@ -170,6 +170,6 @@ When feedback arrives: - Keep the conversation focused on the technical decision. - Ask for another review when the PR is ready. -## Community Standards +## Community standards -Be direct, respectful, and specific. Assume contributors are trying to improve the project, and keep discussions grounded in reproducible behavior, code, documentation, or user impact. +Be direct, respectful, and specific. Assume the person on the other side is trying to improve the project, and keep the discussion anchored in reproducible behavior, code, documentation, or user impact. 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 8a7bf34..abb29dd 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 @@ -7,24 +7,24 @@ order: 21 # Email & Templates -FerrisKey sends transactional emails for password resets, magic link authentication, and email verification. Delivery is configured per realm through a dedicated SMTP setup, and each email type can be customized with your own HTML template. +FerrisKey sends transactional email for password resets, magic link sign-in, and email verification. Delivery is configured per realm, and each email type can be pointed at a template of your own. ## Configure SMTP -SMTP is stored in the database at the realm level — not as a global environment variable. Each realm can use a different mail provider independently. +SMTP settings live in the database, on the realm, not in a global environment variable. Two realms in the same deployment can use completely different mail providers. :::callout{variant="info" title="No global SMTP"} SMTP is configured per realm, not globally. See the [Configuration guide](/en/discover/guides/configuration) for the short overview. This page covers the full field reference and API. ::: -### SMTP Fields +### SMTP fields | Field | Type | Notes | |---|---|---| | `host` | String | SMTP server hostname | | `port` | u16 | Port number (1–65535) | | `username` | String | SMTP authentication username | -| `password` | String | SMTP authentication password — write-only, never returned by the API | +| `password` | String | SMTP authentication password. Write-only: the API never returns it | | `from_email` | String | Sender address, must be a valid email | | `from_name` | String | Sender display name | | `encryption` | Enum | `tls` \| `starttls` \| `none` | @@ -33,11 +33,11 @@ SMTP is configured per realm, not globally. See the [Configuration guide](/en/di | Mode | Port | When to use | |---|---|---| -| `tls` | 465 | Implicit TLS from the first byte — preferred for modern providers | -| `starttls` | 587 | Plain connection upgraded to TLS — common in corporate relays | -| `none` | 25 / any | No encryption — use only on a trusted local network or for testing | +| `tls` | 465 | Implicit TLS from the first byte. What most modern providers want | +| `starttls` | 587 | Plain connection upgraded to TLS. Common on corporate relays | +| `none` | 25 / any | No encryption. Only on a trusted local network, or for testing | -### SMTP API Endpoints +### SMTP API endpoints All endpoints are scoped to a realm and require authentication. @@ -47,9 +47,9 @@ All endpoints are scoped to a realm and require authentication. | `PUT` | `/realms/{realm_name}/smtp-config` | Create or replace the config | | `DELETE` | `/realms/{realm_name}/smtp-config` | Remove the config | -**Required permissions:** reading SMTP config requires view access to the realm. Creating, updating, or deleting requires `ManageRealm`. There are no dedicated SMTP permissions beyond the realm permission gates. +**Required permissions:** reading the SMTP config requires view access to the realm. Creating, updating, or deleting it requires `ManageRealm`. There are no SMTP-specific permissions beyond the realm gates. -### Configure SMTP in the Console +### Configure SMTP in the console ::::step-group :::step{title="Open Realm Settings"} @@ -84,9 +84,9 @@ The API never returns the SMTP password. If you need to rotate credentials, subm --- -## Transactional Emails +## Transactional emails -FerrisKey sends exactly three types of transactional email. Each is tied to a realm feature toggle. +There are three types of transactional email, each gated by a realm toggle. | Email type | Identifier | Sent when | Realm toggle required | |---|---|---|---| @@ -94,29 +94,29 @@ FerrisKey sends exactly three types of transactional email. Each is tied to a re | Magic link | `magic_link` | A user requests passwordless email login | `magic_link_enabled` | | Email verification | `email_verification` | A user must verify their email address | `email_verification_enabled` | -Enabling these toggles in Realm Settings activates the corresponding flow. FerrisKey will use the default built-in template unless you assign a custom one. +Turning a toggle on in Realm Settings activates the matching flow. Until you assign a template of your own, FerrisKey uses the built-in default. --- -## Customizing Templates +## Customizing templates -### Template Engine +### Template engine -FerrisKey uses a lightweight custom interpolation engine — not Handlebars or Tera. Placeholders use double-brace syntax: +Templates are rendered by a small interpolation engine written for the purpose, not Handlebars or Tera. Placeholders use double braces: ```text {{variable_name}} ``` -All interpolated values are HTML-escaped before insertion (`&`, `<`, `>`, `"`, `'`), which prevents injection attacks in the rendered email. +Every interpolated value is HTML-escaped before insertion (`&`, `<`, `>`, `"`, `'`), so a template cannot be turned into an injection vector. :::callout{variant="info" title="HTML escaping"} -Values inserted via `{{...}}` are always HTML-escaped. If a user's name is `Alice