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:


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/validate responsemissing_token confirms client URL bug
  • API-key GET /sessions/{id} returns 200 (proves session + backend OK)
  • Fresh POST …/launch launch_url loads in incognito (proves end-to-end OK)
  • 401 response body code from 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

  1. Exact URL embedded in the customer's LMS SCORM manifest / iframe src (full string, including query params).
  2. 401 code from DevTools → Network → failed sessions/… request → Response JSON.
  3. First document request for /player/… — does the HTML URL contain ?token=?
  4. When the launch URL is minted — on Play click or when the course card renders?
  5. Their Connect API key (or run the contract test themselves) to confirm session exists and mint→GET works.

LMS/client integration rules

  1. Never construct …/player/{sessionId} manually.
  2. Always use full launch_url from POST /api/v1/packages/{id}/launch and track expires_in_seconds for token refresh.
  3. Prefer dispatch URLs from POST /api/v1/packages/{id}/dispatches/player/dispatch/{dispatchId} for LMS packaging.
  4. Never strip ?token= before embedding in LMS.
  5. Mint launch URL when learner clicks Play, not when course card renders.
  6. 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 hasLaunchToken and sessionId on session fetch failure.
  • Server logs session_auth_denied (no PII) on 401 auth paths in GET/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/:idsession.package.launchUrl
Player shell URL POST /api/v1/packages/:id/launchlaunch_url (includes ?token=)
Package version asset GET /api/v1/packages/:idversions[].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

  1. Empty session.package.launchUrl — player shows “This launch is missing a content URL” (not an infinite spinner). Fix package processing / version launchUrl + storageKey in Convex.
  2. Client fetch never completes — player showed spinner while waiting on browser fetch even though the server page already loaded session data. Fixed by hydrating initialSession from the server component and adding a 15s fetch timeout (app/player/[sessionId]/page.tsxPlayerClient).
  3. 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