12 KiB
title
| title |
|---|
| server — openapi |
openapi
The /openapi/v1/* endpoint group: user-scoped, bearer-authed, programmatic-API surface. Hosts everything difyctl, third-party scripts, and integrations talk to.
Companion: endpoints.md (flat HTTP reference), tokens.md (storage + prefixes), device-flow.md (RFC 8628 logic), middleware.md (request pipeline).
Surface boundaries
| Group | Auth | Role |
|---|---|---|
/openapi/v1/* |
Bearer (dfoa_ / dfoe_) |
User-scoped programmatic surface — identity, sessions, device flow, workspaces, apps |
/v1/* |
App-scoped key (app-) |
Service API, app-key-only |
/console/api/* |
Browser cookie | Dashboard — no bearer surface |
/inner/api/* |
Enterprise-Api-Secret-Key header |
Server-to-server only |
URL prefix
/openapi/v1/...
Distinct from /v1/ (service_api per-app keys), /console/api/ (browser cookie), /inner/api/ (s2s).
Versioned at the prefix level — /openapi/v2/ is the future major-version path. No mid-version breakage.
Auth model
Bearer only. Authorization: Bearer <token>.
Accepted token prefixes:
| Prefix | Subject | Status |
|---|---|---|
dfoa_ |
Dify account (device-flow approved from console) | accepted |
dfoe_ |
External SSO account (EE, IdP-approved) | accepted (EE-gated routes) |
dfp_ |
Personal Access Token | rejected — 401 unknown_token_prefix |
app-… |
App-scoped service_api key | rejected — /openapi/v1/* routes to /v1/* |
Scope model
AuthContext.scopes (frozenset on g.auth_ctx) gates routes:
| Token kind | Scopes |
|---|---|
dfoa_ |
[full] |
dfoe_ |
[apps:run, apps:read:permitted-external] |
Scopes derive from prefix on every request; mint endpoints do not accept a scopes field. Endpoints declare required scope at registration; the check returns 403 insufficient_scope with the missing scope name in the body. full is the umbrella — it satisfies every check within accepted surface.
Scope catalog (wire format, colon:lower):
| Scope | Holders | Grants |
|---|---|---|
full |
dfoa_ only |
Superuser within the dfoa_ surface |
apps:read |
dfoa_ only |
List + describe via /openapi/v1/apps* |
apps:run |
both | Run via the surface matching the holder's subject_type |
apps:read:permitted-external |
dfoe_ only (EE) |
List + describe via /openapi/v1/permitted-external-apps* |
Mint policy. Hard rejection at device-flow mint endpoint:
dfoa_may receive[full],[apps:read],[apps:run], or combinations.dfoe_may receive only[apps:run, apps:read:permitted-external].- Cross-subject scope minting → 400
mint_policy_violation. CE deploys rejectdfoe_mint entirely.
full does not umbrella apps:read:permitted-external across surface — even a full-bearing dfoa_ hitting /permitted-external-apps* is rejected with 403 wrong_surface at the surface gate, before scope check runs. Surface gate is independent of scope semantics.
Endpoint surface
Identity + sessions
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /openapi/v1/account |
Bearer | Polymorphic by subject. Replaces /v1/me |
| GET | /openapi/v1/account/sessions |
Bearer | New — list user's active OAuth tokens (no current /v1/ equivalent). See §Sessions list shape |
| DELETE | /openapi/v1/account/sessions/self |
Bearer | Revoke session backing this request. Replaces /v1/oauth/authorizations/self |
| DELETE | /openapi/v1/account/sessions/<id> |
Bearer + subject-match | New — revoke specific session |
GET /openapi/v1/account shape:
{
"subject_type": "account" | "external_sso",
"subject_email": "...",
"subject_issuer": null | "https://idp.partner.com",
"account": null | { "id", "email", "name" },
"workspaces": [{ "id", "name", "role" }],
"default_workspace_id": null | "ws_..."
}
subject_type always present. Absent fields are explicit null / [].
Sessions list shape
GET /openapi/v1/account/sessions filters revoked_at IS NULL AND expires_at > NOW() AND token_hash IS NOT NULL — hard-expired rows must not surface as phantom devices.
Returns the canonical pagination envelope (see endpoints.md §/openapi/v1/apps — list shape); session row shape:
{
"id": "tok_...",
"prefix": "dfoa_ab2f",
"client_id": "difyctl",
"device_label": "difyctl on alice-mbp",
"created_at": "2026-04-20T10:00:00Z",
"last_used_at": "2026-04-26T08:30:00Z",
"expires_at": "2026-05-04T10:00:00Z"
}
Device flow (RFC 8628 protocol)
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /openapi/v1/oauth/device/code |
Public + rate-limit | Request device + user code |
| POST | /openapi/v1/oauth/device/token |
Public + rate-limit | Poll for token |
| GET | /openapi/v1/oauth/device/lookup |
Public + rate-limit | Validate user_code from /device page |
These three are RFC 8628 protocol endpoints — intentionally unauthenticated. Rate-limits stay at current per-IP / per-device_code levels (see security.md).
Device flow (user approval)
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /openapi/v1/oauth/device/approve |
Browser cookie + CSRF | Approve device flow (account branch — mints dfoa_) |
| POST | /openapi/v1/oauth/device/deny |
Browser cookie + CSRF | Deny device flow |
Cookie-authed because the user is approving from the dashboard.
Device flow (SSO branch, EE-only)
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /openapi/v1/oauth/device/sso-initiate |
Public, @enterprise_only |
Build IdP auth URL, 302 to IdP |
| GET | /openapi/v1/oauth/device/sso-complete |
Signed assertion (5-min TTL, nonce-consumed) | Set device_approval_grant cookie (path-scoped to /openapi/v1/oauth/device), 302 → /device?sso_verified=1. IdP-side ACS callback URL must point here. |
| GET | /openapi/v1/oauth/device/approval-context |
device_approval_grant cookie |
SPA reads claims (idempotent — nonce not consumed) |
| POST | /openapi/v1/oauth/device/approve-external |
device_approval_grant cookie + CSRF |
Mint dfoe_ for External SSO subject |
CE: @enterprise_only returns 404. EE: gated by entitlement.
Workspaces (dfoa_ surface)
| Method | Path | Auth |
|---|---|---|
| GET | /openapi/v1/workspaces |
Bearer (dfoa_ only — dfoe_ 403 wrong_surface) |
| GET | /openapi/v1/workspaces/<id> |
Bearer + member |
Apps — dfoa_ surface (CE + EE)
workspace_id required on every request. List/describe/run subject to Layer 0 (workspace membership) + Layer 1 ACL.
| Method | Path | Auth |
|---|---|---|
| GET | /openapi/v1/apps?workspace_id=<ws> |
Bearer + apps:read |
| GET | /openapi/v1/apps/<id>/describe?workspace_id=<ws> |
Bearer + apps:read — canonical "what is this app". Supports ?fields=info,parameters,input_schema |
| POST | /openapi/v1/apps/<id>/run |
Bearer + apps:run — server dispatches by apps.mode. See endpoints.md §OpenAPI — app |
Permitted apps — dfoe_ surface (EE only)
No workspace concept — dfoe_ has no tenant_account_joins row. Tenant resolved from app. Layer 0 skipped. Layer 1 enforced (binary access-mode gate).
| Method | Path | Auth |
|---|---|---|
| GET | /openapi/v1/permitted-external-apps |
Bearer + apps:read:permitted-external |
| GET | /openapi/v1/permitted-external-apps/<id> |
Bearer + apps:read:permitted-external |
| POST | /openapi/v1/permitted-external-apps/<id>/run |
Bearer + apps:run |
Blueprint registered only when ENTERPRISE_ENABLED=true. CE deploys return 404 (route absent, not 403).
Error model
Inherits the apierrors.Typed shape used by other groups:
{
"code": "snake_case_code",
"message": "human-readable",
"hint": "optional next-action"
}
| HTTP | Code (sample) | When |
|---|---|---|
| 400 | invalid_request |
Malformed body / missing required field |
| 401 | bearer_missing / bearer_invalid / bearer_expired |
Auth failures |
| 403 | wrong_surface |
Subject_type hit a surface reserved for the other subject_type (dfoa_ → /permitted-external-apps*, dfoe_ → /apps* or /workspaces*). Surface gate at request-flow step 6 |
| 403 | insufficient_scope (with required_scope) |
Scope gate failed within accepted surface |
| 403 | license_required |
EE surface (/permitted-external-apps*, internal-mode internal-API) reached but EE license absent or expired. CE deploys never emit this |
| 404 | not_found |
Resource not found OR route doesn't exist on this group (e.g. /permitted-external-apps* on CE) |
| 429 | rate_limited (with retry_after_ms) |
Per-IP / per-token throttle |
| 503 | bearer_auth_disabled |
ENABLE_OAUTH_BEARER=false |
CORS posture
Distinct from service_api (which is permissive for embedded use). /openapi/v1/* allows:
Authorization,Content-Type,X-CSRF-Tokenrequest headersGET POST PATCH DELETE OPTIONSmethods*origin only whenENABLE_OAUTH_BEARER=trueANDOPENAPI_CORS_ALLOW_ORIGINS=*; otherwise an explicit allowlistAccess-Control-Max-Age: 600
Cookie-authed routes within the group (approve / deny / approve-external) require same-origin and reject cross-origin OPTIONS.
Rate limit posture
- Public device-flow endpoints: per-IP token bucket (existing settings preserved)
- Bearer-authed routes: per-token bucket, default 60 req/min, configurable via
OPENAPI_RATE_LIMIT_PER_TOKEN. Shared Redis bucket persha256(token)across all api instances. Details:middleware.md §Rate limit - 429 response includes
Retry-Afterheader +retry_after_msin body
Relationship to other groups
| Group | State |
|---|---|
service_api/ (/v1/*) |
App-scoped keys only. service_api/oauth.py deleted — /v1/me, /v1/oauth/... retired |
console/api/* |
Cookie-authed dashboard only. console/auth/oauth_device.py deleted |
inner_api/ |
Unchanged — internal s2s |
controllers/oauth_device_sso.py (root file) |
Deleted — content lives in controllers/openapi/oauth_device_sso.py |
controllers/fastopenapi.py |
Unrelated — exports the fastopenapi library's FlaskRouter for console-side schema generation. Naming collision is cosmetic; file stays. |
Gateway routing
Every gateway in front of api:5001 must route /openapi/* to it; without a rule, requests fall through to the web frontend and 404.
| Deployment | File | Rule |
|---|---|---|
| dify docker-compose | docker/nginx/conf.d/default.conf.template |
location /openapi { proxy_pass http://api:5001; include proxy.conf; } |
| dify-enterprise gateway | server/hack/configs/gateway/Caddyfile |
handle /openapi/* { reverse_proxy http://api:5001 } inside console.dify.local only — cookie-authed routes are scoped to that host |
| dify-helm chart | charts/dify/templates/gateway/caddy-config.yaml |
Same Caddy handle /openapi/* { reverse_proxy {{ $apiSvc }} } inside both consoleApiDomain blocks (the chart has two variants depending on whether console-api and console-web share a domain) |
/openapi/* is intentionally absent from enterprise.dify.local, app.dify.local, serviceApiDomain, and api.dify.local: cookie-authed routes (approve / deny / approval-context / sso-complete) only work on the host that mints the console session cookie, and the IdP-side ACS callback pins a single hostname.
Out of scope
- Admin / billing / setup / init endpoints — owner-only, browser ctx, stay in console
- Plugin marketplace, tool providers — extension management
- Webhook triggers — already separate blueprint
- App-key features (
/v1/chat-messagesetc.) — stay in service_api