280 lines
11 KiB
Markdown
280 lines
11 KiB
Markdown
# 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:
|
|
<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:
|
|
|
|
```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"
|
|
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
|
|
|
|
```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 |
|
|
|
|
## 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 |