mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
470 lines
19 KiB
Markdown
470 lines
19 KiB
Markdown
---
|
||
title: auth
|
||
---
|
||
|
||
# auth
|
||
|
||
> Implementation: see [`cli/src/`](../../src/). Build & test: see [`cli/README.md`](../../README.md).
|
||
|
||
CLI auth: login, logout, status, whoami, devices. Credential storage. Bearer HTTP contract. Error model.
|
||
|
||
Companion: `server/tokens.md` (storage + prefixes), `server/device-flow.md` (server flow), `server/endpoints.md` (API contracts).
|
||
|
||
## Commands
|
||
|
||
| Command | Purpose | Output |
|
||
|---|---|---|
|
||
| `difyctl auth login` | Interactive device flow | Prompts → `Logged in as …` |
|
||
| `difyctl auth logout` | Server revoke + local clear | One-line confirm |
|
||
| `difyctl auth status [-v] [--json]` | Identity dashboard | Multi-line human or JSON |
|
||
| `difyctl auth whoami [--json]` | Account identity only | One line or JSON |
|
||
| `difyctl auth use <workspace-id>` | Switch active workspace (account only) | One-line confirm |
|
||
| `difyctl auth devices list [--json]` | OAuth sessions across devices | Table or JSON |
|
||
| `difyctl auth devices revoke <device-label\|id> [--all] [--yes]` | Revoke one or all OAuth sessions | One-line confirm |
|
||
|
||
Interactive device flow only. No `--with-token` / `DIFY_PAT` / `--token` — PAT not supported.
|
||
|
||
## Session model
|
||
|
||
**Single active host.** `auth login` replaces prior session. `--host` on individual commands reaches non-active hosts without changing the active one. Credential store shape allows future migration to a per-host map if multi-host becomes real.
|
||
|
||
**Workspace.** Server returns the user's workspaces on login + marks default. CLI stores `default_workspace_id` in `hosts.yml` as the active workspace. Every resource command accepts `--workspace <id>` override. `difyctl auth use <workspace-id>` switches active workspace (writes `current_workspace_id` to `hosts.yml`). See `workspaces.md §Resolution chain`.
|
||
|
||
**Account switch on same host.** Re-login with a different account → drop all prior metadata (workspace, account, available_workspaces), adopt new account's server default. Sequence:
|
||
|
||
1. New device flow completes.
|
||
2. Compare returned `account.id` vs stored.
|
||
3. Different → clear all metadata + bearer.
|
||
4. Best-effort revoke old bearer: `DELETE /openapi/v1/account/sessions/self`. Fire-and-forget.
|
||
5. Write new bundle, stderr `note: previous account signed out`.
|
||
|
||
|
||
## Login
|
||
|
||
Interactive device flow.
|
||
|
||
### Interactive
|
||
|
||
```
|
||
$ difyctl auth login
|
||
? Dify host: https://dify.internal
|
||
! Copy this one-time code: ABCD-1234
|
||
Press Enter to open dify.internal/device in your browser...
|
||
|
||
Waiting for authorization... done
|
||
Logged in as gareth@dify.ai (Gareth Chen)
|
||
Workspace: Acme Corp
|
||
```
|
||
|
||
**Flow:**
|
||
|
||
1. **Host.** Skipped if `--host` given. Else prior host shown as default. CLI normalizes scheme to `https://`, strips trailing slash. Non-HTTPS rejected unless `--insecure`.
|
||
2. **Device flow.** `POST /openapi/v1/oauth/device/code` with `client_id=difyctl` + `device_label`. Server returns `{device_code, user_code, verification_uri, expires_in, interval}`.
|
||
3. **Show code + URL on stderr** (always — stays visible for manual recovery).
|
||
4. **Browser open decision** (see below). Auto-open prompts `Press Enter to open …` → `open` / `xdg-open` / `cmd /c start`. Launch failure → `note: couldn't open browser; open the URL above manually`.
|
||
5. **Poll** `POST /openapi/v1/oauth/device/token` every `interval` sec. Spinner + countdown (interactive); silent (plain/structured). Handle `authorization_pending` / `slow_down` / `expired_token` / `access_denied`.
|
||
6. **Workspace resolution.** Server-returned default becomes active. No prompt.
|
||
|
||
**Flags:**
|
||
|
||
| Flag | Effect |
|
||
|---|---|
|
||
| `--host <url>` | Skip host prompt |
|
||
| `--no-browser` | Force skip-open even when auto-open qualifies |
|
||
| `--insecure` | Allow `http://`. Stderr warns. For `auth login` specifically, warns that `device_code` + `user_code` travel plaintext — any on-path MITM can intercept, poll the token endpoint, and race the legitimate user's approval. Local-dev / loopback only |
|
||
|
||
**Error states:**
|
||
|
||
| Server code | CLI behavior |
|
||
|---|---|
|
||
| `authorization_pending` | Keep polling at current interval. No stderr noise. |
|
||
| `slow_down` | Double current interval (`new = min(prev * 2, 60s)`), keep polling. Stderr at debug level only. Per RFC 8628 §3.5. |
|
||
| `expired_token` | `error: code expired before authorization; run 'difyctl auth login' to try again`. Exit 4 |
|
||
| `access_denied` | `error: authorization denied`. Exit 4 |
|
||
| any other / unknown error code | `error: unexpected device-flow error: <code>`. Exit 1. Treat as transient bug, do not retry. |
|
||
| transport 5xx / network timeout on poll | Retry up to 5× with exponential backoff (1s → 16s, capped). Exhaust → `error: device-flow poll unavailable`. Exit 1. |
|
||
|
||
### Browser-open decision
|
||
|
||
Skip auto-open if any condition matches:
|
||
|
||
| Condition | Check |
|
||
|---|---|
|
||
| User opted out | `--no-browser` set |
|
||
| SSH | `$SSH_CONNECTION` or `$SSH_TTY` set |
|
||
| Headless Linux | Linux + both `$DISPLAY` and `$WAYLAND_DISPLAY` unset |
|
||
| Non-interactive | stdout or stderr not a TTY |
|
||
|
||
Else attempt auto-open. Failure non-fatal — code + URL already on stderr. Windows-over-SSH (OpenSSH / WSL) sets `$SSH_CONNECTION` same as POSIX.
|
||
|
||
SSH example:
|
||
|
||
```
|
||
$ difyctl auth login --host https://dify.internal
|
||
! Detected SSH session — opening the browser on this machine is skipped.
|
||
! Open this URL on any device with a browser:
|
||
! https://dify.internal/device
|
||
! When prompted, enter this one-time code (expires in 15 minutes):
|
||
! ABCD-1234
|
||
|
||
Waiting for authorization... done
|
||
Logged in as gareth@dify.ai (Gareth Chen)
|
||
Workspace: Acme Corp
|
||
```
|
||
|
||
### Re-login / host switch
|
||
|
||
`auth login` while logged in replaces session. Different host → stderr note:
|
||
|
||
```
|
||
note: switching from <old-host> to <new-host>; previous session will be cleared
|
||
```
|
||
|
||
Old session cleared only on new-login success. Failed re-login preserves old.
|
||
|
||
### First-run
|
||
|
||
No prior host → prompt first. No magic discovery (users know their Dify URL — it's how they reach the web console). Cloud users may see `https://cloud.dify.ai` as the default suggestion — exact copy at implementation.
|
||
|
||
## Logout
|
||
|
||
Revoke server-side + clear local. Best-effort; never blocks on network.
|
||
|
||
1. Read bearer.
|
||
2. `DELETE /openapi/v1/account/sessions/self`. Bearer in `Authorization` header.
|
||
3. Non-200 → stderr: `warning: server revoke failed (<status> <reason>); local credentials cleared anyway`.
|
||
4. Delete keychain entry + rewrite `hosts.yml` without bearer.
|
||
5. `Logged out of <host>` to stdout. Exit 0 even on step-2 failure.
|
||
|
||
## Status + identity output
|
||
|
||
`auth status` default = minimal identity. `-v` = extra metadata. **Never print token details** — expiry, refresh timing, raw token. That's the middleware's concern, not the user's.
|
||
|
||
**Compact (default):**
|
||
|
||
```
|
||
Logged in to dify.internal as gareth@dify.ai (Gareth Chen)
|
||
Workspace: Acme Corp
|
||
Session: Dify account — full access
|
||
```
|
||
|
||
**Verbose (`-v`):**
|
||
|
||
```
|
||
dify.internal
|
||
Account: gareth@dify.ai (Gareth Chen, acc_6c8a1f)
|
||
Workspace: Acme Corp (ws_abc123, role: owner)
|
||
Available: 2 workspaces
|
||
Session: Dify account — full access (scope: full)
|
||
Surface: apps (dfoa_)
|
||
Storage: keychain
|
||
```
|
||
|
||
**Session tier line** — one-line summary of what the user can do. Shown on compact + verbose. `Surface:` line names the server surface the session targets (`apps` for `dfoa_`, `permitted-external-apps` for `dfoe_`).
|
||
|
||
| Subject | Line |
|
||
|---|---|
|
||
| Account (`dfoa_`, scope `full`) | `Session: Dify account — full access` |
|
||
| External SSO (`dfoe_`, scopes `apps:run` + `apps:read:permitted-external`) | `Session: External SSO — can run permitted apps and discover them, cannot access workspace surface` |
|
||
|
||
**Logged out:** `Not logged in. Run 'difyctl auth login' to sign in.` Exit 4.
|
||
|
||
**JSON (`--json`):**
|
||
|
||
```json
|
||
{
|
||
"host": "dify.internal",
|
||
"logged_in": true,
|
||
"account": { "id": "acc_6c8a1f", "email": "gareth@dify.ai", "name": "Gareth Chen" },
|
||
"workspace": { "id": "ws_abc123", "name": "Acme Corp", "role": "owner" },
|
||
"available_workspaces_count": 2,
|
||
"storage": "keychain"
|
||
}
|
||
```
|
||
|
||
Logged-out JSON: `{"host": null, "logged_in": false}`.
|
||
|
||
**`auth whoami`:**
|
||
- Human: `gareth@dify.ai (Gareth Chen)`
|
||
- JSON: `{"id": "acc_6c8a1f", "email": "gareth@dify.ai", "name": "Gareth Chen"}`
|
||
|
||
### External SSO rendering
|
||
|
||
`dfoe_` token (`subject_email + subject_issuer` populated, `account_id = NULL`) → no workspace lines (subject isn't a workspace member; no workspace concept):
|
||
|
||
```
|
||
Logged in to dify.internal as sso-user@partner.com (via https://idp.partner.com)
|
||
Surface: permitted-external-apps (external SSO)
|
||
Scopes: apps:run, apps:read:permitted-external
|
||
```
|
||
|
||
`auth whoami`:
|
||
- Human: `sso-user@partner.com (external SSO, issuer: https://idp.partner.com)`
|
||
- JSON: `{"subject_type": "external_sso", "email": "sso-user@partner.com", "issuer": "https://idp.partner.com"}`
|
||
|
||
## Devices (multi-device management)
|
||
|
||
Users sign in from multiple machines simultaneously. Server stores one row per `(subject_email, subject_issuer, client_id, device_label)` via partial unique index; same-device re-login rotates in place. `device_label` auto-derived from hostname (`"difyctl on gareth-mbp"`).
|
||
|
||
### `auth devices list`
|
||
|
||
```
|
||
$ difyctl auth devices list
|
||
DEVICE CREATED LAST USED CURRENT
|
||
difyctl on gareth-mbp 2026-03-15 5m ago *
|
||
difyctl on ci-runner-01 2026-02-01 17h ago
|
||
difyctl on old-thinkpad 2025-11-02 98d ago
|
||
```
|
||
|
||
- `GET /openapi/v1/account/sessions` with current bearer.
|
||
- `CURRENT` flags the row where `id == local token_id`.
|
||
- `--json` → raw array.
|
||
|
||
### `auth devices revoke <device-label|id>`
|
||
|
||
```
|
||
$ difyctl auth devices revoke "difyctl on old-thinkpad"
|
||
Revoked: difyctl on old-thinkpad
|
||
```
|
||
|
||
- Resolution: exact `device_label` → UUID → unique substring. Ambiguous → exit 2 with disambiguation hint.
|
||
- `DELETE /openapi/v1/account/sessions/<id>`. Server enforces subject-match → 403 otherwise. Cross-user revoke is admin-only and out of scope here.
|
||
- **Self-revoke shortcut.** If the resolved id matches current session's `token_id`, behave like `auth logout` — server revoke + local clear.
|
||
- **`--all`** revokes every OAuth token for this user *except* current device. Confirm prompt unless `--yes`.
|
||
|
||
## Credential storage
|
||
|
||
Bearer in OS keychain (preferred). Metadata in YAML. Keychain unavailable → YAML also holds bearer at `0600`. Same model as `gh`.
|
||
|
||
### File path
|
||
|
||
| OS | Default |
|
||
|---|---|
|
||
| Linux | `$XDG_CONFIG_HOME/difyctl/hosts.yml` else `~/.config/difyctl/hosts.yml` |
|
||
| macOS | `~/.config/difyctl/hosts.yml` (not `~/Library/…` — matches `gh`/`docker`/`kubectl`/`git`) |
|
||
| Windows | `%AppData%\difyctl\hosts.yml` |
|
||
|
||
`DIFY_CONFIG_DIR` overrides. Dir `0700`, files `0600` POSIX; Windows ACL user-only. Unexpected mode on read → stderr warning.
|
||
|
||
### `hosts.yml` schema
|
||
|
||
```yaml
|
||
current_host: dify.internal
|
||
subject_type: account # OR "external_sso" — drives CLI command dispatch
|
||
account:
|
||
id: acc_6c8a...
|
||
email: gareth@dify.ai
|
||
name: Gareth Chen
|
||
workspace: # only present when subject_type == "account"
|
||
id: ws_abc123
|
||
name: Acme Corp
|
||
role: owner
|
||
available_workspaces: # empty list when subject_type == "external_sso"
|
||
- id: ws_abc123
|
||
name: Acme Corp
|
||
role: owner
|
||
- id: ws_def456
|
||
name: Side Project
|
||
role: member
|
||
external_sso: # only present when subject_type == "external_sso"
|
||
email: sso-user@partner.com
|
||
issuer: https://idp.partner.com
|
||
token_storage: keychain # OR "file" when keychain unavailable
|
||
token_id: oat_abc123... # for revocation DELETE
|
||
token_expires_at: null # usually null (gh-shape)
|
||
# token kind (OAuth account / OAuth ExtSSO) discriminated by prefix:
|
||
# dfoa_ / dfoe_. subject_type field is the authoritative dispatch key.
|
||
# Only present when token_storage == "file":
|
||
tokens:
|
||
bearer: "dfoa_..." # OR "dfoe_..."
|
||
```
|
||
|
||
CLI dispatch reads `subject_type` to decide which commands are valid for this session — `dfoa_` allows `get apps` / `get app` / `run app`; `dfoe_` allows `get permitted-external-apps` / `get permitted-external-app` / `run permitted-external-app`. Cross-surface invocation errors client-side before any network call. Surface field on the token itself is implicit from prefix; `subject_type` is the canonical field.
|
||
|
||
### Keychain entry
|
||
|
||
When `token_storage: keychain`:
|
||
|
||
- Service: `difyctl`
|
||
- Account: `<host>` (e.g. `dify.internal`)
|
||
- Password: JSON blob:
|
||
|
||
```json
|
||
{
|
||
"bearer": "dfoa_ab2f...",
|
||
"source": "oauth",
|
||
"token_id": "oat_abc...",
|
||
"expires_at": null
|
||
}
|
||
```
|
||
|
||
### Storage-mode detection
|
||
|
||
1. First login: probe keychain via Set → Get → Delete sentinel (`difyctl-probe:<host>`).
|
||
2. Probe OK → `token_storage: keychain`. Probe fail → `token_storage: file` + stderr: `info: OS keychain unavailable; token will be stored in ~/.config/difyctl/hosts.yml (0600).`
|
||
3. Mode persisted in `hosts.yml`; respected subsequently.
|
||
4. Force file: `DIFY_CREDENTIAL_STORAGE=file`.
|
||
|
||
### Source of truth
|
||
|
||
- Metadata → `hosts.yml` authoritative. Keychain bearer without matching config → treated as logged out.
|
||
- Bearer in keychain mode → keychain authoritative. Config says keychain but missing keychain entry → logged out.
|
||
- Manual edits to `tokens:` under `token_storage: keychain` are ignored.
|
||
|
||
### Env escape hatch
|
||
|
||
`DIFY_TOKEN` + `DIFY_HOST` + `DIFY_WORKSPACE_ID` all present → skip storage reads; bearer env-driven, never persisted. Undocumented in `--help`. Emergencies only.
|
||
|
||
**All-or-none.** Partial set (e.g., `DIFY_TOKEN` alone) → exit 2 with `error: env escape hatch requires all of DIFY_TOKEN, DIFY_HOST, DIFY_WORKSPACE_ID; missing: <list>`. CLI does not silently fall back to storage when one var is set.
|
||
|
||
`DIFY_TOKEN` accepts `dfoa_` / `dfoe_` only. `app-` and `dfp_` rejected with the same prefix-validation error as device-flow ingestion.
|
||
|
||
### File-mode security
|
||
|
||
Plain-text bearer in `~/.config/difyctl/hosts.yml` (`0600`) = as secure as an SSH key on the same disk. Backups, fs attackers, misconfigured ACLs leak it. First file-mode write emits stderr notice making the trade-off explicit.
|
||
|
||
## HTTP contract
|
||
|
||
### Bearer
|
||
|
||
Every authenticated request:
|
||
|
||
```
|
||
Authorization: Bearer <token>
|
||
```
|
||
|
||
`<token>` = `dfoa_…` / `dfoe_…`. No cookies, no `X-CSRF-Token`, no jar. CLI never mints or accepts `dfp_`.
|
||
|
||
**Single surface.** All bearer traffic targets the service API — CLI base URL = `<host>/v1`. Bearer tokens never reach `/console/api/*`. Details: `server/middleware.md §Coexistence`.
|
||
|
||
### App-context headers
|
||
|
||
Commands acting on a specific app:
|
||
|
||
| Header | Required | Purpose |
|
||
|---|---|---|
|
||
| `X-Dify-App-Id: <uuid>` | yes | Target app. Server resolves tenant from `app.tenant_id` |
|
||
| `X-Dify-Env` | no | Static CLI traffic identifier; sent by CLI to distinguish CLI-originated requests. Not user-configurable. |
|
||
|
||
App-scoped `app-` keys ignore both (app is in the key). Identity calls (`auth login/whoami/status`) hitting `GET /openapi/v1/account` send neither.
|
||
|
||
### Identification headers
|
||
|
||
Every request — authenticated + device flow:
|
||
|
||
| Header | Value |
|
||
|---|---|
|
||
| `User-Agent` | `difyctl/<semver> (<platform>; <arch>; <channel>)` — e.g. `difyctl/1.0.0 (darwin; arm64; stable)` |
|
||
|
||
Admins filter CLI traffic via User-Agent regex.
|
||
|
||
**No `X-Dify-Client`** — redundant with User-Agent parse, and client-controlled inputs without server enforcement are noise.
|
||
|
||
**No `X-CSRF-Token`** — bearer requests bypass CSRF server-side.
|
||
|
||
**Not included:** CLI request-id (server-side IDs suffice), fingerprint, telemetry opt-in.
|
||
|
||
### No token refresh
|
||
|
||
Bearer tokens are long-lived. They live until the user revokes or a user-set `expires_at` is reached. CLI never rotates.
|
||
|
||
**On 401:**
|
||
|
||
1. Don't retry.
|
||
2. Clear local creds (token + metadata).
|
||
3. Typed error: `error: session expired or revoked; run 'difyctl auth login' to sign in again.`
|
||
4. Exit 4.
|
||
|
||
No mutex, no storage-reload dance, no cross-process race.
|
||
|
||
### Middleware chain
|
||
|
||
`RequestLogger → UserAgent → BearerAuth → ErrorParser`. `BearerAuth` injects `Authorization: Bearer <token>`. Streaming handlers don't handle mid-stream refresh (it doesn't exist).
|
||
|
||
`/console/api/refresh-token` is not called by difyctl. Web console uses it unchanged.
|
||
|
||
## Bearer token kinds
|
||
|
||
Two subject variants. Full storage + scope details in `server/tokens.md`.
|
||
|
||
| Token | Prefix | Minted by | Subject | Scope | Surface |
|
||
|---|---|---|---|---|---|
|
||
| OAuth account | `dfoa_…` | Device flow, account branch | Dify account | `[full]` | `/openapi/v1/apps*` |
|
||
| OAuth External SSO | `dfoe_…` | Device flow, SSO branch (EE only) | SSO-verified email, no account | `[apps:run, apps:read:permitted-external]` | `/openapi/v1/permitted-external-apps*` |
|
||
|
||
Surface is bound by subject_type. Cross-surface requests → 403 `wrong_surface`. CLI caches `subject_type` in `hosts.yml` at login and dispatches commands client-side; cross-surface command invocation errors before any network call.
|
||
|
||
Wire format identical: `Authorization: Bearer <token>`. CLI stores kind + subject locally so logout + `auth status` render correctly.
|
||
|
||
CLI rejects `dfp_` at every ingestion point.
|
||
|
||
**CLI-side OAuth plaintext defense:**
|
||
|
||
1. No raw-bearer export command.
|
||
2. Device-flow response consumed directly by CLI, written to keychain. User never sees OAuth plaintext at login.
|
||
3. Same-device re-login rotates in place → prior exfiltrated plaintext invalid.
|
||
4. `app-` and `dfp_` rejected at every CLI ingestion point (`DIFY_TOKEN` env, credential-store load).
|
||
|
||
## Login-time behavior
|
||
|
||
### Device flow
|
||
|
||
1. `POST /openapi/v1/oauth/device/code` with `client_id=difyctl` + `device_label`.
|
||
2. Prompt user with URL + user_code.
|
||
3. Poll `POST /openapi/v1/oauth/device/token` until success.
|
||
4. Response:
|
||
- Account branch: `{token: "dfoa_...", account, workspaces, default_workspace_id, expires_at}`
|
||
- External SSO branch: `{token: "dfoe_...", subject_type: "external_sso", subject_email, subject_issuer, account: null, workspaces: []}`
|
||
Prefix encodes subject type.
|
||
5. Write bearer → keychain (or file). Write metadata → `hosts.yml`.
|
||
6. Print `Logged in as …`.
|
||
|
||
Server-side endpoint details: `server/endpoints.md §OpenAPI — identity + sessions`.
|
||
|
||
## Error model + exit codes
|
||
|
||
gh's 5-bucket model. Errors also emit structured JSON when `--json` / `--jq` is active — agents branch on `code` string independently of exit int.
|
||
|
||
### Exit codes
|
||
|
||
| Code | Meaning | Triggers |
|
||
|---|---|---|
|
||
| `0` | Success | — |
|
||
| `1` | Generic / unexpected | Network failures, server 5xx, uncaught exceptions, unparseable responses |
|
||
| `2` | Usage error | Unknown flag, invalid arg, missing required input, conflicting flags |
|
||
| `4` | Auth error | Not logged in, session expired, PAT rejected, server-revoked |
|
||
| `6` | Version / compat | Server version outside `SupportedRange`, unsupported endpoint, schema break |
|
||
|
||
No `127` shell-style (overloaded). No per-HTTP-status exit codes — 403/404/409 all → 1; agents read `http_status` from JSON body.
|
||
|
||
### JSON error envelope
|
||
|
||
Single-line JSON to stderr + mapped exit code:
|
||
|
||
```json
|
||
{"error":{"code":"auth_expired","message":"session expired","hint":"run 'difyctl auth login'","http_status":401}}
|
||
```
|
||
|
||
### Stable `code` strings
|
||
|
||
| Code | Exit |
|
||
|---|---|
|
||
| `not_logged_in`, `auth_expired`, `token_expired` | 4 |
|
||
| `version_skew`, `unsupported_endpoint` | 6 |
|
||
| `usage_invalid_flag`, `usage_missing_arg`, `config_invalid_key`, `config_invalid_value` | 2 |
|
||
| `config_schema_unsupported` | 6 |
|
||
| `network_timeout`, `network_dns`, `server_5xx`, `server_4xx_other` | 1 |
|
||
| `unknown` | 1 |
|
||
|
||
### Human output
|
||
|
||
Non-JSON mode → stderr, up to two lines:
|
||
|
||
```
|
||
error: <message>
|
||
hint: <suggested next action>
|
||
```
|
||
|
||
Hint optional.
|
||
|