OIDC provider configuration
Personal Agent authenticates every user and client with OpenID Connect. The API is a bearer-only resource server: it never runs a login itself — it only validates the access tokens that your identity provider (IdP) issues, on every request. Keycloak is the reference IdP and ships as realm-as-code, but any standards-compliant OIDC provider works as long as it can mint access tokens in the shape described in §2.
This page is the canonical reference for that setup. For how the bundled Keycloak realm is imported, see the realm-import steps for Docker and Kubernetes.
1. How authentication works
- The browser/app logs in against your IdP with authorization-code + PKCE (public clients, no
secret). Headless clients (
pa, Computer Service enrollment, MCP) use the device authorization grant. - The client receives an access token audienced to the API and sends it as a
Authorization: Bearer …header (for WebSockets, via theSec-WebSocket-Protocolsubprotocol — never a query string). - The API verifies the token’s signature, issuer, audience and expiry with RS256 keys
fetched from the IdP’s JWKS endpoint (discovered from
{issuer}/.well-known/openid-configuration), then derives the user’s identity, org and roles from the token’s claims. There is no server-side session. - Users are provisioned lazily on first request (keyed by the
subclaim); you do not pre-create them in Personal Agent — you manage them in your IdP.
!!! note “JWKS is a soft dependency”
The API’s readiness probe (/readyz) gates only on Postgres + Redis. The IdP/JWKS is a
soft dependency reported by /health/deps (jwks_ready), so a Keycloak blip never takes the
API offline — the next request just re-fetches the keys.
2. The token contract
Every access token the API accepts must be an RS256 JWT with these claims:
| Claim | Required | Value / shape | Used for |
|---|---|---|---|
sub |
yes | stable subject id | the user’s primary key (lazy provisioning, ownership of all data) |
iss |
yes | exactly your issuer (PERSONAL_AGENT__OIDC__ISSUER) |
issuer validation |
aud |
yes | must contain personal-agent-api (PERSONAL_AGENT__OIDC__AUDIENCE) |
resource-server audience |
exp, iat |
yes | standard | expiry / freshness |
organization |
optional | org id(s) — string, array, or object keyed by org id | multi-tenant; validated against the X-Personal-Agent-Org header |
realm_access.roles and/or resource_access.personal-agent-api.roles |
optional | array of role names; include admin for platform admins (org-admin, user also recognised) |
authorization (admin features) |
groups |
optional | array of full group paths, e.g. ["/Engineering"] |
group-shared content + per-group roles |
email |
optional | user email | stored on the user record (informational) |
!!! warning “The four non-negotiables”
1. The token is signed with RS256 and verifiable against the IdP’s JWKS.
2. iss equals PERSONAL_AGENT__OIDC__ISSUER exactly (scheme, host, path).
3. aud contains personal-agent-api — a token audienced only to the SPA/public client is
rejected. Most IdPs need an explicit audience mapper (Keycloak) or API/resource request
(others) to add this.
4. The JWKS URL is reachable over HTTPS (HTTP is allowed only in dev).
Anything not in the required rows is optional: a single-user / single-tenant instance works with
no organization, groups, or role claims at all (the user is a normal non-admin user in the
default org). OAuth scopes are not enforced by the API — offline_access and refresh tokens are
handled entirely client-side and never reach the backend.
3. Backend settings
All under the PERSONAL_AGENT__OIDC__ env prefix (__ nests). Most self-hosters only set the
issuer (via the OIDC_ISSUER Compose alias) and leave the rest at their defaults.
| Setting | Default | Meaning |
|---|---|---|
PERSONAL_AGENT__OIDC__ISSUER |
http://localhost:8080/realms/personal-agent |
Issuer URL; JWKS is discovered from it. Set to ${KEYCLOAK_ORIGIN}/realms/${REALM}. |
PERSONAL_AGENT__OIDC__AUDIENCE |
personal-agent-api |
Required aud. Change only if your IdP emits a different audience identifier. |
PERSONAL_AGENT__OIDC__SPA_CLIENT_ID |
personal-agent-spa |
Public client id advertised to the web SPA. |
PERSONAL_AGENT__OIDC__BROWSER_CLIENT_ID |
personal-agent-browser |
Public client id for the browser extension. |
PERSONAL_AGENT__OIDC__ANDROID_CLIENT_ID |
personal-agent-app |
Public client id for the Android shell. |
PERSONAL_AGENT__OIDC__ALGORITHMS |
RS256 |
Accepted signing algorithms. |
PERSONAL_AGENT__OIDC__JWKS_CACHE_SECONDS |
300 |
JWKS cache TTL. |
PERSONAL_AGENT__OIDC__ORG_HEADER |
X-Personal-Agent-Org |
Header the SPA sends to select the active org. |
The Compose .env exposes the common backend knobs — OIDC_ISSUER, KEYCLOAK_ORIGIN and REALM.
The SPA discovers issuer and client id from the backend. See the
Configuration reference.
!!! note “Client bootstrap endpoint”
Thin clients need only the server URL. The backend serves the non-secret authentication
contract unauthenticated at GET /api/v1/public/client-config: auth mode, client ids and the
device authorization/token endpoints. In external-OIDC mode those endpoints come from OIDC
Discovery; in local mode they point at the backend’s RFC 8628 implementation. Clients reject
incomplete discovery rather than deriving provider-specific URLs.
4. Keycloak (the reference setup)
The repo ships a minimal example realm — only the personal-agent-* clients, with ${VAR}
placeholders — that you can import to bootstrap local dev or a new instance: see the realm-import
steps for Docker and Kubernetes. It is a
starting point, not a production realm; for a real deployment (or any non-Keycloak provider),
configure the clients from the reference below rather than relying on the import. This section
documents exactly what the realm needs to contain.
Clients
The realm personal-agent defines one resource server plus a public client per front end. All are
public (PKCE, no secret) except the API, which is bearer-only.
| Client | Access type | Flow | Used by |
|---|---|---|---|
personal-agent-api |
bearer-only (confidential) | — (validates tokens) | the FastAPI backend — this is the audience |
personal-agent-spa |
public, PKCE S256 | auth-code | the web SPA |
personal-agent-device |
public | device grant | pa login and one-time Computer Service ownership verification |
personal-agent-browser |
public, PKCE S256 | auth-code | the browser extension |
personal-agent-app |
public, PKCE S256 | auth-code | the Android shell (custom redirect scheme) |
personal-agent-mcp |
public, PKCE S256 | auth-code and device grant | external MCP clients (Claude, Cursor, …) |
Each login client (everything except personal-agent-api) needs, in addition to its
redirect URIs and web origins:
- an audience mapper that injects
aud=personal-agent-apiinto the access token (in the shipped realm this is a per-clientoidc-audience-mapper— there is no shared scope for it). Without it, tokens are audienced to the public client and the API rejects them; - the default
rolesclient scope (carriesrealm_access.roles/resource_access); - for auth-code clients, PKCE method S256;
- (multi-tenant only) the optional
organizationscope, requested by the client; - a group-membership mapper emitting full paths into
groups(the shipped realm includes one on every login client; harmless if you don’t use group sharing).
!!! note “Exact redirect URIs”
Keycloak only honours a trailing * in a redirect URI — a mid-host wildcard
(https://*.example.com/*) is rejected at the authorize step. Register each exact host:
${APP_ORIGIN}/* + ${APP_ORIGIN}/auth/callback for the SPA, the
https://<ext-id>.chromiumapp.org/ host for the extension (see the
extension repo), and the Android
custom-scheme callback.
Roles → admin
Create the realm roles admin, org-admin, user. The backend grants platform admin to any
token whose realm_access.roles or resource_access.personal-agent-api.roles contains the role
named admin (case-insensitive). Assign admin to your administrator(s); everyone
authenticated is implicitly a normal user. There is no admin-by-group and no standalone admin
claim.
Organizations → tenancy (multi-tenant only)
The shipped realm has Organizations off (organizationsEnabled: false) and exposes
organization only as an optional client scope. For a multi-tenant instance, enable Keycloak
Organizations, add the organization client scope (an oidc-organization-membership-mapper
emitting the organization claim), and have clients request scope=organization. The SPA then
sends the active org in the X-Personal-Agent-Org header, which the API validates against the
token’s organization claim every request (Postgres row-level security enforces it as
defense-in-depth). If the header is omitted but the token grants exactly one org, the API defaults to
it. Single-tenant instances skip this entirely - with no organization claim the user simply
operates without an org scope.
Groups (optional)
A Group Membership mapper (full path, claim name groups) enables group-shared chats, workflows
and folders; the shipped realm already attaches one to every login client, so you only manage which
groups a user is in. (You can optionally add a Keycloak browser-flow override to gate which app a
user may log into by group → role — that is Keycloak-side, not enforced by the backend, and the
example realm does not include it.) New users get no groups by default (and self-registration is off,
registrationAllowed: false), so grant access by adding users to a group.
5. Using a different OIDC provider
Any OIDC provider (Auth0, Authentik, Microsoft Entra ID, Okta, Google, …) works if it produces the token contract. Map the Personal Agent clients onto your provider’s concepts:
| Personal Agent needs | Generic OIDC equivalent |
|---|---|
personal-agent-spa (+ browser/Android) |
a public / SPA application with PKCE and your redirect URIs |
personal-agent-api (the audience) |
an API / resource / audience identifier; configure the front-end app to request it so tokens carry aud=personal-agent-api — or set PERSONAL_AGENT__OIDC__AUDIENCE to whatever identifier your provider emits |
personal-agent-device, personal-agent-mcp |
applications enabled for the device authorization grant |
admin role |
a claim realm_access.roles (or resource_access.personal-agent-api.roles) containing admin |
organization (multi-tenant) |
a top-level organization claim with the user’s org id(s) |
groups (sharing) |
a groups claim of path strings |
!!! warning “Claim shapes are Keycloak-flavoured”
The role, org and group claims use Keycloak’s nesting (realm_access.roles, organization,
groups). Non-Keycloak providers rarely emit these by default — you’ll need a custom
claim/rule/mapping to produce exactly these names and shapes. If you can’t, the affected
feature simply stays off: no realm_access.roles → no admin UI; no organization → run
single-tenant; no groups → no group sharing. Core sign-in (token with sub + iss +
aud=personal-agent-api) still works.
Provider notes:
- Authentik is closest to Keycloak — it can emit custom property mappings for
realm_access,organizationandgroupsdirectly, and supports the device flow. - Auth0 / Okta / Entra ID use API audiences + RBAC; add a rule/action/claim-mapping that writes
the nested
realm_access.roles(and, for multi-tenant,organization). Confirm device-flow support if you use the Computer Service or MCP. - Google can sign users in (PKCE + device flow) but emits no roles/groups/org claims — fine for a single-user instance where you don’t need the admin UI or sharing.
Whatever the provider: set PERSONAL_AGENT__OIDC__ISSUER to its issuer, ensure RS256 + a reachable
HTTPS JWKS, and make the tokens carry aud=personal-agent-api.
6. Verify it works
- Sign in to the web app — you should land in a chat and see a session.
- Inspect an access token (browser dev-tools, or
/api/v1/me’s request). Decode it and confirmiss= your issuer,audcontainspersonal-agent-api, and (for an admin)realm_access.rolesincludesadmin. - From a shell:
Acurl -H "Authorization: Bearer <access-token>" https://app.example.com/api/v1/me200with your profile means the token contract is satisfied; a401points at §7. - Open an admin-only page (e.g. Platform settings) as your admin user to confirm the
adminrole maps through. GET /health/depsreportsjwks_ready: trueonce the first token has been verified.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
401 immediately after a successful login |
aud ≠ personal-agent-api (token audienced only to the public client) |
add the audience mapper / request the API audience, or set PERSONAL_AGENT__OIDC__AUDIENCE to your provider’s value |
401 invalid issuer |
iss ≠ PERSONAL_AGENT__OIDC__ISSUER |
match the issuer exactly (scheme/host/realm path) |
401 signature / key errors |
algorithm ≠ RS256, or JWKS unreachable/HTTP | use RS256; ensure {issuer}/.well-known/openid-configuration and its jwks_uri resolve over HTTPS |
403 “requested organization is not granted by the token” |
X-Personal-Agent-Org value not in the token’s organization claim |
add the user to that org / emit the claim, or run single-tenant (no header) |
| Logged in but no admin features | admin missing from realm_access.roles / resource_access.personal-agent-api.roles |
grant the admin realm role |
Invalid parameter: redirect_uri |
the exact redirect URI isn’t registered | register it verbatim (no mid-host wildcards) |
| Group sharing doesn’t pick up membership | no groups claim (full paths) |
add the group-membership mapper |