LMS Client Session 401 — Incident Handoff
Shareable summary for LMS/client admins investigating learner 401 errors on Connect player loads. TrainingOS follows the same public contract as any other Connect client.
Related docs:
- TrainingOS integration guide
- Session errors — 401 from learner browser
- Internal runbook:
documentation/planned/improvements/2026-04-17_trainingos-launch-link-fix.md
Executive summary
When a learner sees GET /api/v1/sessions/{id} → 401 in the browser console, Connect is rejecting an unauthenticated request by design. Learners do not have API keys; the player must receive a signed launch JWT in the URL (?token=…) or arrive via a durable /player/dispatch/{dispatchId} handoff URL.
The most common root cause is embedding a tokenless URL such as:
https://app.allureconnect.com/player/a5ca9bf9-c725-40f5-91fe-63bb93beade1
instead of the full launch_url from POST /api/v1/packages/{id}/launch.
Hypothesis audit (Connect code review)
Client teams have proposed several Connect-side fixes. Audit against this repository:
| # | Client hypothesis | Connect verdict |
|---|---|---|
| 1 | Player bootstrap drops ?token= before session fetch |
Not supported. page.tsx passes searchParams.token to the client; fetch sends token in query and Authorization: Bearer. If Network shows no token, the browser opened a tokenless URL upstream. |
| 2 | Session GET validates API key instead of launch JWT | False. lib/session-route-auth.ts accepts API key or launch JWT. Contract tests prove mint → GET with JWT only. |
| 3 | tenant_id stored at mint but not honored on GET |
Honored. Cross-tenant mismatch returns 404 SESSION_NOT_FOUND, not 401. |
| 4 | Cookie auth blocked in cross-site iframe (Safari ITP) | Not applicable. Player auth is JWT in query + Bearer header only — no session cookies for launch. |
| 5 | CONNECT_API_BASE_URL vs player host split |
Unlikely for bootstrap 401. Initial fetch uses relative /api/v1/sessions/… (same origin). Wrong NEXT_PUBLIC_SCORM_API_URL would affect post-load SCORM commits, not the first 401. |
Ops edge case: If SCORM_SESSION_TOKEN_SECRET differs between mint and verify environments, tokens fail with INVALID_SESSION_TOKEN (401), not AUTH_REQUIRED. Check the response code to distinguish.
401 code → owner
code |
Meaning | Owner |
|---|---|---|
AUTH_REQUIRED |
No launch token reached Connect | Client / LMS — tokenless or stripped URL |
EXPIRED_SESSION_TOKEN |
JWT expired or missed refresh window | Client / Connect — refresh before expiry or re-mint when learner clicks Play |
INVALID_SESSION_TOKEN |
Truncated token or secret mismatch | Either — URL length + Vercel env parity |
SESSION_NOT_FOUND |
Token ok but session/tenant mismatch | Either — wrong ID or cross-tenant (404, not 401) |
Evidence checklist
Collect before closing the ticket:
- Exact LMS-embedded URL (screenshot or string) — does it contain
?token=or/player/dispatch/? -
launch-links/validateresponse —missing_tokenconfirms client URL bug - API-key
GET /sessions/{id}returns 200 (proves session + backend OK) - Fresh
POST …/launchlaunch_urlloads in incognito (proves end-to-end OK) - 401 response body
codefrom browser Network tab
If items 2–4 pass with the tenant API key, Connect mint→GET works; learner 401 is URL delivery.
Diagnostic commands
Readonly (no secrets) — already verified for session a5ca9bf9-…
# Unauthenticated GET — expected 401 AUTH_REQUIRED for tokenless access
curl -s "https://app.allureconnect.com/api/v1/sessions/a5ca9bf9-c725-40f5-91fe-63bb93beade1"
# Validate tokenless player URL structure
curl -s "https://app.allureconnect.com/api/v1/launch-links/validate?url=$(python3 -c 'import urllib.parse; print(urllib.parse.quote("https://app.allureconnect.com/player/a5ca9bf9-c725-40f5-91fe-63bb93beade1"))')"
Observed prod results (2026-05-24):
{"error":"Authentication required. Provide a valid Connect API key or session token.","code":"AUTH_REQUIRED"}
{"status":"missing_token","message":"Session launch URL has no `?token=` query parameter.","remediation":"Use the full `launch_url` returned by POST /api/v1/packages/{packageId}/launch. A bare /player/<sessionId> will 401 for every learner.","detail":{"host":"app.allureconnect.com","pathname":"/player/a5ca9bf9-c725-40f5-91fe-63bb93beade1","tokenPresent":false,"linkType":"session"}}
Requires tenant Connect API key
export CONNECT_API_KEY="<server-side Connect API key>"
# Confirm session exists
curl -s -H "Authorization: Bearer $CONNECT_API_KEY" \
"https://app.allureconnect.com/api/v1/sessions/a5ca9bf9-c725-40f5-91fe-63bb93beade1"
# Validate the exact LMS-embedded URL
curl -s "https://app.allureconnect.com/api/v1/launch-links/validate?url=$(python3 -c 'import urllib.parse; print(urllib.parse.quote("PASTE_LMS_URL_HERE"))')"
# Mint fresh launch URL
curl -s -X POST "https://app.allureconnect.com/api/v1/packages/{packageId}/launch" \
-H "Authorization: Bearer $CONNECT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"user_id":"test-learner"}'
Automated contract test (minimum acceptance test)
CONNECT_API_BASE_URL=https://app.allureconnect.com \
CONNECT_TEST_API_KEY=<tenant API key> \
node --test app/api/v1/launch-session-id-contract-consistency.contract.mjs
If this passes with their API key, Connect's mint→GET path works; any learner 401 is URL delivery, not API logic.
What to ask the client team
- Exact URL embedded in the customer's LMS SCORM manifest / iframe
src(full string, including query params). - 401
codefrom DevTools → Network → failedsessions/…request → Response JSON. - First document request for
/player/…— does the HTML URL contain?token=? - When the launch URL is minted — on Play click or when the course card renders?
- Their Connect API key (or run the contract test themselves) to confirm session exists and mint→GET works.
LMS/client integration rules
- Never construct
…/player/{sessionId}manually. - Always use full
launch_urlfromPOST /api/v1/packages/{id}/launchand trackexpires_in_secondsfor token refresh. - Prefer dispatch URLs from
POST /api/v1/packages/{id}/dispatches→/player/dispatch/{dispatchId}for LMS packaging. - Never strip
?token=before embedding in LMS. - Mint launch URL when learner clicks Play, not when course card renders.
- On 401, refresh the session token if possible or re-mint — do not reuse cached tokenless URLs.
Full guide: TrainingOS integration
Connect hardening shipped (this incident)
- Player console warns with
hasLaunchTokenandsessionIdon session fetch failure. - Server logs
session_auth_denied(no PII) on 401 auth paths inGET/PUT /api/v1/sessions/:id. - GA4 analytics skipped on
/player/*routes to reduce content-blocker console noise.
Player stuck on “Loading SCORM content…” (session GET 200)
Reported when TrainingOS Portfolio shows launchUrl / contentUrl / entryPoint as null on a session record, but GET /api/v1/sessions/:id returns 200 with a launch JWT.
Field mapping (TrainingOS ↔ Connect)
Connect does not expose top-level launchUrl, contentUrl, or entryPoint on session resources. Use:
| TrainingOS expectation | Connect API field |
|---|---|
| Content iframe URL | GET /api/v1/sessions/:id → session.package.launchUrl |
| Player shell URL | POST /api/v1/packages/:id/launch → launch_url (includes ?token=) |
| Package version asset | GET /api/v1/packages/:id → versions[].launchUrl |
A null launchUrl in TrainingOS’s Portfolio wrapper does not mean Connect returned null — verify the nested session.package.launchUrl on the v1 session GET.
Root cause classes
- Empty
session.package.launchUrl— player shows “This launch is missing a content URL” (not an infinite spinner). Fix package processing / versionlaunchUrl+storageKeyin Convex. - Client fetch never completes — player showed spinner while waiting on browser
fetcheven though the server page already loaded session data. Fixed by hydratinginitialSessionfrom the server component and adding a 15s fetch timeout (app/player/[sessionId]/page.tsx→PlayerClient). - 401 on client fetch — missing/expired
?token=; see sections above.
Repro sessions (tenant tenant_jon-borgwing-workspace)
44c9dfa0-97fb-4cca-8b1f-8162cb7d8fcb(Kira)410be5bd-…(Workplace — UUID abbreviated; use full ID from TrainingOS repro)
Verification commands
# Replace TOKEN from launch_url query string
curl -s -H "Authorization: Bearer $TOKEN" \
"https://app.allureconnect.com/api/v1/sessions/44c9dfa0-97fb-4cca-8b1f-8162cb7d8fcb" \
| jq '.session.package.launchUrl, .session.status'
# Package version asset (API key)
curl -s -H "Authorization: Bearer $CONNECT_API_KEY" \
"https://app.allureconnect.com/api/v1/packages/pkg_4_kira_warm_ups_scorm12_xubdqoew" \
| jq '.package.versions[0].launchUrl'
If session GET returns a non-empty session.package.launchUrl but the player still spins, check browser Network for the client-side /api/v1/sessions/:id request (blocked, pending, or aborted).
Last updated: 2026-05-24