mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(auth): map malformed OIDC provider bodies to OIDCError The SSO login route answers OIDCError with 502 and the callback with an sso_failed redirect, but oidc.py raised OIDCError only for transport and HTTP status errors. A provider answering 200 with an HTML page or any non-object JSON raised JSONDecodeError, KeyError or AttributeError instead, so both routes failed with a 500. A malformed UserInfo body also aborted the login, although UserInfo is best-effort. Decode discovery, token, JWKS and UserInfo bodies through one helper that requires a JSON object, require string endpoints and issuer, reject a non-list JWKS keys field, skip non-object JWK entries, and wrap a malformed ID token header as OIDCValidationError. Build discovery metadata before caching it so a rejected document is not served from the cache. * docs(changelog): reference #7258 for the OIDC malformed body fix * fix(auth): report a falsy non-string OIDC issuer as wrong-typed Check the issuer's type before its presence so 0, false and [] get the non-string message instead of "missing the issuer field"; an absent or empty issuer still reports missing. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
303 lines
12 KiB
Markdown
303 lines
12 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?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
|