Authentication
Services accept a Keycloak access token from the realm startupos, sent as Authorization: Bearer <token>. This guide covers how a token is obtained in the current pilot, how it is used, what 401 and 403 mean, and how it is renewed.
:::caution Do not paste real credentials into examples The examples use placeholders. This site has no "try it" panel for that reason, and the specs carry no server URLs. :::
Get a token
In the pilot, tokens come from the frontend's own route, which signs in a demo persona against Keycloak.
curl -sS -X POST "https://<web-host>/api/auth/token" \
-H "Content-Type: application/json" \
-d '{"userId": "<persona id>", "password": "<password>"}'
userIdmust be one of the demo persona ids offered on the sign-in page. Any other value returns 400.- A missing password returns 400. A refused password returns 401. An unreachable identity provider returns 502. A deployment without sign-in configured returns 503.
- On success the body is
{"token": "<access token>", "expiresIn": <seconds>}and the response sets the HttpOnly cookiestartupos_sessionwith the refresh token.
Use the web host (rtih.peoplewave.in). The auth routes are served by the frontend, which is the catch-all only on the web hosts. On the API host the catch-all is the LMS service, so /api/auth/* there is not the frontend.
There is no other way to get a token for a person today. Keycloak is not reachable from outside the cluster. A public sign-in for the mobile apps is planned. See Mobile apps.
Use the token
Send it on every call to /api/v1/*:
curl -sS "https://<web-host>/api/v1/lms/courses" \
-H "Authorization: Bearer <access token>"
The access token lasts 30 minutes. The services read the caller from the token's preferred_username claim and the roles from its realm roles. Do not send X-User-Id, X-User-Role or X-Tenant-Id as an identity. Production services ignore them.
401 and 403
| Status | Meaning | What to do |
|---|---|---|
| 401 | No token, an invalid token, or an expired token. The response carries WWW-Authenticate: Bearer. | Renew the token or sign in again. |
| 403 | The token is valid but the caller may not do this. The role is wrong, the tenant does not match, the caller is not enrolled, or the caller is not a moderator. | Do not retry with the same token. |
A role failure from RoleEnforcementFilter returns JSON with error: "FORBIDDEN", a message and the requiredRoles. See Errors for the other shapes.
Some operations do not use a bearer token:
- Video and recording
streamoperations take a short-livedtokenquery parameter minted by the matching authenticatedstream-tokenoperation. - The LiveKit webhook carries a LiveKit-signed JWT.
- Certificate verification, PDF and QR are public and rely on unguessable identifiers.
- A few onboarding operations are public. They are listed in Public paths.
Each of these is marked on the operation in the reference.
Renew
curl -sS -X POST "https://<web-host>/api/auth/refresh" --cookie "<cookie jar>"
There is no body. The route reads the startupos_session cookie, redeems it and returns a new {"token", "expiresIn"}. It also rewrites the cookie, because Keycloak may rotate the refresh token. If there is no cookie, or the session has ended, the route returns 401 with {"token": null, "reason": ...} and clears the cookie. If the identity provider cannot be reached it returns 502 and keeps the cookie.
Sign out
POST /api/auth/logout clears the cookie and ends the Keycloak session. It always returns 200.
Service-to-service
Services call each other with client-credentials tokens. This is internal and not part of the public API.
Request and response details for the three routes are in the Frontend routes reference.
Sources: frontend/src/app/api/auth/{token,refresh,logout}/route.ts, libs/common-models/src/main/java/com/rtih/startupos/common/security/RoleEnforcementFilter.java (401 and 403 bodies), services/*/src/main/resources/application.yml, infra/oci-devops/deploy/ingress.yaml (catch-all per host).