# 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` ```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 ```bash 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 ```bash cd backend && make dev ``` ## Provider Configuration ### Per-Provider Options ```yaml providers: : 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: ```yaml 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?prompt=login" token_endpoint: "https://provider.example.com/oauth2/token" userinfo_endpoint: "https://provider.example.com/oauth2/userinfo" jwks_uri: "https://provider.example.com/oauth2/jwks" ``` Authorization endpoints may include provider query parameters, such as `prompt=login` in the example above. DeerFlow preserves existing encoded query values, blank values and repeated parameters when appending the login request parameters. This applies to both discovered endpoints and configured overrides. Do not include DeerFlow-generated parameters (`response_type`, `client_id`, `redirect_uri`, `scope`, `state`, `nonce`, `code_challenge` or `code_challenge_method`) in the endpoint query. Appending does not replace existing keys, and the identity provider decides how duplicate parameters are resolved. ## Local Keycloak Example This section walks through setting up a local Keycloak instance with Podman or Docker for development. ### 1. Start Keycloak ```bash # 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 1. Open the Keycloak admin console: http://localhost:8080 2. Log in with `admin` / `admin` 3. Create a new realm called `deerflow` 4. In the `deerflow` realm, go to **Clients** → **Create client** 5. 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) 6. After creating the client, go to the **Credentials** tab 7. Copy the **Client secret** — this is your `KEYCLOAK_CLIENT_SECRET` ### 3. Create a Test User 1. In the `deerflow` realm, go to **Users** → **Add user** 2. Set **Username**: `testuser` 3. Set **Email**: `testuser@example.com` 4. Set **Email verified**: On 5. Go to the **Credentials** tab 6. Set a password (e.g. `testpass123`) 7. Set **Temporary**: Off ### 4. Configure DeerFlow Add to `config.yaml`: ```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: ```bash export KEYCLOAK_CLIENT_SECRET="the-secret-from-step-2" ``` ### 5. Restart and Test ```bash cd backend && make dev ``` 1. Open http://localhost:3000 2. On the login page, click **Login with Keycloak** 3. You'll be redirected to Keycloak's login page 4. Log in with `testuser` / `testpass123` 5. 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: ```json { "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 | ### Provider failures An unreachable provider, an HTTP error, and a response body that is not the expected JSON object (for example a `200` maintenance page) all fail the same way. `GET /api/v1/auth/oauth/{provider}` answers `502`, and the callback redirects to `/login?error=sso_failed`. A discovery document without a usable authorization, token or JWKS endpoint counts as failed unless the provider config overrides that endpoint, and it is not cached. A malformed UserInfo body is skipped like a failed UserInfo request: the login continues with the ID token claims. The cause stays in the Gateway log. ## Frontend Callback Flow The frontend handles the post-SSO flow at `/auth/callback`: 1. After the backend processes the OIDC callback and sets cookies, it redirects to `{frontend_base_url}/auth/callback?next=...` 2. The callback page calls `GET /api/v1/auth/me` 3. On success: redirects to the workspace (or the original `next` path) 4. 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