14 KiB
| title |
|---|
| server — tokens |
tokens
Server-side primitives for user-level bearer tokens: two Postgres tables, three wire prefixes, Redis caches, TTL policy, revocation paths.
Companion: middleware.md (request flow + authz), device-flow.md (mint paths), endpoints.md (HTTP surfaces).
Token surface
Prefix-dispatched alongside existing app-scoped keys:
| Prefix | Subject | App context source |
|---|---|---|
app-… (existing) |
App | Fixed by key row → apps.tenant_id |
dfoa_ (new) |
Dify account (OAuth) | URL path <string:app_id> |
dfoe_ (new) |
SSO-verified email, no Dify account (EE, OAuth) | URL path <string:app_id> |
Prefix carries subject type. Middleware short-circuits scope dispatch at prefix check — no DB/Redis read needed to know "is this caller External SSO?"
dfp_ (PAT) is not supported — 401 unknown_token_prefix. No model, service, controller, or migration for PAT ships.
Tenant never carried by token. Always looked up from apps row at request time.
X-Dify-Env is accepted and ignored.
Token types
One kind (OAuth) × two subject variants. Scope derived from prefix, not stored.
| Kind | Prefix | Subject | Scope | Created by | Storage | UI |
|---|---|---|---|---|---|---|
| OAuth (account) | dfoa_ |
Dify account | [full] |
Device flow, account branch | oauth_access_tokens (account_id populated) |
CLI auth devices list/revoke |
| OAuth (External SSO) | dfoe_ |
SSO-verified email, no account (EE) | [apps:run, apps:read:permitted-external] |
Device flow, SSO branch | oauth_access_tokens (account_id = NULL, subject_issuer populated) |
CLI auth devices list/revoke |
OAuth access token
- Created via OAuth Device Flow (
difyctl auth login). Details:device-flow.md. - Subject determined at approval:
- Account → email matches Dify account →
account_idpopulated → scope[full] - External SSO → SSO-verified, no Dify account →
account_id = NULL,subject_issuerpopulated → scope[apps:run, apps:read:permitted-external]
- Account → email matches Dify account →
- Auto-derived
device_label="difyctl on <hostname>"(client-supplied, server-stored). client_idallowlist controlled byOPENAPI_KNOWN_CLIENT_IDSenv (default"difyctl"). Unknown clients rejected at/device/code.- Plaintext never rendered in UI or printed to terminal — flows directly from Redis → CLI poll response → OS keychain.
Mint policy (hard-enforced at device-flow approve)
dfoa_mint:[full](v1.0 default). Future PAT may narrow to subsets like[apps:read, apps:run].dfoe_mint: always[apps:run, apps:read:permitted-external]. No alternatives.- Cross-subject scope minting → 400
mint_policy_violation. Device-flow approve handler validates(subject_type, requested_scope)pairs against this table before INSERT/UPDATE. - CE deploys reject
dfoe_minting entirely (the SSO branch endpoints are@enterprise_only). - Surface gate at request time is independent of scope check (see
middleware.md §Surface gate) —fulldoes not umbrellaapps:read:permitted-externalacross surfaces.
Wire format
dfoa_<43 base64url chars> # OAuth account (5 + 43)
dfoe_<43 base64url chars> # OAuth External SSO (5 + 43)
regex: ^(dfoa|dfoe)_[A-Za-z0-9_-]{43}$
Server rejects any dfp_ bearer with 401 unknown_token_prefix.
~256 bits entropy after prefix. Base64url alphabet — safe in URLs, shell vars, YAML.
Hashing
SHA-256 at creation. Only hash stored; token_hash varchar(64) uniquely indexed.
OAuth plaintext is never rendered. Lives in Redis for seconds during approve → poll, travels CLI poll response, written directly to OS keychain. If keychain unavailable, CLI falls back to hosts.yml at 0600 with a prominent stderr warning.
Storage
One table: oauth_access_tokens.
oauth_access_tokens
Identified primarily by email. account_id populated when email matches Dify account; NULL for SSO-only (EE).
CREATE TABLE oauth_access_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Subject
subject_email TEXT NOT NULL, -- primary identity
subject_issuer TEXT, -- NULL for account; IdP entity_id / OIDC issuer URL for SSO-only
account_id UUID REFERENCES accounts(id) ON DELETE SET NULL, -- NULL for SSO-only
-- Client
client_id VARCHAR(64) NOT NULL, -- allowlisted via OPENAPI_KNOWN_CLIENT_IDS
device_label TEXT NOT NULL, -- 'difyctl on gareth-mbp'
-- Credential
prefix VARCHAR(8) NOT NULL, -- 'dfoa_' (account) | 'dfoe_' (ExtSSO)
token_hash VARCHAR(64) NULL UNIQUE, -- NULL after hard-expire
-- Lifecycle
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL, -- mandatory. Set to NOW() + oauth_ttl_days at mint/rotate
revoked_at TIMESTAMPTZ
);
-- No `scopes` column. Middleware derives from (prefix, account_id IS NULL).
CREATE INDEX idx_oauth_subject_email ON oauth_access_tokens (subject_email) WHERE revoked_at IS NULL;
CREATE INDEX idx_oauth_account ON oauth_access_tokens (account_id) WHERE revoked_at IS NULL AND account_id IS NOT NULL;
CREATE INDEX idx_oauth_client ON oauth_access_tokens (subject_email, client_id) WHERE revoked_at IS NULL;
CREATE INDEX idx_oauth_token_hash ON oauth_access_tokens (token_hash) WHERE revoked_at IS NULL;
-- Rotate-in-place per (subject, client, device).
-- Re-login from same device rotates the row. Re-login after hard-expire takes the INSERT branch
-- (expired row has revoked_at IS NOT NULL, so not a candidate for ON CONFLICT).
-- subject_issuer participates so two IdPs asserting same email land in different rows.
-- Account branch writes a sentinel issuer ('dify:account') instead of NULL — application
-- normalizes at mint time. With no NULLs in the indexed column, the standard partial unique
-- index enforces "one active row per (email, issuer, client, device)" without needing a
-- COALESCE expression index or PG15 NULLS NOT DISTINCT.
CREATE UNIQUE INDEX uq_oauth_active_per_device
ON oauth_access_tokens (subject_email, subject_issuer, client_id, device_label)
WHERE revoked_at IS NULL;
ACCOUNT_ISSUER_SENTINEL = 'dify:account'. Constant lives in api token service. Mint path normalizes account-branch issuer to this sentinel before every INSERT/UPDATE; SSO branch passes the IdP issuer URL verbatim. Resolve / hard-expire / revoke read the sentinel as opaque — no special-case branching in middleware. Sentinel chosen to be a non-URL (URI scheme reserved for Dify-internal use, no IdP can match) so cannot collide with a real IdP issuer.
Multi-device semantics. device_label = per-device identifier. Unique index permits exactly one active row per (subject_email, subject_issuer, client_id, device_label). Different devices = independent rows. Soft-deleted rows excluded from uniqueness.
Redis cache
Middleware hits every request; Postgres on every call = wasteful. Two caches.
Token-context cache
auth:token:{sha256_of_token} → JSON AuthContext
{
"email": "user@example.com",
"account_id": "acc_..." | null,
"subject_type": "account" | "external_sso",
"scopes": ["full"] | ["apps:run", "apps:read:permitted-external"],
"token_id": "oat_...",
"source": "oauth",
"expires_at": "...iso..." | null
}
- Positive TTL: 60 s. Short enough that revokes propagate fast; long enough to deflect hot-token load.
- Negative TTL: 10 s. Invalid / revoked / expired tokens cached as
"invalid"— prevents 401-storm from a broken CI thrashing Postgres. - Invalidation on revoke: every revoke path must
UPDATE revoked_atANDDEL auth:token:{hash}. RPC returns only after both succeed. - Expiry: cached entry carries
expires_at; middleware double-checks on cache hit. Triggers hard-expire path if TTL tripped mid-cache. - Memory: ~200 bytes/entry. Negligible.
No tenant_id in cache — always looked up per request from the apps row.
ACL cache
Layer-2 authorization result (see middleware.md §Authorization).
acl:webapp:{subject}:{app_id} → "allow" | "deny" (60 s allow / 10 s deny)
{subject} = account_id for account bearer, sha256(subject_email) for External SSO. No env dimension.
Invalidation: TTL-only (no active push). ACL edits propagate within 60 s worst-case.
last_used_at
Currently NULL on new mints. Middleware does not update last_used_at on request — sync per-request UPDATE would double the DB write rate and cancel the middleware cache win. auth devices list renders blank in the LAST USED column.
TTL policy (OAuth)
All OAuth tokens carry mandatory expires_at:
ttl_days = Policy.OAuthTTLDays()
expires_at = NOW() + ttl_days * 86400s
| Scope | Source | Default | Range |
|---|---|---|---|
| Enterprise tenant | EE Inner API GET /inner/api/policy/oauth-ttl?tenantId=X (Redis-cached 60 s) |
14 days | [1, 365] |
| CE (no EE) / Inner API unreachable | env var OAUTH_TTL_DAYS, falls through to hardcoded 14 |
14 days | [1, 365] |
Applies to every oauth_access_tokens row at mint and rotate.
Policy change semantics: new TTL applies to new mints/rotates only. Existing rows keep their originally-written expires_at. Tightening 30→7 → effect on next rotate / natural expiry. No background sweep.
Detection + hard-expire on middleware hit
Middleware reads the row without expires_at filter. Behavior on resolve:
row.expires_at > NOW()orNULL→ valid; cacheAuthContext60 s.row.expires_at <= NOW()→ atomic CAS revoke (UPDATE oauth_access_tokens SET revoked_at = NOW(), token_hash = NULL WHERE id = :id AND revoked_at IS NULL),DEL auth:token:{hash}, emitoauth.token_expiredaudit (only when CAS hit a row,rows_affected == 1), write 10 s"invalid"negative cache, return 401token_expired.
Hard-expire fires from both paths. The api Python middleware and the Enterprise inner API (/inner/api/auth/check-access-oauth, see gateway.md §Inner API — auth check-access (OAuth)) perform the same revoke + invalidate + audit on expires_at <= NOW(). Idempotent CAS (WHERE id = :id AND revoked_at IS NULL) makes concurrent hits safe.
token_hash = NULL releases the column UNIQUE so same-device re-login can issue a fresh hash without conflict. Row retained for audit (90-day sweep).
Re-login lifecycle after hard-expire. The hard-expired row has revoked_at IS NOT NULL; partial unique index uq_oauth_active_per_device excludes it. Re-login therefore takes the INSERT branch — new row, new id. Consequence: auth devices list and GET /openapi/v1/account/sessions must filter (revoked_at IS NULL AND expires_at > NOW() AND token_hash IS NOT NULL) or dead rows surface as phantom devices.
Edge case — token never presented after expiry: row stays alive with revoked_at IS NULL + expires_at < NOW(). A scheduled retention task (schedule/clean_oauth_access_tokens_task.py, daily 05:00 via Celery beat) DELETEs rows past OAUTH_ACCESS_TOKEN_RETENTION_DAYS (default 30) under either:
revoked_at < NOW() - INTERVAL 'N days'
OR (revoked_at IS NULL AND expires_at < NOW() - INTERVAL 'N days')
Kill switch: ENABLE_CLEAN_OAUTH_ACCESS_TOKENS_TASK=false. Live unexpired rows are never touched.
Revocation paths
All paths: soft-delete Postgres (revoked_at = NOW()) AND DEL auth:token:{hash}. Return success only after both succeed.
| Trigger | Endpoint | Auth |
|---|---|---|
difyctl auth logout (OAuth) |
DELETE /openapi/v1/account/sessions/self |
Bearer being revoked |
difyctl auth devices revoke <id> |
DELETE /openapi/v1/account/sessions/<id> |
Bearer + subject-match |
Subject-match on revoke-by-id:
if requester is AccountContext:
require target.account_id IS NOT NULL AND target.account_id == requester.account_id
elif requester is SSOIdentityContext:
require target.account_id IS NULL
AND target.subject_email == requester.subject_email
AND target.subject_issuer == requester.subject_issuer
else:
403
Two identities sharing an email (account + SSO for foo@x.com) do not cross-revoke.
CLI ingestion contract
OAuth tokens (dfoa_ / dfoe_) arrive only via device-flow response and are written directly to keychain. CLI never accepts a raw bearer through stdin / flag. app- and dfp_ are rejected at every ingestion point. Details: ../auth.md §Bearer token kinds.
Secret scanner
Two distinct prefixes → two patterns for GitHub Advanced Security / GitLab / TruffleHog. Severity by prefix:
dfoa_= full scope, account session → high severitydfoe_=apps:run+apps:read:permitted-external, SSO-only → medium severity
See security.md §Secret-scanner prefixes for enrollment detail.