mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
555 lines
34 KiB
Markdown
555 lines
34 KiB
Markdown
---
|
||
title: server — device flow
|
||
---
|
||
|
||
# device flow
|
||
|
||
OAuth 2.0 Device Authorization Grant (RFC 8628) for `difyctl auth login`. Two branches — account and External SSO — sharing one code-entry page and one Redis state machine.
|
||
|
||
Companion: `tokens.md` (storage), `middleware.md` (post-mint auth), `endpoints.md` (full endpoint table), `security.md` (rate limits + audit + anti-frame).
|
||
|
||
## Shape
|
||
|
||
CLI shows a one-time code + URL; user opens the URL on any device with a browser; server polls for approval. No PKCE + localhost callback.
|
||
|
||
## Ephemeral state (Redis)
|
||
|
||
Each attempt = short-lived state machine, 15-min TTL, single-use.
|
||
|
||
```
|
||
device_code:{device_code_value} → JSON:
|
||
{
|
||
"user_code": "ABCD-1234",
|
||
"client_id": "difyctl",
|
||
"device_label": "difyctl on gareth-mbp",
|
||
"status": "pending" | "approved" | "denied",
|
||
"subject_email": null | "<email>", // set on approval
|
||
"account_id": null | "<uuid>", // may stay null for SSO-only
|
||
"minted_token": null | "dfoa_<43 chars>" | "dfoe_<43 chars>",
|
||
"token_id": null | "<uuid>", // oauth_access_tokens.id after mint
|
||
"created_at": "<iso8601>",
|
||
"created_ip": "<caller IP at /device/code>",
|
||
"last_poll_at": "<iso8601>" // for slow_down
|
||
}
|
||
|
||
user_code:{user_code_value} → "<device_code_value>" (reverse lookup)
|
||
```
|
||
|
||
Both keys `EX 900` (15 min). Matches RFC 8628 `expires_in`.
|
||
|
||
**Code format:**
|
||
|
||
- `device_code` = `dc_<32 base64url chars>` (~256 bit). Never user-facing.
|
||
- `user_code` = 8 chars `XXXX-XXXX`, uppercase, reduced alphabet (Crockford-style, ambiguous chars stripped). Low entropy by design — humans type it. Defended by rate-limit + 15-min TTL + single-use.
|
||
|
||
**Alphabet (literal, 30 chars):**
|
||
|
||
```
|
||
3 4 5 6 7 8 9 A B C D E F G H J K L M N P Q R S T U V W X Y
|
||
```
|
||
|
||
Excluded: `0` (vs `O`), `1` (vs `I`/`l`), `2` (vs `Z`), `O` (vs `0`), `I` (vs `1`), `Z` (vs `2`). Server normalizes input — uppercases, strips hyphen, rejects any char outside the alphabet with `400 invalid_user_code`.
|
||
|
||
**Collision handling.** 30⁸ ≈ 6.5 × 10¹¹ combinations. Silent overwrite would cross-authorize users. `/device/code` atomically claims `user_code` via Redis `SET NX EX` in a 5-attempt retry loop. After 5 collisions → `503 user_code_exhausted` (operator alarm — never seen in normal traffic). `device_code` entropy high enough that no such check is needed.
|
||
|
||
**State transitions:**
|
||
|
||
```
|
||
pending → user clicks Authorize at /device → approved
|
||
pending → user clicks Cancel → denied
|
||
pending → 900s TTL elapses → evicted
|
||
|
||
approved → CLI poll reads minted_token → DEL both keys
|
||
denied → CLI poll reads status=denied → DEL both keys
|
||
```
|
||
|
||
**Mint-at-approve semantics.** `oauth_access_tokens` row is written when the user clicks Authorize, not during CLI poll. Redis `minted_token` holds plaintext until the CLI poll retrieves it; then full state DEL'd (plaintext lives in Redis for seconds). On transition to `approved`, `EXPIRE` shrinks the key to `max(remaining_ttl, 60s)`. User-aborted approve (CLI never polls) leaves an orphaned row; user revokes via `auth devices list/revoke`.
|
||
|
||
## Account branch
|
||
|
||
User authenticates via password / email-code / social OAuth / account-SSO at `/signin`, returns to `/device`, clicks Authorize → mints `dfoa_`.
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
autonumber
|
||
actor User
|
||
participant CLI as difyctl
|
||
participant Browser
|
||
participant API as Dify API Service (Flask)
|
||
participant EE as Enterprise Go
|
||
participant IdP
|
||
participant KC as OS Keychain
|
||
|
||
User->>CLI: difyctl auth login
|
||
CLI->>API: POST /openapi/v1/oauth/device/code
|
||
API-->>CLI: {device_code, user_code=ABCD-1234}
|
||
CLI->>Browser: open /device (no code in URL — user types it)
|
||
|
||
loop every `interval` sec
|
||
CLI->>API: POST /openapi/v1/oauth/device/token
|
||
API-->>CLI: authorization_pending
|
||
end
|
||
|
||
Browser->>Browser: GET /device
|
||
Note over Browser: Code-entry page.
|
||
User->>Browser: type ABCD-1234, click Continue
|
||
Note over Browser: SPA holds user_code in state.<br/>No console session → render LoginForm.
|
||
User->>Browser: click "Sign in with Dify account"
|
||
Note over Browser: setPostLoginRedirect('/device?user_code=ABCD-1234')<br/>router.push('/signin')
|
||
|
||
Browser->>Browser: GET /signin
|
||
User->>Browser: click login method (password / email-code / SSO / …)
|
||
|
||
alt Account-SSO (return_to plumbed via signed state)
|
||
Browser->>EE: GET /enterprise/sso/saml/login?intent=account_login&return_to=/device?user_code=…
|
||
Note over EE: Sign state envelope (JWS):<br/>{intent, return_to, nonce}
|
||
EE-->>Browser: IdP authorize URL (state=signed JWS)
|
||
Browser->>IdP: authorize
|
||
User->>IdP: credentials + MFA
|
||
IdP-->>Browser: 302 → Enterprise ACS
|
||
Browser->>EE: POST /sso/saml/acs
|
||
Note over EE: Verify state JWS + consume state nonce.<br/>dispatchSSOCallback: Intent=account_login<br/>→ mintWebappPassport → 302 state.ReturnTo
|
||
EE-->>Browser: 302 → /device?user_code=… (Set-Cookie: console_session)
|
||
else Password / email-code / social OAuth (no return_to plumb)
|
||
Note over Browser: User lands on /signin default post-login destination.<br/>Manually navigates back to /device URL from CLI terminal.
|
||
end
|
||
|
||
Browser->>Browser: GET /device?user_code=…
|
||
Note over Browser: Console session present.<br/>Render Authorize with device_label + email + user_code.
|
||
User->>Browser: click Authorize
|
||
|
||
Browser->>API: POST /openapi/v1/oauth/device/approve<br/>Cookie: console_session
|
||
Note over API: Validate session. Mint dfoa_.<br/>INSERT/UPDATE oauth_access_tokens<br/>(account_id=[user], scope=[full]).<br/>Redis device state → approved.
|
||
API-->>Browser: {status: "approved"}
|
||
|
||
CLI->>API: POST /openapi/v1/oauth/device/token (next poll)
|
||
API-->>CLI: {access_token: "dfoa_...", account, workspaces, scopes:[full]}
|
||
CLI->>KC: store dfoa_ + metadata
|
||
CLI-->>User: ✓ Signed in as user@example.com
|
||
```
|
||
|
||
### Account-branch endpoints
|
||
|
||
All endpoint contracts (request/response, rate limits, auth): `endpoints.md`.
|
||
|
||
- `POST /openapi/v1/oauth/device/code` (unauthenticated) — CLI initiates. Response `interval` = **5 (RFC 8628 default)**, hardcoded server-side. CLI polls every `interval` seconds; clamps to `[1, 60]` defensively, treats `0` / negative / absent as `5`.
|
||
- `POST /openapi/v1/oauth/device/token` (unauthenticated, rate-limited) — CLI polls.
|
||
- `GET /openapi/v1/oauth/device/lookup` (public + rate-limit) — web validates typed code.
|
||
- `POST /openapi/v1/oauth/device/approve` (session) — web mints `dfoa_`.
|
||
- `POST /openapi/v1/oauth/device/deny` (session) — web denies.
|
||
|
||
### Approve implementation
|
||
|
||
`POST /openapi/v1/oauth/device/approve`:
|
||
|
||
1. `GET user_code:{user_code}` → device_code. Miss → 404.
|
||
2. `GET device_code:{device_code}`. Status ≠ pending → 409.
|
||
3. Resolve subject from session: `subject_email = session.email`; `account_id` = matching account, or NULL.
|
||
4. Read TTL: `ttl_days = Policy.OAuthTTLDays()`.
|
||
5. Generate `dfoa_` token. SHA-256.
|
||
6. **Upsert** `oauth_access_tokens` keyed on `(subject_email, subject_issuer, client_id, device_label)`. `device_label` from Redis state; `subject_issuer = NULL` on account branch:
|
||
|
||
```sql
|
||
-- Caller normalizes :issuer before this query:
|
||
-- account branch → :issuer = 'dify:account' (sentinel)
|
||
-- SSO branch → :issuer = <IdP entity_id / OIDC issuer URL>
|
||
|
||
-- Capture old hash to invalidate Redis cache after upsert
|
||
SELECT token_hash AS old_hash INTO <old_hash>
|
||
FROM oauth_access_tokens
|
||
WHERE subject_email = :email
|
||
AND subject_issuer = :issuer
|
||
AND client_id = :client AND device_label = :label AND revoked_at IS NULL;
|
||
|
||
INSERT INTO oauth_access_tokens
|
||
(subject_email, subject_issuer, account_id, client_id, device_label, prefix, token_hash, expires_at)
|
||
VALUES (:email, :issuer, :account_id, :client, :label, :prefix, :new_hash,
|
||
NOW() + (:ttl_days || ' days')::interval)
|
||
ON CONFLICT (subject_email, subject_issuer, client_id, device_label) WHERE revoked_at IS NULL
|
||
DO UPDATE SET
|
||
token_hash = EXCLUDED.token_hash,
|
||
prefix = EXCLUDED.prefix,
|
||
account_id = EXCLUDED.account_id, -- handles CE→EE account provisioning
|
||
expires_at = EXCLUDED.expires_at, -- rotate refreshes TTL from current policy
|
||
created_at = NOW(),
|
||
last_used_at = NULL
|
||
RETURNING id;
|
||
```
|
||
|
||
`ON CONFLICT` matches the partial unique index `uq_oauth_active_per_device` (see `tokens.md §oauth_access_tokens`). Account branch writes the `'dify:account'` sentinel into `subject_issuer` at mint time so the column is never NULL — Postgres' default NULL-as-distinct semantics don't apply, and the plain partial unique index enforces "one active row per (email, issuer, client, device)" without needing a COALESCE expression index. Rows hard-expired via `tokens.md §Detection + hard-expire` (`revoked_at IS NOT NULL`) are excluded — so re-login after hard-expire takes the INSERT branch.
|
||
|
||
- First login from this device → INSERT (new row, new `id`).
|
||
- Re-login same device → UPDATE (same `id`, fresh `token_hash` + `created_at`). Old plaintext invalid at commit.
|
||
- Login from different device → INSERT (new row, independent).
|
||
|
||
7. **Invalidate old Redis on rotation:** `DEL auth:token:{old_hash}`. No-op if no prior row. Without this, old cached entry could stay valid up to 60 s.
|
||
8. Update Redis `device_code:{device_code}` → `{status=approved, subject_email, account_id, minted_token=dfoa_..., token_id, ...}`. Scope not persisted; computed at CLI-poll response time. `EXPIRE` to `max(remaining_ttl, 60s)`.
|
||
9. **Mint policy validation.** For account branch: scope = `[full]`. SSO branch (see §External SSO branch): scope = `[apps:run, apps:read:permitted-external]`. Cross-subject scope minting → 400 `mint_policy_violation` before INSERT/UPDATE. CE deploys reject `dfoe_` mint entirely.
|
||
10. Emit audit `oauth.device_flow_approved` (payload: `subject_email`, `account_id` nullable, `client_id`, `device_label`, `scopes`, `token_id`, `subject_type`, `rotated: true|false`, `expires_at`).
|
||
11. Return `{ status: "approved" }`.
|
||
|
||
### Deny
|
||
|
||
`POST /openapi/v1/oauth/device/deny` — lookup same as approve, update Redis `{status=denied, …}` keeping TTL. Emit `oauth.device_flow_denied`. Return `{ status: "denied" }`.
|
||
|
||
### Poll
|
||
|
||
`POST /openapi/v1/oauth/device/token`:
|
||
|
||
1. `GET device_code:{device_code}`. Miss → `{error: "expired_token"}`.
|
||
2. If `last_poll_at < interval` sec ago → `{error: "slow_down"}`. Update `last_poll_at`.
|
||
3. Dispatch on `status`:
|
||
- `pending` → `{error: "authorization_pending"}`.
|
||
- `denied` → `{error: "access_denied"}`. `DEL` both keys.
|
||
- `approved` → proceed.
|
||
4. **Validate minted row still live:** `SELECT 1 FROM oauth_access_tokens WHERE id=:token_id AND revoked_at IS NULL AND expires_at > NOW() AND token_hash IS NOT NULL`. Miss → token was revoked or hard-expired between approve and poll. Return `{error: "access_denied"}`, `DEL` both keys.
|
||
5. **Cross-IP audit:** if request IP ≠ `/device/code` creation IP, emit `oauth.device_code_cross_ip_poll` (payload: `token_id`, `subject_email`, `creation_ip`, `poll_ip`). Does not block — RFC 8628 allows this; audit enables admin detection.
|
||
6. Return success body. `DEL` both keys.
|
||
|
||
Success (account subject):
|
||
|
||
```json
|
||
{
|
||
"token": "dfoa_...",
|
||
"expires_at": null,
|
||
"account": { "id": "acc_...", "email": "...", "name": "..." },
|
||
"workspaces": [{ "id": "ws_...", "name": "...", "role": "owner" }],
|
||
"default_workspace_id": "ws_..."
|
||
}
|
||
```
|
||
|
||
External SSO subject: `token = dfoe_...`, `account: null`, `workspaces: []`, plus `subject_type: "external_sso"`, `subject_email`, `subject_issuer`.
|
||
|
||
## External SSO branch
|
||
|
||
EE-only. SSO-verified IdP users without a Dify `accounts` row authenticate at the IdP, return with a signed external-subject assertion, accept a short-lived cookie, then click Authorize → mints `dfoe_`.
|
||
|
||
All four External-SSO API Service endpoints (`sso-initiate`, `sso-complete`, `approval-context`, `approve-external`) are gated by the `@enterprise_only` decorator. CE builds short-circuit to 404 before any business logic runs. Account-branch endpoints (`/openapi/v1/oauth/device/{code,token,lookup,approve,deny}`) are **not** decorated.
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
autonumber
|
||
actor User
|
||
participant CLI as difyctl
|
||
participant Browser
|
||
participant API as Dify API Service (Flask)
|
||
participant EE as Enterprise Go
|
||
participant IdP
|
||
participant KC as OS Keychain
|
||
|
||
User->>CLI: difyctl auth login
|
||
CLI->>API: POST /openapi/v1/oauth/device/code
|
||
API-->>CLI: {device_code, user_code}
|
||
|
||
CLI->>Browser: open /device
|
||
loop every `interval` sec
|
||
CLI->>API: POST /openapi/v1/oauth/device/token
|
||
API-->>CLI: authorization_pending
|
||
end
|
||
|
||
Browser->>API: GET /device
|
||
API-->>Browser: Code-entry page
|
||
User->>Browser: type ABCD-1234, click Continue
|
||
Note over Browser: SPA holds user_code in state.<br/>No session → render LoginForm.
|
||
User->>Browser: click "Sign in with SSO"
|
||
|
||
Browser->>API: GET /openapi/v1/oauth/device/sso-initiate?user_code=ABCD-1234
|
||
API->>EE: SSOInitiate(intent="device_flow", user_code, redirect_url)
|
||
Note over EE: Sign state envelope (JWS):<br/>{Intent, UserCode, Nonce, IdPCallbackURL}
|
||
EE-->>API: IdP authorize URL (redirect_uri = existing Enterprise callback,<br/>state = signed JWS)
|
||
API-->>Browser: 302 → IdP authorize URL
|
||
|
||
Browser->>IdP: authorize
|
||
User->>IdP: credentials + MFA
|
||
IdP-->>Browser: 302 → Enterprise callback (state echoed)
|
||
|
||
Browser->>EE: POST /sso/saml/acs
|
||
Note over EE: Verify state JWS + consume state nonce.<br/>dispatchSSOCallback:<br/> Intent="device_flow" → signExternalSubjectAssertion<br/> (email, issuer, user_code, nonce, kid).
|
||
EE-->>Browser: 302 → /openapi/v1/oauth/device/sso-complete?sso_assertion=[JWS]
|
||
|
||
Browser->>API: GET /openapi/v1/oauth/device/sso-complete?sso_assertion=…
|
||
Note over API: Validate blob (JWS+kid, 5-min TTL,<br/>consume nonce, verify user_code pending).<br/>Mint device_approval_grant cookie<br/>(HttpOnly, Path=/openapi/v1/oauth/device, 5-min TTL,<br/>csrf_token inside).
|
||
API-->>Browser: Set-Cookie: device_approval_grant<br/>302 → /device?sso_verified=1
|
||
|
||
Browser->>API: GET /openapi/v1/oauth/device/approval-context (cookie auto-attached)
|
||
API-->>Browser: {subject_email, subject_issuer, user_code, csrf_token, expires_at}
|
||
Note over Browser: Render "Authorize difyctl as sso-user@partner.com?"
|
||
User->>Browser: click Authorize
|
||
|
||
Browser->>API: POST /openapi/v1/oauth/device/approve-external<br/>Cookie + X-CSRF-Token
|
||
Note over API: Validate cookie + CSRF.<br/>Verify body user_code == cookie user_code.<br/>Consume nonce. Mint dfoe_.<br/>INSERT/UPDATE oauth_access_tokens<br/>(account_id=NULL, email, issuer, scope=[apps:run, apps:read:permitted-external]).<br/>Redis device state → approved. Clear cookie.
|
||
API-->>Browser: {status: "approved"}
|
||
|
||
CLI->>API: POST /openapi/v1/oauth/device/token (next poll)
|
||
API-->>CLI: {access_token: "dfoe_...", subject_type:"external_sso", email, issuer, scopes:[apps:run, apps:read:permitted-external]}
|
||
CLI->>KC: store dfoe_ + metadata
|
||
CLI-->>User: ✓ Signed in as sso-user@partner.com
|
||
```
|
||
|
||
### Enterprise: SSO state envelope
|
||
|
||
State passed to the IdP (SAML RelayState / OIDC `state` / OAuth2 `state`) is a compact JWS envelope, signed HS256 with the shared Dify secret (`SECRET_KEY` on api / `DIFY_SECRET_KEY` on Enterprise). `kid` header selects the active key. One secret backs state envelope + subject assertion + approval cookie.
|
||
|
||
Envelope claims:
|
||
|
||
| Claim | Meaning |
|
||
|---|---|
|
||
| `intent` | `"webapp"` (legacy) / `"account_login"` / `"device_flow"`. Empty = `"webapp"` |
|
||
| `user_code` | Populated when `intent = "device_flow"` |
|
||
| `nonce` | Per-initiate; consumed at callback via `SET NX` to defeat state-JWS replay |
|
||
| `return_to` | Post-login target (e.g., `/device?user_code=X`); exact-path whitelisted |
|
||
| `idp_callback_url` | Existing Enterprise-registered callback (IdP-facing) |
|
||
| `app_code` | Empty unless `intent = "webapp"` |
|
||
| `redirect_url` | API Service redirect target |
|
||
|
||
Enterprise's callbacks reject any state whose signature fails. Signed state is mandatory — no phased rollout flag.
|
||
|
||
**Three intents (plus legacy empty/`"webapp"`):**
|
||
|
||
| `intent` | Behavior |
|
||
|---|---|
|
||
| `""` / `"webapp"` | Existing webapp-passport flow. `return_to` ignored. |
|
||
| `"account_login"` | Account-branch device-flow handoff. Mints console session, then 302s to `return_to` instead of default `/apps`. |
|
||
| `"device_flow"` | SSO-only device-flow handoff. Skips console-session/passport mint; signs external subject assertion; 302s to `idp_callback_url?sso_assertion=<JWS>`. |
|
||
|
||
Initiate handlers (SAML / OIDC / OAuth2 + external variants):
|
||
|
||
- `intent="device_flow"`: skip the webapp readiness check, allow empty `app_code`, populate `intent`/`user_code`/`nonce`.
|
||
- `intent="account_login"`: populate `intent`; require non-empty `return_to`.
|
||
- `return_to` present → validate via exact-path whitelist (`path == "/device"`, query keys ⊆ `{user_code, sso_verified}`). Anything else → 400 `invalid_return_to`.
|
||
- Sign state JWS with `kid`, attach to outbound IdP `state` / RelayState.
|
||
|
||
Callbacks verify state signature + `kid`, consume state nonce (`SET NX EX state_nonce:{nonce} 600` — defeats re-POST replay on ACS), then dispatch on `intent`:
|
||
|
||
- `webapp` → `mintWebappPassport`.
|
||
- `account_login` → `mintWebappPassport`, override redirect target with `return_to`.
|
||
- `device_flow` → sign short-lived external subject assertion (no `end_user` row, no webapp passport).
|
||
|
||
`device_flow` branch signs a short-lived external subject assertion:
|
||
|
||
```
|
||
302 Location: <IdPCallbackURL>?sso_assertion=<signed_blob>
|
||
|
||
Signed blob (compact JWS, HS256, shared Dify `SECRET_KEY`):
|
||
{
|
||
"sub_type": "external_sso",
|
||
"email": "<verified email from IdP>",
|
||
"issuer": "<IdP entity_id or issuer URL>",
|
||
"user_code": "<from state.UserCode>",
|
||
"nonce": "<from state.Nonce>",
|
||
"kid": "api-ee-shared-v1",
|
||
"iat": <now>,
|
||
"exp": <now + 300>,
|
||
"aud": "api.device_flow.external_subject_assertion"
|
||
}
|
||
```
|
||
|
||
No `WebSSOLogin` / `WebSSOExternalLogin` call on device_flow path — no `end_user` row, no webapp passport. Enterprise's only job: verify IdP assertion, hand API Service verified identity.
|
||
|
||
### API Service: `sso-initiate`
|
||
|
||
`GET /openapi/v1/oauth/device/sso-initiate?user_code=<required>`. API-internal, not IdP-registered.
|
||
|
||
1. **Clear any stale `device_approval_grant` cookie** — `Set-Cookie: device_approval_grant=; Max-Age=0; Path=/openapi/v1/oauth/device`. Defends against cross-tab mixing and stale cookies from Back-button navigation.
|
||
2. Validate `user_code` maps to device_code in `pending`. Absent / unknown / not-pending → 400 `invalid_user_code`.
|
||
3. Read workspace-wide SSO config. None configured → 404 `sso_not_configured`.
|
||
4. Determine configured IdP type (exactly one per workspace today — SAML OR OIDC OR OAuth2).
|
||
5. Call matching Enterprise initiate with `intent="device_flow"` + `redirect_url="<host>/openapi/v1/oauth/device/sso-complete"` + `user_code` + no `app_code`.
|
||
6. Enterprise returns IdP auth URL with signed state attached. API Service 302s user to IdP.
|
||
|
||
**Decorator order.** `@enterprise_only` must run **before** `@rate_limit("60/hour/ip")` — otherwise CE 404s consume the bucket. Flask stack: `@enterprise_only → @rate_limit → handler`.
|
||
|
||
### API Service: `sso-complete`
|
||
|
||
`GET /openapi/v1/oauth/device/sso-complete?sso_assertion=<signed_blob>`.
|
||
|
||
1. Validate `sso_assertion` JWS signature with key identified by blob `kid` header. Invalid / expired (>5 min) / wrong `aud` / unknown `kid` → 400 `invalid_sso_assertion`.
|
||
2. Consume nonce: `SET NX EX sso_assertion_nonce:{nonce} 600`. Replay → 400.
|
||
3. Extract `subject_email`, `subject_issuer`, `user_code` from blob.
|
||
4. Verify `user_code` still maps to device_code in `pending`. Not pending → 409 — user retries from `/device` without burning another IdP round-trip.
|
||
5. **Email-collision reject.** If `subject_email` matches an active Dify Account row (case-insensitive — `func.lower(Account.email) == normalized`, filtered to `AccountStatus.ACTIVE`) → emit `oauth.device_flow_rejected` audit (payload: `subject_type="external_sso"`, `subject_email`, `subject_issuer`, `reason="email_belongs_to_dify_account"`), 302 → `/device?sso_error=email_belongs_to_dify_account`. The SSO branch is reserved for IdP users without a Dify account; account-SSO users must take Button 1.
|
||
6. Mint `device_approval_grant` cookie (see §Approval grant cookie). Fresh `nonce`, fresh `csrf_token`, 5-min TTL, signed with active API-side key.
|
||
7. `Set-Cookie: device_approval_grant=<jws>; HttpOnly; Secure; SameSite=Lax; Path=/openapi/v1/oauth/device; Max-Age=300`.
|
||
8. 302 → `/device?sso_verified=1`.
|
||
|
||
Cookie-then-redirect means the SPA detects SSO completion via a lookup call, not URL-fragment parsing. No JWT ever reaches page JS.
|
||
|
||
### API Service: `approval-context`
|
||
|
||
`GET /openapi/v1/oauth/device/approval-context`. No body. Browser attaches `device_approval_grant` cookie automatically (path-match).
|
||
|
||
1. Read + validate cookie (signature, `aud`, `exp`, kid resolvable). Missing / invalid → 401 `no_session`.
|
||
2. Return `{ subject_email, subject_issuer, user_code, csrf_token, expires_at }`.
|
||
|
||
Nonce NOT consumed here. Lookup idempotent — SPA may fetch on mount, refresh, React strict-mode double-render.
|
||
|
||
### API Service: `approve-external`
|
||
|
||
`POST /openapi/v1/oauth/device/approve-external`. Cookie-authed + CSRF double-submit.
|
||
|
||
**Subject invariant:** only External SSO subjects (no `accounts` row) reach this endpoint. The email-collision check at `sso-complete` (step 5 above) plus the explicit re-check here defend in depth — a cookie surviving an aborted `sso-complete` cannot promote an account email to an external SSO token.
|
||
|
||
Request headers: `Cookie: device_approval_grant=<jws>` + `X-CSRF-Token: <csrf_token from lookup>`.
|
||
Request body: `{ "user_code": "ABCD-1234" }`.
|
||
|
||
1. Validate cookie: signature, `aud == "api.device_flow.approval_grant"`, `exp > now()`, kid resolvable. Fail → 401 `invalid_session`.
|
||
2. Validate CSRF: header `X-CSRF-Token` == cookie claim `csrf_token`. Mismatch / absent → 403 `csrf_mismatch`.
|
||
3. Validate binding: body `user_code` == cookie claim `user_code`. Mismatch → 400 `user_code_mismatch`.
|
||
4. `GET user_code:{user_code}` → device_code. Miss → 404.
|
||
5. `GET device_code:{device_code}`. Status ≠ pending → 409.
|
||
6. **Email-collision reject (defense in depth).** If cookie `subject_email` matches an active Dify Account row (case-insensitive `func.lower(Account.email) == normalized`, filtered to `AccountStatus.ACTIVE`) → emit `oauth.device_flow_rejected` audit, 403 `email_belongs_to_dify_account`.
|
||
7. Claim cookie nonce: `SET NX EX device_approval_grant_nonce:{nonce} 600`. Already claimed → 401 `session_already_consumed`.
|
||
8. Resolve subject from cookie claims: `subject_email`, `subject_issuer`, `account_id = NULL`.
|
||
9. Read TTL: `ttl_days = Policy.OAuthTTLDays()`.
|
||
10. **Mint policy validation.** `dfoe_` mint locked to `scopes = [apps:run, apps:read:permitted-external]`. Any other requested scope → 400 `mint_policy_violation`. Cross-subject (e.g., approve-external attempting `[full]`) blocked here.
|
||
11. Generate `dfoe_` token, hash. Upsert `oauth_access_tokens` — same `ON CONFLICT` upsert as account branch, keyed on `(subject_email, subject_issuer, client_id, device_label)` with `account_id = NULL` and `subject_issuer` populated from cookie claim. `device_label` from Redis `device_code:{device_code}`.
|
||
12. `DEL auth:token:{old_hash}` on rotation.
|
||
13. Update Redis `device_code:{device_code}` → `{status=approved, subject_email, account_id:null, minted_token, token_id, …}`. `EXPIRE` to `max(remaining_ttl, 60s)`.
|
||
14. Emit `oauth.device_flow_approved` with `subject_type: "external_sso"`, `subject_email`, `subject_issuer`, `client_id`, `device_label`, `scopes: [apps:run, apps:read:permitted-external]`, `rotated`, `expires_at`.
|
||
15. Respond: `Set-Cookie: device_approval_grant=; Max-Age=0; Path=/openapi/v1/oauth/device`. Body `{ status: "approved" }`.
|
||
|
||
CLI poll at `POST /openapi/v1/oauth/device/token` picks up the token. Response: `account: null`, `workspaces: []`, `subject_email` populated.
|
||
|
||
## Approval grant cookie
|
||
|
||
SSO branch needs to carry IdP-authenticated identity from SSO callback to approve-external endpoint **without** granting console / webapp / `/v1/*` access. Existing webapp-SSO JWT is app-scoped — unsuitable.
|
||
|
||
`device_approval_grant` = short-lived compact JWS cookie (HS256, shared Dify `SECRET_KEY`), path-scoped to `/openapi/v1/oauth/device`. Zero authority beyond approving the specific device_code it's bound to. `HttpOnly` + `Path=/openapi/v1/oauth/device` + `SameSite=Lax`.
|
||
|
||
**Cookie envelope:**
|
||
|
||
```json
|
||
{
|
||
"iss": "<dify-host>",
|
||
"aud": "api.device_flow.approval_grant",
|
||
"subject_email": "user@example.com",
|
||
"subject_issuer": "https://idp.example.com",
|
||
"user_code": "ABCD-1234",
|
||
"nonce": "<random>",
|
||
"csrf_token": "<random>",
|
||
"kid": "api-ee-shared-v1",
|
||
"exp": <now + 300>,
|
||
"iat": <now>
|
||
}
|
||
```
|
||
|
||
**Cookie attributes:**
|
||
|
||
```
|
||
Set-Cookie: device_approval_grant=<jws>;
|
||
HttpOnly; Secure; SameSite=Lax;
|
||
Path=/openapi/v1/oauth/device; Max-Age=300
|
||
```
|
||
|
||
**Isolation:**
|
||
|
||
| Session | Valid on | TTL | Reusable |
|
||
|---|---|---|---|
|
||
| Console account session | `/console/api/*` | hours, refreshable | yes |
|
||
| Webapp passport | `/passport` + webapp routes, scoped to `app_code` | per-app-configured | yes |
|
||
| **`device_approval_grant`** | **`/openapi/v1/oauth/device/approval-context` + `/openapi/v1/oauth/device/approve-external` only** | **5 min, one-shot** | **no** (single nonce, bound to single `user_code`) |
|
||
|
||
**Enforcement:**
|
||
|
||
1. **Path scoping.** `Path=/openapi/v1/oauth/device` — browser does not attach to other URLs. Console / webapp / `/v1/*` / other `/openapi/v1/*` middlewares never see this cookie.
|
||
2. **Audience binding.** Validator checks `aud == "api.device_flow.approval_grant"`. Any future cookie with different `aud` → cross-reject.
|
||
3. **One-shot nonce.** `SET NX EX device_approval_grant_nonce:{nonce} 600`. Replay → 401 `session_already_consumed`. Nonce burned at approve-external success, not at lookup — user can hit lookup repeatedly without burning.
|
||
4. **User-code binding.** Body `user_code` must equal cookie claim. Prevents leaked cookie from approving a different pending device_code.
|
||
5. **CSRF double-submit.** Approve must include `X-CSRF-Token` matching cookie claim `csrf_token`. Cookie alone insufficient. `csrf_token` + `nonce` = ≥16 bytes (128-bit) CSPRNG.
|
||
6. **Short TTL.** 5 min — covers human approval delay, bounds leak exposure.
|
||
|
||
**Nonce TTL 2×.** Redis nonce keys use 600 s (10 min) while cookie / assertion lifetimes are 300 s (5 min). The 2× ratio defeats late-replay if clock skew between Redis and JWS issuer allows a just-expired cookie to verify as non-expired when Redis sees the key gone.
|
||
|
||
**Three-nonce model.** Each nonce defends a distinct hop. Removing any one opens a replay class.
|
||
|
||
| Nonce | Origin | Consumed at | Redis key | Defeats |
|
||
|---|---|---|---|---|
|
||
| `state.Nonce` | Enterprise `sso-initiate` | Enterprise ACS / callback (`SET NX`) | `state_nonce:{n}` on Enterprise | Re-POST replay on IdP callback |
|
||
| subject-assertion `nonce` | Enterprise `dispatchSSOCallback` | API Service `/openapi/v1/oauth/device/sso-complete` (`SET NX`) | `sso_assertion_nonce:{n}` on API Service | Replay of leaked `?sso_assertion=…` URL |
|
||
| cookie `nonce` | API Service `/openapi/v1/oauth/device/sso-complete` | API Service `/openapi/v1/oauth/device/approve-external` (`SET NX`) | `device_approval_grant_nonce:{n}` on API Service | Replay of approval-grant cookie after prior approve |
|
||
|
||
**What the cookie cannot do:** reach `/console/api/*` / `/passport` / `/v1/*` / other `/openapi/v1/*` (browser doesn't send it outside `/openapi/v1/oauth/device/*`); approve a different `user_code` (bound at mint); replay after approve (nonce consumed); be read by JS (`HttpOnly`); persist past 5 min.
|
||
|
||
**Key rotation.** State envelope + subject assertion + cookie all carry `kid` and use HS256 with the shared Dify `SECRET_KEY` key-set (same secret already shared between API Service and Enterprise — no dedicated signing-key env var). Rotation = append new `kid` to config, overlap window (1 h covers any in-flight 5-min blob), retire old `kid`. Both services reload key-set at process boot and on config reload. One secret, three uses, one rotation.
|
||
|
||
## Web UI contract
|
||
|
||
One new surface on `dify/web` (Next.js): the `/device` two-button page. Account-page management of CLI sessions is CLI-only (`auth devices list/revoke`). Full security headers: `security.md §Anti-framing`.
|
||
|
||
### `/device` — two-button login
|
||
|
||
Top-level page, unauthenticated entry allowed. User self-selects branch based on identity type.
|
||
|
||
Renders the same `LoginForm` React component used by `/signin` with a `variant="device-authorization"` prop. Only the dispatch targets differ per variant.
|
||
|
||
```
|
||
<LoginForm
|
||
variant="device-authorization"
|
||
user_code={entered}
|
||
ssoButtonHidden={!ssoAvailable}
|
||
onAccountLogin={() => {
|
||
setPostLoginRedirect('/device?user_code=' + entered)
|
||
router.push('/signin')
|
||
}}
|
||
onSSOLogin={() => redirect('/openapi/v1/oauth/device/sso-initiate?user_code=' + entered)}
|
||
/>
|
||
```
|
||
|
||
**SSO availability gate.** `/device` derives `ssoAvailable` from `systemFeatures.webapp_auth`:
|
||
|
||
```ts
|
||
const ssoAvailable =
|
||
systemFeatures.webapp_auth.enabled &&
|
||
systemFeatures.webapp_auth.allow_sso &&
|
||
Boolean(systemFeatures.webapp_auth.sso_config.protocol)
|
||
```
|
||
|
||
Same triplet the existing `/device` page already evaluates (see `dify/web/app/device/page.tsx`). All three fields ship server-side via `GET /console/api/system-features` regardless of edition; CE deploys (`ENTERPRISE_ENABLED=false`) never populate `sso_config.protocol`, so the SSO button never renders. No new system-features field needed.
|
||
|
||
**States:**
|
||
|
||
1. **Code entry.** Text input, label "Enter the code shown in your terminal", placeholder `ABCD-1234`. Button "Continue". Auto-uppercase, auto-hyphenate. Required before either login button enables.
|
||
2. **Login chooser.** Shown if user not authenticated after code entry.
|
||
- **Button 1 — "Sign in with Dify account"** (covers password + email-code + GitHub / Google social OAuth + account-SSO). Dispatch: `setPostLoginRedirect('/device?user_code=<code>')` + `router.push('/signin')`. Target persists via sessionStorage (tab-scoped, survives same-tab cross-origin bounces). Every login-success handler — password, email-code verify, social-OAuth callback landing via `app-initializer`, account-SSO callback landing via `app-initializer` — consumes `resolvePostLoginRedirect()` before falling to `/apps` default. Account-SSO additionally plumbs `return_to` through IdP state (see below) because signed state is required for IdP cross-origin preservation in principle, but sessionStorage covers the browser-side path.
|
||
- **Button 2 — "Sign in with SSO"** (External SSO IdP users, no `accounts` row). Hidden when workspace-wide SSO not configured. Dispatch: `/openapi/v1/oauth/device/sso-initiate?user_code=<entered>` → state-intent dispatch → `/openapi/v1/oauth/device/sso-complete` sets `device_approval_grant` cookie → 302 → `/device?sso_verified=1`. SPA calls `GET /openapi/v1/oauth/device/approval-context` to render Authorize.
|
||
3. **Authorize screen.**
|
||
- Heading: "Authorize Dify CLI"
|
||
- Body: `Dify CLI (difyctl) is requesting access to your account. If you did not start this from your terminal, click Cancel.`
|
||
- Signed-in-as: `Signed in as <email>` (session or cookie claim)
|
||
- Workspace (account path only): `Default workspace: <name>`
|
||
- Buttons: `Authorize` (primary) + `Cancel` (secondary). No scope checkboxes, no role pickers.
|
||
4. **Success.** Heading: "You're signed in". Body: "Return to your terminal to continue." No auto-close, no summary, no revoke.
|
||
5. **Error / expired.** Heading: "This code is no longer valid". Body: "The code may have expired or already been used. Run `difyctl auth login` again to get a new one." No retry input.
|
||
|
||
**`postLoginRedirect` helper.** `web/app/signin/utils/post-login-redirect.ts` — sessionStorage-backed, 15-min TTL. `setPostLoginRedirect(target)` validates same-origin + exact-path whitelist (`/device` with `{user_code, sso_verified}` query keys; `/account/oauth/authorize` with OAuth-dance keys) before storing. `resolvePostLoginRedirect()` re-validates on read. Tab-scoped — concurrent `/device` tabs don't clobber each other. Stale values expire after 15 min.
|
||
|
||
**Account-SSO `return_to` plumbing.** Web `sso-auth.tsx` snapshots `postLoginRedirect` into a local `const` on the first synchronous tick of the click handler (defeats React strict-mode double-invoke and tab-duplication races), passes as `return_to` to `/enterprise/sso/{saml,oidc,oauth2}/login?intent=account_login&return_to=<url>`. Enterprise validates exact path, signs into state, honors on callback.
|
||
|
||
**Covered sign-in flows:** password, email-code, GitHub, Google, account-SSO — all preserve `/device?user_code=...` via sessionStorage through in-tab navigation and cross-origin callback bounces, consumed by `app-initializer.tsx` or the signin-form success handlers.
|
||
|
||
**Known gap:** signup via email-verification link opened in a new tab loses sessionStorage (new browsing context). Signup flow falls to `/apps` default; user manually reopens the CLI-printed `/device` URL.
|
||
|
||
### Shared
|
||
|
||
- Existing console layout, typography, locale files.
|
||
- EN + ZH at launch.
|
||
|
||
## Rate limits
|
||
|
||
See `security.md §Rate limits` for the full table. Key values:
|
||
|
||
- `POST /openapi/v1/oauth/device/code` — 60 / hr / IP.
|
||
- `POST /openapi/v1/oauth/device/token` — 1 / `interval` / device_code (RFC 8628 `slow_down`).
|
||
- `GET /openapi/v1/oauth/device/sso-initiate` — 60 / hr / IP (`@enterprise_only` gate runs first).
|
||
- `POST /openapi/v1/oauth/device/approve-external` — 10 / hr / `subject_email`.
|
||
- `POST /openapi/v1/oauth/device/approve` — 10 / hr / session.
|
||
|
||
## Audit
|
||
|
||
See `security.md §Audit events`. Device-flow-specific events:
|
||
|
||
- `oauth.device_flow_approved` — on mint (both branches), carries `rotated`, `subject_type`, `subject_issuer`.
|
||
- `oauth.device_flow_denied` — on cancel.
|
||
- `oauth.device_flow_rejected` — email-collision reject on SSO branch (`sso-complete` or `approve-external`).
|
||
- `oauth.device_code_cross_ip_poll` — CLI polled from different IP than `/device/code` caller.
|