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.
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.
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
- profileexport KEYCLOAK_CLIENT_SECRET="your-client-secret"Or create a .env file in the backend/ directory:
KEYCLOAK_CLIENT_SECRET=your-client-secret
cd backend && make devproviders:
<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://..."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"This section walks through setting up a local Keycloak instance with Podman or Docker for development.
# 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- 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
- 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
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
- profileSet the secret:
export KEYCLOAK_CLIENT_SECRET="the-secret-from-step-2"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
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"
}| 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 |
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
- 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