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).
Scopes
Section titled “Scopes”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.
Endpoints
Section titled “Endpoints”| 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.
Device login
Section titled “Device login”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).
Agent audit log
Section titled “Agent audit log”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.
Idempotency-Key and X-Request-Id
Section titled “Idempotency-Key and X-Request-Id”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.
GET /api/meta
Section titled “GET /api/meta”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.
My tokens (web)
Section titled “My tokens (web)”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.
Common errors
Section titled “Common errors”| 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": ...}) |