Skip to content

OpenID Connect (OIDC) Authentication Guide

This guide walks you through configuring and using OpenRag’s OIDC authentication mode.

  1. Overview
  2. Architecture
  3. Configuration
  4. User Pre-provisioning
  5. Claim Mapping (optional)
  6. Auto-provisioning (optional)
  7. Keycloak Setup
  8. LemonLDAP::NG Setup
  9. Programmatic Access
  10. Back-Channel Logout
  11. Troubleshooting
  12. Security Considerations

OpenRag supports two authentication modes:

  • Token Mode (AUTH_MODE=token, default): Traditional Bearer token authentication. Suitable for development and programmatic access.
  • OIDC Mode (AUTH_MODE=oidc): OpenID Connect Authorization Code + PKCE flow. Users authenticate via an external identity provider (IdP), and the UI receives a browser-managed session cookie.

Use OIDC when:

  • Your organization uses a centralized identity provider (Keycloak, Azure AD, Okta, LemonLDAP::NG, etc.)
  • You want users to authenticate through a familiar corporate login
  • You need federated identity and single sign-on (SSO) across multiple systems
  • You want to leverage existing user directories (LDAP, Active Directory, SAML)

Use Token Mode when:

  • Running in development or testing
  • Your application is purely backend/programmatic
  • You prefer simplicity without external dependencies
  • You’re integrating with headless tools (CI/CD, SDKs, etc.)

Browser OpenRag IdP (Keycloak)
| | |
|-- GET /chainlit -->| |
|<- 302 /auth/login | |
|-- GET /auth/login->| |
| | [gen state/nonce/PKCE,
| | pose cookie (5 min)]
|<-302 authorize-----| |
|----------- authorize?client_id=&state=&code_challenge=-------->|
|<------ login form --------------------------------|
|------- username/password ----------------------->|
|<------ 302 /auth/callback?code=&state=----------|
|-- GET /auth/callback->| |
| |---token exchange --->|
| |<-- id_token, access_token, refresh_token --|
| | [verify signature, nonce, iss, aud;
| | match user by external_user_id=sub;
| | (optional) apply OIDC_CLAIM_MAPPING;
| | create oidc_sessions row;
| | set openrag_session cookie]
|<--302 next_url-----| |
|-- GET /chainlit --->| |
| | [session OK]
|<-- 200 Chainlit UI--| |

openrag/components/auth/ package:

  • oidc_client.py — Authlib-based OIDC client (discovery, JWKS, token exchange, verification)
  • session_tokens.py — Session token generation and Fernet encryption/decryption
  • state_cookie.py — Temporary cookie (5 min TTL) for transporting state, nonce, code_verifier
  • middleware.py — Modified AuthMiddleware supporting cookie + bearer + lazy refresh
  • refresh.py — Lazy access token refresh logic

openrag/routers/auth.py:

  • /auth/login — Start Authorization Code + PKCE flow
  • /auth/callback — Handle IdP redirect, create session
  • /auth/backchannel-logout — IdP-driven revocation (OIDC spec)
  • /auth/logout — RP-initiated logout (local + IdP)
  • /auth/me — Debug endpoint (returns current user + session expiry)

Database:

  • New oidc_sessions table: stores encrypted IdP tokens, session metadata, revocation status
  • email column on users table: optional metadata, NOT used for matching (matching is exclusively by external_user_id == sub)

All variables must be set when AUTH_MODE=oidc. If any required variable is missing, the application refuses to start.

VariableRequiredDefaultDescription
AUTH_MODENotokenSet to oidc to enable OIDC authentication
OIDC_ENDPOINTYes*Issuer URL (auto-discovery via /.well-known/openid-configuration)
OIDC_CLIENT_IDYes*Client ID registered at the IdP
OIDC_CLIENT_SECRETYes*Client secret (confidential clients only)
OIDC_REDIRECT_URIYes*Callback URL on OpenRag’s backend, must match IdP configuration. Behind a proxy: https://openrag.example.com/auth/callback; direct access: http://<ip_addr>:<APP_PORT>/auth/callback. See Choosing OIDC_REDIRECT_URI.
OIDC_TOKEN_ENCRYPTION_KEYYes*Fernet key for encrypting tokens at rest (see Generating the Fernet Key)
OIDC_CLAIM_SOURCENoid_tokenWhere to read claims for Claim Mapping: id_token (verified JWT) or userinfo (/userinfo endpoint)
OIDC_CLAIM_MAPPINGNoOptional CSV of db_field:claim pairs to copy claims into user fields on every login (e.g., display_name:name,email:email). See Claim Mapping.
OIDC_AUTO_PROVISION_LOGINNofalseWhen true, an unknown sub triggers creation of a non-admin user from the ID-token claims instead of returning 403 User not registered. Also keeps the user’s display_name and email in sync with the claims on every subsequent login. See Auto-provisioning.
OIDC_SCOPESNoopenid email profile offline_accessSpace-separated OIDC scopes; include offline_access for refresh tokens
OIDC_POST_LOGOUT_REDIRECT_URINoURL the IdP sends the user to after RP-initiated logout. No default: if unset AND the IdP doesn’t have an end_session_endpoint, /auth/logout returns a plain 200 confirming the logout. Avoid pointing this to an OpenRag URL (triggers re-auth — silent SSO loop).

* Required when AUTH_MODE=oidc

The value must be an absolute URL pointing at OpenRag’s backend /auth/callback route, reachable from the user’s browser, and registered verbatim in the IdP client configuration.

  • Behind a reverse proxy (production, TLS terminated): UI and backend share the same public base URL, so use the public hostname directly:

    OIDC_REDIRECT_URI=https://openrag.example.com/auth/callback
  • No reverse proxy (local / bare-metal deployment): each service is reached on its own port, so the backend must be addressed explicitly by host and APP_PORT:

    OIDC_REDIRECT_URI=http://<ip_addr>:<APP_PORT>/auth/callback

    The IdP redirects the browser to this URL, so <ip_addr> must be resolvable/reachable from the end user’s machine — not localhost or 127.0.0.1 unless the browser runs on the same host as OpenRag.

Whichever form you use, the string must match the IdP’s Valid redirect URIs list byte-for-byte (scheme, host, port, path, trailing slash).

Generate a cryptographically secure key for token encryption:

Terminal window
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

Output example:

XFlT-ZfXkdqf0v-5Z8kVt9xhU6c7Z4z0ZY8Z4Z4Z4=

Store this key securely (e.g., in a secrets manager). Never commit it to version control.

Create a .env file (or set these environment variables):

Terminal window
# Token mode (default)
AUTH_MODE=token
AUTH_TOKEN=sk-or-change-me
# OR, OIDC mode
AUTH_MODE=oidc
# OIDC configuration (Keycloak example)
OIDC_ENDPOINT=https://idp.example.com/realms/openrag
OIDC_CLIENT_ID=openrag
OIDC_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx
OIDC_REDIRECT_URI=https://openrag.example.com/auth/callback
OIDC_TOKEN_ENCRYPTION_KEY=XFlT-ZfXkdqf0v-5Z8kVt9xhU6c7Z4z0ZY8Z4Z4Z4=
OIDC_SCOPES=openid email profile offline_access
OIDC_POST_LOGOUT_REDIRECT_URI=/
# Optional — sync display name and email from IdP on every login:
# OIDC_CLAIM_SOURCE=id_token # or 'userinfo'
# OIDC_CLAIM_MAPPING=display_name:name,email:email

By default, OIDC requires users to be pre-provisioned in OpenRag’s database — there is no automatic user creation on login. User matching is performed exclusively by external_user_id == sub: the admin MUST set each user’s external_user_id to the exact OIDC sub claim the IdP will emit for that user.

If pre-provisioning every user is impractical at your scale, see Auto-provisioning for an opt-in flag that lets the callback create users on the fly from ID-token claims.

Create a user with external_user_id:

Terminal window
curl -X POST http://localhost:8080/users/ \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"display_name": "Alice Cooper",
"external_user_id": "550e8400-e29b-41d4-a716-446655440000",
"is_admin": false
}'

Response (returns the API token once):

{
"id": 42,
"display_name": "Alice Cooper",
"external_user_id": "550e8400-e29b-41d4-a716-446655440000",
"email": null,
"is_admin": false,
"token": "or-xxxxxxxxxxxxxxxxxxxxxxxx"
}

Where does external_user_id come from? It is whatever string the IdP puts in the sub claim for that user — an opaque stable identifier. Look it up in the IdP admin console (Keycloak: Users → open a user → copy the ID field) or via the IdP’s own API.

email is OPTIONAL metadata. It is not used for matching. You may set it at creation time for human-readable admin listings, or let it be populated automatically via Claim Mapping.

For bulk imports, write a script that calls /users/ with the users’ external_user_id values from your IdP export:

import requests
auth_token = "sk-your-token"
headers = {
"Authorization": f"Bearer {auth_token}",
"Content-Type": "application/json",
}
users = [
{"display_name": "Alice", "external_user_id": "kc-uuid-alice"},
{"display_name": "Bob", "external_user_id": "kc-uuid-bob"},
{"display_name": "Charlie", "external_user_id": "kc-uuid-charlie"},
]
base_url = "http://localhost:8080"
for user in users:
response = requests.post(f"{base_url}/users/", headers=headers, json=user)
print(f"Created {user['display_name']}: {response.json()}")

By default, the callback only verifies the user’s sub claim and issues a session — nothing else is read from the IdP. If you want OpenRag to keep selected columns on the users table in sync with IdP claims (display name, email, …) on every login, set OIDC_CLAIM_MAPPING.

  • You want users.display_name to reflect the IdP’s current value (e.g., user renamed themselves).
  • You’re migrating from an email-based matching scheme and want to populate users.email automatically without a manual script.
  • Your LDAP directory is the source of truth for user attributes.

Format: comma-separated pairs db_field:claim.

Terminal window
OIDC_CLAIM_MAPPING=display_name:name,email:email

Each pair means “copy the value of the OIDC claim claim into users.db_field”. Spaces around the separator are tolerated.

Where the claim is read from is controlled by OIDC_CLAIM_SOURCE:

ValueSource
id_token (default)Verified ID-token claims
userinfo/userinfo endpoint fetched with the user’s access token

Only these columns may appear on the left-hand side of a mapping:

DB fieldPurpose
display_nameHuman-readable name
emailEmail metadata (NOT used for matching)

Any other field (is_admin, external_user_id, file_quota, token, …) is rejected at startup — this is a hard security boundary, preventing an IdP that returns an is_admin: true claim from privilege-escalating existing users.

  • If OIDC_CLAIM_MAPPING is empty or unset: no user field is updated on login.
  • If a mapped claim is missing from the source, that field is skipped (but the login still succeeds).
  • If the claim value matches what’s already stored, no DB write happens (no-op).
  • If OIDC_CLAIM_SOURCE=userinfo and the /userinfo fetch fails, the login fails with 400 "Failed to fetch userinfo from IdP".
  • Email values are normalized (trimmed + lowercased) before being written.

With OIDC_CLAIM_MAPPING=display_name:name,email:email and OIDC_CLAIM_SOURCE=id_token:

ID token claimusers column updated to
name: "Alice Cooper"display_name = "Alice Cooper"
email: "alice@example.com"email = "alice@example.com"
sub: "kc-alice-uuid"(used for matching only, never written)
is_admin: trueignored (not in whitelist)

By default, an unknown sub is rejected with 403 User not registered and the admin must pre-create the user via POST /users/. For deployments where every IdP user should be allowed in without manual provisioning, set OIDC_AUTO_PROVISION_LOGIN=true.

  • Your IdP already gates who can issue valid tokens, so its user list is the source of truth.
  • Manual pre-provisioning at corporate scale is impractical.
  • You want a user renamed in the IdP to be reflected in OpenRag automatically.

When OIDC_AUTO_PROVISION_LOGIN=true:

  • Unknown sub: a non-admin user is created from the ID-token claims.

    User columnSource
    external_user_idsub (always)
    display_namename then preferred_username then <given_name> <family_name> then oidc-<8-char-sub-prefix>
    emailemail claim if present, else null
    is_adminalways false (operators promote afterwards via /users/)
    file_quotaserver default (DEFAULT_FILE_QUOTA)
  • Known sub: display_name and email are kept in sync with the ID-token claims on every login. A field is only written when the IdP value differs from the stored one (no DB churn on identical values).

is_admin, external_user_id, file_quota, and token are never written via this path — same security boundary as Claim Mapping.

The two flags are independent and compose cleanly:

  • OIDC_AUTO_PROVISION_LOGIN=true alone is enough to keep display_name + email synced. No OIDC_CLAIM_MAPPING needed.
  • If both are set, OIDC_CLAIM_MAPPING runs after the auto-provision sync and can override it (useful when the operator wants display_name to come from a non-default claim).

Enabling auto-provisioning shifts the trust boundary: the IdP’s user list becomes the source of truth for OpenRag accounts. Strict-whitelist deployments leave the flag unset and keep the historical 403. There is no path to grant is_admin=true via OIDC claims; promotion remains an explicit admin action.


Keycloak is a popular open-source identity provider. This section walks through a typical Keycloak configuration.

  • Keycloak 20+ (or latest)
  • Network connectivity between OpenRag and Keycloak
  • Admin access to Keycloak
  1. Log into Keycloak Admin Console (e.g., http://localhost:8081/admin)
  2. Click Create Realm (top left)
  3. Enter realm name: openrag
  4. Click Create
  1. In the realm openrag, navigate to Clients (left sidebar)
  2. Click Create client
  3. Set Client ID: openrag
  4. Leave Client Type: OpenID Connect
  5. Click Next
  6. Enable:
    • Client authentication: ON (confidential client)
    • Authorization: ON
  7. Click Next
  8. Valid redirect URIs: Add https://openrag.example.com/auth/callback (replace with your actual URL)
  9. Valid post logout redirect URIs: Add https://openrag.example.com/ (or your home page)
  10. Backchannel logout URL: Add https://openrag.example.com/auth/backchannel-logout
  11. Backchannel logout session required: ON
  12. Click Save
  1. Navigate to ClientsopenragClient scopes
  2. Ensure these default scopes are assigned:
    • email — includes email claim
    • profile — includes name claims
    • offline_access — allows refresh tokens
  3. Click Save
  1. Navigate to ClientsopenragCredentials tab
  2. Copy Client secret
  3. Use this in OIDC_CLIENT_SECRET env var

In .env:

Terminal window
AUTH_MODE=oidc
OIDC_ENDPOINT=http://keycloak.example.com/realms/openrag
OIDC_CLIENT_ID=openrag
OIDC_CLIENT_SECRET=<paste-secret-from-step-4>
OIDC_REDIRECT_URI=https://openrag.example.com/auth/callback
OIDC_TOKEN_ENCRYPTION_KEY=<generate-via-python-script>
OIDC_SCOPES=openid email profile offline_access
  1. Navigate to Users (left sidebar)
  2. Click Create new user
  3. Username: testuser
  4. Email: testuser@example.com
  5. First name: Test
  6. Last name: User
  7. Click Create
  8. Copy the user’s ID from the detail view — this is the sub claim the IdP will emit
  9. Go to Credentials tab, set a password for testing
  10. Ensure Temporary is OFF (so user can log in immediately)

Use the Keycloak user ID from the previous step as external_user_id:

Terminal window
curl -X POST http://localhost:8080/users/ \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"display_name": "Test User",
"external_user_id": "<paste-keycloak-user-id>",
"is_admin": false
}'
  1. Navigate to http://localhost:8080/chainlit (or your app URL)
  2. Expect: 302 redirect to /auth/login
  3. Expect: 302 redirect to Keycloak (http://keycloak.example.com/realms/openrag/protocol/openid-connect/auth?...)
  4. Log in with testuser / <password>
  5. Expect: 302 redirect back to /auth/callback?code=...
  6. Expect: 302 redirect to /chainlit or next_url
  7. Should be authenticated

Troubleshooting Keycloak:

  • “Invalid redirect URI”: Ensure OIDC_REDIRECT_URI exactly matches what’s configured in Keycloak ClientsValid redirect URIs.
  • “Client secret mismatch”: Copy the secret again from Credentials tab.
  • “Invalid scope”: Ensure email and offline_access are in the client’s scope mappings.

LemonLDAP::NG is another OIDC provider, often used in enterprise environments with LDAP/AD integration.

LemonLDAP::NG uses different terminology. Map these OpenRag variables to LLNG config:

OpenRag VariableLLNG ParameterExample
OIDC_ENDPOINTOIDCServiceMetaDataIssuerhttps://llng.example.com
OIDC_CLIENT_IDOIDCServiceMetaDataClientIDopenrag
OIDC_CLIENT_SECRETOIDCServiceMetaDataClientSecret(from LLNG admin)
OIDC_REDIRECT_URIOIDCServiceMetaDataRedirectUrishttps://openrag.example.com/auth/callback
  1. Create an OIDC relying party in LLNG admin:

    • Go to LLNG admin console
    • Navigate to ApplicationsOpenID Connect Relying Parties
    • Create a new relying party named openrag
    • Set Client ID: openrag
    • Set Redirect URI: https://openrag.example.com/auth/callback
    • Set Post-logout URI: https://openrag.example.com/
    • Generate/retrieve the client secret
  2. Configure OpenRag .env:

Terminal window
AUTH_MODE=oidc
OIDC_ENDPOINT=https://llng.example.com
OIDC_CLIENT_ID=openrag
OIDC_CLIENT_SECRET=<secret-from-llng>
OIDC_REDIRECT_URI=https://openrag.example.com/auth/callback
OIDC_TOKEN_ENCRYPTION_KEY=<generate-via-python>
OIDC_SCOPES=openid email profile offline_access
  1. Pre-provision users via /users/ API, same as Keycloak — set external_user_id to whatever value LLNG emits as the sub claim (typically the uid).

  2. Test: Navigate to OpenRag, should redirect to LLNG login

For LLNG-specific questions, consult the LLNG documentation.


Even in OIDC mode, Bearer token authentication is still supported for programmatic access (CI/CD, SDKs, scripts, tests).

Each user has a users.token column (same as token mode). Clients can use this for programmatic access:

Terminal window
curl -H "Authorization: Bearer or-xxxxxxxxxxxxxxxxxxxxxxxx" \
http://openrag.example.com/v1/models

This bypasses the OIDC/session cookie flow entirely, suitable for:

  • CI/CD pipelines uploading documents
  • Python/JavaScript SDK clients
  • Test automation
  • Headless microservices

Admin retrieves a user’s token:

Terminal window
curl -X GET http://localhost:8080/users/42 \
-H "Authorization: Bearer ${AUTH_TOKEN}"

Note: The token is hidden in normal responses (security). To get it, use:

Terminal window
curl -X POST http://localhost:8080/users/42/regenerate_token \
-H "Authorization: Bearer ${AUTH_TOKEN}"

This returns a fresh token (old one invalidated).

#!/bin/bash
OPENRAG_URL="https://openrag.example.com"
API_TOKEN="or-xxxxxxxxxxxxxxxxxxxxxxxx"
PARTITION_NAME="documents"
curl -X POST "${OPENRAG_URL}/indexer/add_file" \
-H "Authorization: Bearer ${API_TOKEN}" \
-F "file=@document.pdf" \
-F "partition=${PARTITION_NAME}"

Back-channel logout is part of the OIDC standard. When a user logs out from the IdP or an admin terminates their session, the IdP can notify OpenRag to revoke the session.

  1. User logs out from Keycloak (or is logged out by admin)
  2. Keycloak sends a POST request to https://openrag.example.com/auth/backchannel-logout with a signed JWT logout_token
  3. OpenRag verifies the JWT signature and extracts the sid claim
  4. All oidc_sessions rows with matching sid are marked revoked_at = now()
  5. OpenRag responds with 200 OK
  6. Next time the user tries to use an old session cookie, the middleware sees revoked_at is set and redirects to login

Keycloak:

  1. ClientsopenragSettings
  2. Enable Backchannel logout session required: ON
  3. Set Backchannel logout URL: https://openrag.example.com/auth/backchannel-logout
  4. Click Save

LemonLDAP::NG:

  1. Set the backchannel logout URL in the relying party configuration
  2. Consult LLNG docs for exact steps

The IdP sends:

POST /auth/backchannel-logout HTTP/1.1
Host: openrag.example.com
Content-Type: application/x-www-form-urlencoded
logout_token=eyJhbGc...

logout_token is a signed JWT with claims:

{
"iss": "https://idp.example.com/realms/openrag",
"sub": "user-sub",
"sid": "session-id",
"aud": "openrag",
"iat": 1234567890,
"exp": 1234571490,
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}
}

OpenRag:

  1. Fetches the IdP’s JWKS (via auto-discovery)
  2. Verifies the JWT signature, iss, aud, exp, iat
  3. Checks the events claim contains the logout event
  4. Extracts sid and revokes matching sessions
  5. Returns 200 OK or 400 Bad Request if validation fails

Create a signed logout token manually (advanced):

import jwt
import json
from datetime import datetime, timedelta
# Keycloak realm public key (from /.well-known/openid-configuration -> jwks_uri)
# For testing, sign with a private key
payload = {
"iss": "https://idp.example.com/realms/openrag",
"sub": "user-sub",
"sid": "session-id-from-openrag",
"aud": "openrag",
"iat": int(datetime.utcnow().timestamp()),
"exp": int((datetime.utcnow() + timedelta(minutes=5)).timestamp()),
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}
}
token = jwt.encode(payload, "your-private-key", algorithm="RS256")
print(token)

Then send:

Terminal window
curl -X POST http://localhost:8080/auth/backchannel-logout \
-d "logout_token=${token}" \
-H "Content-Type: application/x-www-form-urlencoded"

Expect 200 OK.


1. “OIDC_TOKEN_ENCRYPTION_KEY is not set”

Section titled “1. “OIDC_TOKEN_ENCRYPTION_KEY is not set””

Error: Application refuses to start when AUTH_MODE=oidc.

Solution: Generate and set the key:

Terminal window
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
export OIDC_TOKEN_ENCRYPTION_KEY=<paste-key>

Error: Backend logs show Issuer mismatch: configured '…', got '…' and /auth/login returns 500 “OIDC discovery failed”.

Cause: OpenRag enforces byte-for-byte equality between OIDC_ENDPOINT, the issuer field returned by the IdP’s discovery document, and the iss claim in tokens — per OIDC Core §2. The most common culprit is a trailing slash mismatch:

IdPTypical issuer form
Keycloakhttps://kc.example.com/realms/myrealm (no trailing slash)
LemonLDAP::NGhttps://llng.example.com/ (WITH trailing slash)
Auth0https://tenant.auth0.com/ (WITH trailing slash)
Googlehttps://accounts.google.com (no trailing slash)

Solution: Configure OIDC_ENDPOINT to match the IdP’s advertised issuer exactly. To check what the IdP actually returns:

Terminal window
curl -s http://your-idp/.well-known/openid-configuration | jq -r .issuer

Copy that string verbatim (including or excluding the trailing /) into .env:

OIDC_ENDPOINT=<exact string from jq output>

OpenRag builds the discovery URL by stripping any trailing slash internally, so both forms work for discovery — but the subsequent token-claim validation is strict.

Error: IdP rejects the callback with “Invalid redirect URI” or similar.

Solution:

  • Ensure OIDC_REDIRECT_URI matches exactly what’s configured in the IdP (case-sensitive, trailing slashes matter)
  • Example: https://openrag.example.com/auth/callback (NOT https://openrag.example.com/auth/callback/)
  • Keycloak: ClientsValid redirect URIs

4. “User not registered” (403 at callback)

Section titled “4. “User not registered” (403 at callback)”

Error: After a successful IdP login, OpenRag responds with 403 {"detail": "User not registered"} at /auth/callback.

Cause: User matching is performed exclusively by users.external_user_id == sub. There is no email fallback. The user either doesn’t exist yet, or their external_user_id column is not set (or does not match the IdP’s sub).

If pre-provisioning every user is impractical, set OIDC_AUTO_PROVISION_LOGIN=true to let the callback create users on the fly from the ID-token claims. See Auto-provisioning for the trust-model implications.

Solution: The admin must pre-provision the user with the exact sub the IdP emits. Discover the IdP’s sub for the user (e.g., Keycloak: Users → open the user → copy the ID field; or decode an ID token for that user with https://jwt.io and read the sub claim).

Create the user:

Terminal window
curl -X POST http://localhost:8080/users/ \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"display_name": "Alice Cooper",
"external_user_id": "<paste-the-sub-value-here>",
"is_admin": false
}'

Or, if the user already exists with a wrong external_user_id:

Terminal window
curl -X PATCH http://localhost:8080/users/42 \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"external_user_id": "<new-sub-value>"}'

Server logs record the attempted sub at WARNING level so admins can copy it over without having to decode the token themselves.

5. “session not found” or “session expired”

Section titled “5. “session not found” or “session expired””

Error: User can log in once, but subsequent requests show “unauthenticated”.

Cause: Session cookie was lost or expired.

Solution:

  • Ensure cookies are enabled in browser
  • Check openrag_session cookie exists and is not marked revoked_at in DB
  • Increase access_token_expires_at via OIDC scopes (offline_access + longer TTL in IdP)

6. “clock skew” or “token not yet valid”

Section titled “6. “clock skew” or “token not yet valid””

Error: “iat claim is in the future” or “exp claim is in the past”.

Solution:

  • Sync system clocks between OpenRag and IdP servers
  • Check NTP is running: ntpq -p

Error: OIDC client redirect fails with “Invalid scope requested: offline_access”.

Solution:

  • Keycloak: Ensure offline_access scope is mapped to the client
    • ClientsopenragClient scopes → Verify offline_access is in the assigned scopes
  • LemonLDAP::NG: Check the OIDC relying party configuration includes offline_access

  • httpOnly: Cookies are marked httpOnly, preventing JavaScript access (XSS mitigation)
  • Secure: In production (HTTPS), cookies are marked Secure (only sent over HTTPS)
  • SameSite=Lax: CSRF protection; allows top-level navigation but not cross-site subresource requests
  • Path=/: Cookies sent for all paths
  • No Domain: Host-only (not shared with subdomains)
  • Access tokens and refresh tokens are encrypted using Fernet (symmetric encryption) before storage in the database
  • The key (OIDC_TOKEN_ENCRYPTION_KEY) must be kept secret
  • If the key is compromised, regenerate it and re-encrypt all sessions (manual process in v1; planned for v2)
  • Access tokens have a short expiry (typically 5-15 minutes)
  • Refresh tokens have a longer expiry (typically hours or days, configured in IdP)
  • Middleware automatically refreshes access tokens when <60 seconds remain before expiry (lazy refresh)
  • No extra API calls for users; happens transparently
  • Authorization requests use a server-generated state parameter
  • The state is stored in a temporary, itsdangerous-signed cookie (openrag_oidc_state, 10-minute TTL)
  • The callback validates that the returned state matches the cookie
  • Prevents CSRF attacks on the callback endpoint
  • PKCE (Proof Key for Code Exchange):
    • Client generates a code_verifier (43-128 character random string)
    • Client computes code_challenge = BASE64URL(SHA256(code_verifier))
    • Client sends code_challenge in the authorization request
    • Callback sends code_verifier in the token exchange request
    • IdP verifies they match, preventing authorization code interception
  • Nonce:
    • Client generates a random nonce
    • Included in the authorization request and returned in the ID token
    • Callback verifies the nonce matches, preventing token replay
BreachImpactMitigation
OIDC_TOKEN_ENCRYPTION_KEY leakedAll encrypted tokens (access, refresh) decryptableRotate key + re-encrypt sessions (v2 feature)
Authorization code interceptedCode is single-use + PKCE prevents exchange without code_verifierPKCE enforced
Session cookie stolen (XSS)Attacker can impersonate user in browserhttpOnly prevents JavaScript access; SameSite limits scope; session revocation via back-channel logout
IdP tokens (access/refresh) leakedAttacker can call IdP on behalf of userRefresh tokens are short-lived; access tokens are encrypted at rest
state cookie stolenAttacker can forge authorization requestsstate is signed (itsdangerous); 5-minute TTL
  1. Use HTTPS in production — Secure and httpOnly cookies require HTTPS
  2. Rotate encryption keys periodically (v2 feature; manual rotation needed in v1)
  3. Monitor back-channel logout requests — Ensure IdP is sending them
  4. Set reasonable token lifetimes in the IdP (e.g., 15-minute access tokens, 7-day refresh tokens)
  5. Use strong OIDC scopes — Request only what you need (e.g., openid email vs. openid email profile)
  6. Audit user access — Log all authentication and authorization events
  7. Implement a password policy in the IdP
  8. Enforce MFA in the IdP for sensitive users


Last Updated: 2026-04-17