19 KiB
title
| title |
|---|
| auth |
auth
Implementation: see
cli/src/. Build & test: seecli/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:
- New device flow completes.
- Compare returned
account.idvs stored. - Different → clear all metadata + bearer.
- Best-effort revoke old bearer:
DELETE /openapi/v1/account/sessions/self. Fire-and-forget. - 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:
- Host. Skipped if
--hostgiven. Else prior host shown as default. CLI normalizes scheme tohttps://, strips trailing slash. Non-HTTPS rejected unless--insecure. - Device flow.
POST /openapi/v1/oauth/device/codewithclient_id=difyctl+device_label. Server returns{device_code, user_code, verification_uri, expires_in, interval}. - Show code + URL on stderr (always — stays visible for manual recovery).
- 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. - Poll
POST /openapi/v1/oauth/device/tokeneveryintervalsec. Spinner + countdown (interactive); silent (plain/structured). Handleauthorization_pending/slow_down/expired_token/access_denied. - 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.
- Read bearer.
DELETE /openapi/v1/account/sessions/self. Bearer inAuthorizationheader.- Non-200 → stderr:
warning: server revoke failed (<status> <reason>); local credentials cleared anyway. - Delete keychain entry + rewrite
hosts.ymlwithout bearer. 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):
{
"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/sessionswith current bearer.CURRENTflags the row whereid == 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 likeauth logout— server revoke + local clear. --allrevokes 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
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:
{
"bearer": "dfoa_ab2f...",
"source": "oauth",
"token_id": "oat_abc...",
"expires_at": null
}
Storage-mode detection
- First login: probe keychain via Set → Get → Delete sentinel (
difyctl-probe:<host>). - 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). - Mode persisted in
hosts.yml; respected subsequently. - Force file:
DIFY_CREDENTIAL_STORAGE=file.
Source of truth
- Metadata →
hosts.ymlauthoritative. 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:undertoken_storage: keychainare 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:
- Don't retry.
- Clear local creds (token + metadata).
- Typed error:
error: session expired or revoked; run 'difyctl auth login' to sign in again. - 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:
- No raw-bearer export command.
- Device-flow response consumed directly by CLI, written to keychain. User never sees OAuth plaintext at login.
- Same-device re-login rotates in place → prior exfiltrated plaintext invalid.
app-anddfp_rejected at every CLI ingestion point (DIFY_TOKENenv, credential-store load).
Login-time behavior
Device flow
POST /openapi/v1/oauth/device/codewithclient_id=difyctl+device_label.- Prompt user with URL + user_code.
- Poll
POST /openapi/v1/oauth/device/tokenuntil success. - 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.
- Account branch:
- Write bearer → keychain (or file). Write metadata →
hosts.yml. - 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:
{"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.