Skip to content

Scoped API tokens

A scoped token is a Passport personal access token with a row in api_token_meta (kind, agent name, creation channel, rate limit, last use). Tokens without that row (login, LTI, demo) are unscoped and behave exactly as before. Design: ADR 0074; plan docs/plans/cli.md.

The token string is ulams_pat_<signed JWT>. The prefix is removed by the StripTokenPrefix middleware before Passport sees the header, so Authorization: Bearer ulams_pat_... and the bare JWT both work. Only Passport’s token id is stored, so a database leak does not leak usable tokens. Tokens are per tenant (each tenant has its own Passport keys).

A scope is <area>:read, <area>:write (includes read) or *. GET and HEAD need :read, every other method :write. Scopes only narrow the user’s own permissions.

Area Covers
courses admin courses, lessons, topics, categories, tags, files, SCORM, cmi5, LiaScript, H5P, Adapt, quizzes, dictionaries, images, video
users admin users, groups, roles, CSV import
enrolments course access, access enquiries, tutor assignment
settings settings, config, pages, templates, translations, notifications, model fields
events webinars, stationary events, consultations
certificates admin/pdfs
commerce orders, products, vouchers, payments
reports reports, stats, questionnaires, tasks
lti admin/lti
builder admin/course-builder
living-course admin/living-course (when installed)
learner profile, my courses, progress, bookmarks, notifications, cart, own orders
tokens auth/tokens, admin/tokens, admin/agent-audit
platform platform tenant API (platform hosts only)

Presets: @read-only (every area’s :read except platform), @author (courses, builder, living-course write, reports:read), @admin (*), @learner, @ci (courses, builder, living-course write).

The route-to-area map is api/packages/auth/resources/token-scopes.php; php artisan ulams:tokens:export-scopes writes the JSON copy used by the CLI generator. A route that is not in the map is denied for scoped tokens, and a test fails when a registered route is missing, so every new endpoint must be added to the map. api/auth/refresh, impersonation, password and account deletion endpoints are never available to a scoped token.

A refused call answers 403 with the missing scope:

{ "success": false, "message": "This token lacks the courses:write scope.", "error": "scope_missing", "required": ["courses:write"] }

error is scope_missing, scope_forbidden (never with a token), scope_unmapped or rate_limited (429 with Retry-After, when the token has rate_limit_per_minute, 1 to 6000). All limits of the API are in Rate limits.

Call Purpose
GET /api/auth/tokens Own scoped tokens (include_revoked=1)
POST /api/auth/tokens {name, scopes[], expires_in_days, kind, agent_name, rate_limit_per_minute} Create; 201 with data.token (shown once). A token can only create tokens whose scopes it already has
DELETE /api/auth/tokens/{id} Revoke own token
GET /api/auth/tokens/current The token of this request: scopes, kind, expiry, user, host and host kind
GET /api/admin/tokens Every user’s tokens (token_manage; filters user_id, kind)
DELETE /api/admin/tokens/{id} Revoke any token
GET /api/admin/tokens/{id}/audit Audit rows of one token (owner or token_manage)
GET /api/admin/agent-audit Audit log (token_id, user_id, from, to)

Lifetime: 1 to 365 days, 30 on a platform host; at most 50 active tokens per user (AUTH_MAX_TOKENS_PER_USER). The admin screen is described in API tokens.

ulams login (no password) uses our own flow with the RFC 8628 wire format (ADR 0075); Passport’s device grant stays disabled. The two endpoints the CLI calls answer in flat RFC JSON, not in the {success, message, data} envelope, and errors are HTTP 400 { "error": ... }.

Call Purpose
POST /api/auth/device/code {client_name, scopes[], agent} No auth, 10 requests per minute per IP (its own bucket: polling the token endpoint does not use it up). Returns device_code, user_code (XXXX-XXXX, letters BCDFGHJKLMNPQRSTVWXZ), verification_uri (<web url>/cli/authorize), verification_uri_complete, expires_in (600), interval (5)
POST /api/auth/device/token {device_code} No auth, 60 requests per minute per IP, poll every interval seconds. 200 {access_token, token_type: "Bearer", expires_at, scopes, token_id} once; HTTP 400 authorization_pending, slow_down (polled faster than every 4 s), access_denied, expired_token (also unknown or already collected codes)
GET /api/auth/device/requests/{user_code} The approval page asks what is requested (client, IP, scopes, lifetime options). Needs the user’s login token
POST /api/auth/device/requests/{user_code}/approve {scopes[], expires_in_days} Mints a scoped token for the approver with approved scopes that the client requested (the intersection) and 1 to 365 days (30 on a platform host)
POST /api/auth/device/requests/{user_code}/deny The next poll answers access_denied

The three approval endpoints need a login token (a scoped token is refused with scope_forbidden, so a token cannot approve a wider one for itself) and are throttled to 5 requests per minute per user (AUTH_DEVICE_APPROVE_PER_MINUTE). Codes are stored as HMAC hashes keyed with the tenant’s APP_KEY, so a code made on one tenant is unknown on every other; they live 10 minutes. The token minted on approval waits in the row, encrypted, until the first poll collects it and the column is cleared; php artisan ulams:auth:prune-device-authorizations (hourly) revokes tokens nobody collected and deletes rows expired for over a day. The approval page is /cli/authorize in the web app; its POSTs pass the same exact-Origin check as every state-changing request of the front (ADR 0014, amended), the page is never framed and never cached, and the code is never sent to another site in Referer (Referrer-Policy: same-origin).

The append-only table agent_audit_log gets one row per request made with a scoped token that is not a GET, and per GET in the users and reports areas: token, user, agent name, client (X-Ulams-Client), user agent, method, route name, path, route parameters, status, dry-run flag (X-Ulams-Dry-Run), idempotency key, request id, duration, IP. No bodies. php artisan ulams:auth:prune-agent-audit (scheduled daily) removes rows older than AUTH_AGENT_AUDIT_DAYS.

Send Idempotency-Key: <1-255 chars> on POST, PUT, PATCH or DELETE to make a retry safe. The key is scoped to host, user, method, route and key:

  • same key and same body: the stored response is replayed for 24 hours with Idempotent-Replayed: true;
  • same key, different body: 422 idempotency_mismatch;
  • the first request still running: 409 idempotency_in_progress.
  • key empty or longer than 255 characters: 422 idempotency_key_invalid.

It works for any signed-in user (login tokens too), not only scoped tokens; anonymous requests ignore the key.

401, 403, 429 and 5xx answers and responses over 1 MB are not stored, so the caller can retry them. Uploads are compared by file name, size and content hash. X-Request-Id (a ULID or UUID) is echoed in the response and added to the log context; anything else is replaced by a new ULID.

No authentication, 60 requests per minute. Returns { api, version, contract, host, kind: "tenant" | "platform", features } with features = ai, courseBuilder, livingCourse, deviceLogin, scopedTokens, idempotency, platformApi, demo. The CLI reads it on login and exits with FEATURE_DISABLED instead of calling an endpoint the host does not have.

Learners, authors and admins manage their own tokens on the account page of the reference frontend, My tokens: create one with a preset (@read-only, @author, @ci, @learner, @admin), a lifetime (7, 30, 90 or 365 days) and a kind, copy the secret once, and revoke it. The page posts plain forms to /account and calls GET|POST /api/auth/tokens and DELETE /api/auth/tokens/{id} with the user’s own login token; the API enforces every rule above.

The platform scopes are for the platform tenant API, which exists on platform hosts only and only when TENANCY_PLATFORM_API=true.

Status error Cause
401 none Missing, expired or revoked token
403 scope_missing The token lacks <area>:read or <area>:write; the body lists it in required
403 scope_forbidden Endpoint never open to scoped tokens: refresh, impersonation, password and account deletion, device approval
403 scope_unmapped Route missing from token-scopes.php
429 rate_limited The token’s rate_limit_per_minute is used up
409 idempotency_in_progress A request with the same Idempotency-Key still runs
422 idempotency_mismatch The same Idempotency-Key with a different body
422 idempotency_key_invalid The key is empty or over 255 characters
400 authorization_pending, slow_down, access_denied, expired_token Device flow polling (RFC 8628, flat JSON {"error": ...})