Authentication
The API authenticates every request with a Laravel Passport bearer token (guard api, driver
passport, api/config/auth.php). There are no API sessions or cookies. Tokens are personal
access tokens issued by the auth package after a password, social, demo or LTI login; there is
no OAuth authorization-code flow for third-party clients, and the device-code grant is disabled
(AppServiceProvider).
Every tenant has its own Passport key pair and APP_KEY (Tenancy), so a
token issued by one tenant is rejected by every other.
Login, refresh, logout
Section titled “Login, refresh, logout”| Call | What it does |
|---|---|
POST /api/auth/login {email, password, remember_me} |
Returns {token, expires_at} in the usual {success, message, data} envelope; 422 on wrong credentials |
GET /api/auth/refresh (authenticated) |
Issues a new token with the same lifetime class as the current one |
POST /api/auth/logout (authenticated) |
Revokes the current token |
POST /api/auth/register, /api/auth/password/forgot, /api/auth/password/reset, /api/auth/email/verify/{id}/{hash} |
Registration, password reset, e-mail verification |
GET /api/auth/social/{provider} and /callback |
Socialite login (stateless), then POST /api/auth/social/complete/{token} if data is missing |
POST /api/admin/auth/impersonate |
An admin gets a token for another user |
GET /api/profile/me |
The current user with roles and permissions |
Token lifetime (AuthService::createTokenForUser):
- with
remember_me: one month; - without it:
ulams_auth.token_expiration_minutes, 5 minutes by default. The value is an administrable setting, so admins can change it per tenant (Settings).
A refresh keeps the class: a token whose lifetime was longer than the configured minutes is treated as “remember me” and replaced by another one-month token.
curl -s http://coffee.localhost/api/auth/login \ -H 'Content-Type: application/json' -H 'Accept: application/json' \ -d '{"email":"student1@coffee.ulams.app","password":"secret","remember_me":1}'# {"success":true,"message":"Login successful","data":{"token":"eyJ0eXAi…","expires_at":"…"}}
curl -s http://coffee.localhost/api/profile/me -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'The demo accounts and their password come from tenant provisioning (TENANT_DEMO_PASSWORD,
development only).
/api/auth/login, register and the password endpoints have no application-level rate limiter; see
Rate limits for what is throttled and why you should limit them at the proxy.
Roles and permissions
Section titled “Roles and permissions”Authorisation uses spatie/laravel-permission. The base roles are student, tutor and admin
(Ulams\Core\Enums\UserRole); each package seeds the permissions its policies check, and the
permissions package exposes roles and their permissions at /api/admin/roles. The full list per
role is in Permissions.
The admin panel reads the permissions from /api/profile/me to show or hide screens; the API checks
them again in policies and form requests. Hiding a screen is never the only control.
How each client keeps its token
Section titled “How each client keeps its token”| Client | Where the token lives | Refresh |
|---|---|---|
Reference frontend (front/web) |
httpOnly, SameSite=Lax cookie ulams_session set by the server; scripts never see it |
The SDK logs in with remember_me on by default (one-month token); the cookie expires with the token. On a 401 the BFF re-logs the demo student once |
Admin (admin) |
localStorage key TOKEN |
refreshTokenCallback in admin/src/services/token_refresh.ts calls /api/auth/refresh shortly before expiry and logs out on failure |
Legacy front (front/src) |
Browser local storage, through the React context in front/src/lib/sdk |
refreshToken in the same context |
| H5P service | Receives the learner’s token as Authorization: Bearer or ?_token= (H5P core cannot set headers), verifies the RS256 signature with the tenant’s Passport public key and calls /api/profile/me |
none; Caddy redacts _token from logs |
In the reference frontend the browser never calls the API directly. Its server-side pages use the
token from the cookie, and browser islands call /bff/api/…, which adds the token. Details in
Reference frontend.
Demo login
Section titled “Demo login”When a tenant runs in demo mode (DEMO_MODE=true in its env file, demo package), the API exposes
POST /api/demo/login {"role": "student" | "tutor" | "admin"}. It logs the visitor in as the seeded student, tutor or
admin without a password, issues a regular personal access token and answers with the same body
as /api/auth/login. GET /api/config then contains ulams_demo: {enabled, front_url, admin_url},
which the admin and the legacy front read at boot to log in automatically.
The reference frontend uses demoStudentSession() from @ulams/sdk: demo login first, and a
password login with DEMO_STUDENT_EMAIL / DEMO_STUDENT_PASSWORD from the server environment when
demo mode is off. One demo token per tenant is cached on the server and shared by all visitors.
The hourly demo reset runs migrate:fresh on the tenant, including the OAuth tables, so every token
issued before stops working; clients log in again on the next 401. See Demo mode.
LTI launches
Section titled “LTI launches”When another LMS launches a ulams course (tool side of the lti package):
- The platform calls
GET/POST /api/lti/tool/login(OIDC login initiation) and thenPOST /api/lti/tool/launchwith the signedid_token. The API validates it (packbackbooks/lti-1p3-tool), matches the user by(platform, sub)(never by e-mail; Instructor maps to tutor, nobody to admin) and records the launch inlti_launches. - The browser is redirected to the front landing URL (
LTI_TOOL_LANDING_URL) with a one-timecodeand the course id. - The front exchanges the code at
POST /api/lti/tool/exchangefor a Passport token. In the reference frontend/lti/launchdoes this on the server, stores the token in the session cookie (8 hours) and redirects to/learn/<course>.
Inside an LMS iframe the session cookie may be blocked as third-party; platforms that frame tools
should open ulams in a new window. Platform-side launches (ulams launching an external tool from a
lesson) start with POST /api/lti/launches/{topic}, which returns the tool’s OIDC login URL with a
two-minute, single-use login hint. Configuration is in LTI.
Scoped API tokens for the CLI and agents
Section titled “Scoped API tokens for the CLI and agents”Long-lived, scope-limited, revocable personal access tokens (ulams_pat_...) are described in
Scoped API tokens.
Other scoped tokens
Section titled “Other scoped tokens”- SCORM tracking token:
POST /api/scorm/launch/{sco}returns a player URL with a token bound to the tenant, user and SCO, sent asX-Ulams-Tracking-Token(notAuthorization, because Passport blanks bearer headers that are not its tokens). It cannot call any other endpoint. - Internal service tokens: the API calls the H5P and PDF services with
X-Internal-Token; browsers never hold these.
Related
Section titled “Related”- Rate limits: every throttle, with its environment variable.
- Webhooks: inbound endpoints that authenticate by signature, not by token.
- SDK usage: calling the API from Node and the browser.