11 KiB
SSO / OIDC Authentication
DeerFlow supports single sign-on (SSO) via any OpenID Connect (OIDC) 2.0 compliant provider. This includes Keycloak, Google Workspace, Azure AD, Okta, and many others.
Architecture
The OIDC flow uses the Authorization Code flow with PKCE (S256) and nonce validation for defense in depth:
Browser Gateway OIDC Provider
│ │ │
│ 1. Click "Login with X" │ │
│ ─────────────────────────▶ │ │
│ │ 2. Build auth URL │
│ │ + state (signed cookie)│
│ │ + PKCE code_challenge │
│ │ + nonce │
│ │ │
│ 3. Redirect to provider │ │
│ ◀────────────────────────── │ │
│ │ │
│ ──────────────────────────────────────────────────────▶ │
│ │ 4. User authenticates │
│ ◀────────────────────────────────────────────────────── │
│ 5. Auth code + state │ │
│ │ │
│ 6. Callback → Gateway │ │
│ ─────────────────────────▶ │ │
│ │ 7. Validate state cookie │
│ │ 8. Exchange code + PKCE │
│ │ ─────────────────────▶ │
│ │ ◀──── tokens ──────────│
│ │ 9. Validate ID token │
│ │ (JWKS, iss, aud, nonce)│
│ │ 10. Fetch userinfo │
│ │ ─────────────────────▶ │
│ │ ◀──── user claims ─────│
│ │ 11. Provision/link user │
│ │ 12. Set session + CSRF │
│ │ cookies │
│ ◀─ redirect to /auth/callback │
│ │ │
│ 13. Frontend detects auth │ │
│ redirects to workspace │ │
Key design decisions:
- State via signed cookie — No server-side session store or Redis needed. The OIDC state (provider, nonce, code_verifier, next path) is signed with the JWT secret and stored in an HttpOnly cookie.
- PKCE + nonce enabled by default — Even though confidential clients could use
client_secret, PKCE provides an extra layer of security. - No email auto-linking — a pre-existing local (email/password) account is never auto-linked to an SSO identity. If the IdP-reported email collides with an existing local account, the SSO login is blocked with a 409 so an SSO login can never seize a password account.
- Existing DeerFlow JWT — After successful OIDC authentication, DeerFlow creates its own JWT session cookie. The OIDC provider's tokens are never exposed to the browser.
Configuration
Step 1: Enable OIDC in config.yaml
auth:
oidc:
enabled: true
frontend_base_url: http://localhost:3000
providers:
keycloak:
display_name: Keycloak
issuer: http://localhost:8080/realms/deerflow
client_id: deerflow
client_secret: $KEYCLOAK_CLIENT_SECRET
redirect_uri: http://localhost:8001/api/v1/auth/callback/keycloak
scopes:
- openid
- email
- profile
Step 2: Set the client secret as an environment variable
export KEYCLOAK_CLIENT_SECRET="your-client-secret"
Or create a .env file in the backend/ directory:
KEYCLOAK_CLIENT_SECRET=your-client-secret
Step 3: Restart the backend
cd backend && make dev
Provider Configuration
Per-Provider Options
providers:
<provider-id>:
display_name: "Display Name" # Shown on the SSO button
issuer: "https://..." # OIDC issuer URL (must match the provider's .well-known/openid-configuration)
client_id: "..." # OAuth2 client ID
client_secret: $SECRET # OAuth2 client secret (supports $ENV_VAR)
redirect_uri: "..." # Optional: explicit callback URL
scopes: # Default: ["openid", "email", "profile"]
- openid
- email
token_endpoint_auth_method: "client_secret_post" # client_secret_post, client_secret_basic, or none
# User provisioning
auto_create_users: true # Auto-create DeerFlow account on first SSO login (default: true)
require_verified_email: true # Reject logins without verified email (default: true)
allowed_email_domains: [] # Restrict to specific domains (default: no restriction)
admin_emails: [] # Auto-grant admin role to these emails (default: none)
# Security features (both enabled by default)
pkce_enabled: true
nonce_enabled: true
# Endpoint overrides (optional)
# Use if the provider has non-standard endpoints.
# authorization_endpoint: "https://..."
# token_endpoint: "https://..."
# userinfo_endpoint: "https://..."
# jwks_uri: "https://..."
Endpoint Overrides
Some providers don't return all endpoints from their .well-known/openid-configuration. You can override specific endpoints:
providers:
my-provider:
display_name: "My Provider"
issuer: "https://provider.example.com"
client_id: "..."
client_secret: $SECRET
authorization_endpoint: "https://provider.example.com/oauth2/authorize"
token_endpoint: "https://provider.example.com/oauth2/token"
userinfo_endpoint: "https://provider.example.com/oauth2/userinfo"
jwks_uri: "https://provider.example.com/oauth2/jwks"
Local Keycloak Example
This section walks through setting up a local Keycloak instance with Podman or Docker for development.
1. Start Keycloak
# Using Podman
podman run -d \
--name keycloak \
-p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.1 \
start-dev
# Using Docker
docker run -d \
--name keycloak \
-p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.1 \
start-dev
2. Create a Realm and Client
- Open the Keycloak admin console: http://localhost:8080
- Log in with
admin/admin - Create a new realm called
deerflow - In the
deerflowrealm, go to Clients → Create client - Configure:
- Client ID:
deerflow - Client authentication: On (makes it a confidential client)
- Standard flow: Enabled
- Valid redirect URIs:
http://localhost:8001/api/v1/auth/callback/keycloak - Valid post logout redirect URIs:
http://localhost:3000/* - Web origins:
http://localhost:8001(or+to allow all redirect URI origins)
- Client ID:
- After creating the client, go to the Credentials tab
- Copy the Client secret — this is your
KEYCLOAK_CLIENT_SECRET
3. Create a Test User
- In the
deerflowrealm, go to Users → Add user - Set Username:
testuser - Set Email:
testuser@example.com - Set Email verified: On
- Go to the Credentials tab
- Set a password (e.g.
testpass123) - Set Temporary: Off
4. Configure DeerFlow
Add to config.yaml:
auth:
oidc:
enabled: true
frontend_base_url: http://localhost:3000
providers:
keycloak:
display_name: Keycloak
issuer: http://localhost:8080/realms/deerflow
client_id: deerflow
client_secret: $KEYCLOAK_CLIENT_SECRET
redirect_uri: http://localhost:8001/api/v1/auth/callback/keycloak
scopes:
- openid
- email
- profile
Set the secret:
export KEYCLOAK_CLIENT_SECRET="the-secret-from-step-2"
5. Restart and Test
cd backend && make dev
- Open http://localhost:3000
- On the login page, click Login with Keycloak
- You'll be redirected to Keycloak's login page
- Log in with
testuser/testpass123 - After successful authentication, you'll be redirected back to the DeerFlow workspace
Account Settings for SSO Users
When a user logs in via SSO, the account settings page detects this (via the oauth_provider field returned by /api/v1/auth/me) and:
- Displays the SSO provider name (e.g. "Keycloak") in the profile section
- Replaces the password change form with an informational message
- Password changes must be done through the SSO provider, not DeerFlow
The backend also rejects password change requests for OAuth users:
{
"code": "invalid_credentials",
"message": "OAuth users cannot change password"
}
Public API Endpoints
| Endpoint | Description |
|---|---|
GET /api/v1/auth/providers |
Returns list of enabled SSO providers (safe metadata only) |
GET /api/v1/auth/oauth/{provider} |
Initiates SSO login, redirects to the OIDC provider |
GET /api/v1/auth/callback/{provider} |
OIDC callback — exchanges code, creates session, redirects to frontend |
Frontend Callback Flow
The frontend handles the post-SSO flow at /auth/callback:
- After the backend processes the OIDC callback and sets cookies, it redirects to
{frontend_base_url}/auth/callback?next=... - The callback page calls
GET /api/v1/auth/me - On success: redirects to the workspace (or the original
nextpath) - On failure: redirects to
/login?error=sso_failed
Security Notes
- State cookies are HttpOnly, SameSite=Lax, Max-Age=300 seconds, and signed with the JWT secret
- PKCE prevents authorization code interception attacks
- Nonce prevents ID token replay attacks
- UserInfo sub check ensures the UserInfo response matches the ID token subject
- Reject alg=none — ID tokens with algorithm "none" are always rejected
- No email auto-linking — SSO accounts are always separate from email/password accounts. An email collision with an existing local account blocks the SSO login (409) rather than merging the two.
- Verified email requirement — SSO users must have verified emails by default