@v4/cortex-sso

EuroLeague ID sign-on through Cortex's hosted microsite: NextAuth with a public PKCE client, a stateless cookie and the header's session hook.

2026-11-07

Overview

@v4/cortex-sso is the site's EuroLeague ID sign-on, through Cortex (FanScore). Readers log in and manage their account on Cortex's hosted microsite; the site holds only a short, stateless session. It is the only package that imports next-auth (pinned at 4.24.15), apart from the app's own NextAuth route. Club-only: it is not a template domain (Club integrations has the shape it shares with the club's other third-party seams).

This page is the package view. The reader journeys (login, logout from the site, logout from the microsite, a failed login) are in Single sign-on; the plan and its rulings are in .claude/reference/SSO/sso-mvp-plan.md, beside Cortex's own SSO Developer Overview - Euroleague (October 2026).pdf.

Three facts shape everything else:

  • A public OAuth client. PKCE, state and nonce, no client secret, scope openid profile email. The provider is written out by hand rather than discovered: Cortex's discovery document names the website as both the login address and the issuer while being served from the OAuth host, which a standard discovery check rejects. Cortex's code exchange also wants a device_id, so the token request is the package's own, but it still goes through the OpenID client NextAuth passes in — that call is what verifies the ID token's signature, issuer, audience, expiry and nonce (src/options.ts).
  • No session store. The session is NextAuth's encrypted cookie and nothing else. It keeps the ID token's sub, the first name, emailVerified, the ID token itself (logout needs it) and loginAt. No access or refresh token is kept (src/session.ts). The first name is read from Cortex's non-standard first_name claim, falling back to given_name.
  • The lifetime is absolute. A session ends sessionMaxAgeSeconds after login, whatever the activity (isSessionLive); NextAuth's sliding renewal does not extend it.

Entry points

Import What it holds Imports next-auth
@v4/cortex-sso Settings, links, cookie names, session types. Server-safe and client-safe No
@v4/cortex-sso/server The NextAuth options and the login / logout route handlers Yes
@v4/cortex-sso/client "use client": the header's session hook and the login / logout actions Via next-auth/react

@v4/cortex-sso (src/index.ts)

  • Settings — readCortexSsoSettings(env), DEFAULT_SESSION_MAX_AGE_SECONDS (86 400), type CortexSsoSettings (baseUrl, micrositeUrl, clientId, issuer, siteUrl, sessionMaxAgeSeconds).
  • Links (src/links.ts)
    • AUTH_PATHS — callback (/api/auth/callback/cortex), loginReturn, logout, logoutReturn, logoutLocal, all under /api/auth/. The callback and the two return paths are the addresses registered with Cortex.
    • CORTEX_PROVIDER_ID ("cortex", part of the registered callback), RETURN_TO_PARAM (returnTo).
    • SIGNOUT_PARAM / SIGNOUT_VALUE — the ?signout=true the microsite adds when a reader logs out there.
    • LOGIN_FAILED_PARAM / LOGIN_FAILED_PATH — a failed login lands on /?loginfailed=true. Nothing reads the marker yet.
    • micrositeAuthorizeUrl, micrositeAccountUrl, micrositeRegisterUrl, micrositeLogoutUrl (with id_token_hint and post_logout_redirect_uri), logoutReturnUrl, loginReturnPath.
    • safeReturnPath — the open-redirect guard. A return address must be one of our own paths: another origin, //host, a backslash, a control character or our own /api/auth/* routes fall back to /.
  • Cookies (src/cookies.ts) — SIGNED_IN_HINT_COOKIE (elb-signed-in, value SIGNED_IN_HINT_VALUE "1"): a readable hint that carries no identity and grants nothing; LOGOUT_RETURN_COOKIE (elb-logout-return, five minutes): the page a reader signed out from, kept while the browser visits the microsite; sessionCookieName(secure) (__Secure-next-auth.session-token on https).
  • Session — isSessionLive, types AccountSession (what the browser may read: firstName, userId — the ID token's sub — and expires) and SessionToken (what the cookie holds).

@v4/cortex-sso/server (src/server.ts)

  • cortexProvider(settings), createCortexAuthOptions(settings) — the NextAuth provider and options.
  • Route handlers (src/routes.ts):
    • handleLoginReturn (GET) — sets the signed-in hint, but only if a session cookie came with the request, then sends the reader back to their page.
    • handleLogout (POST, a form) — clears our cookies FIRST, then sends the browser to the microsite's logout page. The browser has to visit it: the microsite keeps its session in its own local storage, which no server call can clear.
    • handleLocalLogout (POST, 204) — ends only our session; used for ?signout=true.
    • handleLogoutReturn (GET) — where Cortex returns after a logout.
    • type ReadSessionToken.
  • readSessionProfile and SessionProfile — the reader's name and email from the session's ID token, for the Code of Conduct report.

@v4/cortex-sso/client (src/client/index.ts)

  • startLogin(returnTo?) — NextAuth's signIn, a POST carrying the CSRF token. It is a button action, never a link.
  • startLogout(returnTo?) — builds and submits a hidden form POST to the logout route.
  • useAccountSession(options) with types AccountState (signedOut, optionally checked, or signedIn with firstName and userId) and UseAccountSessionOptions. It replaces NextAuth's SessionProvider:
    • It starts signed out on the server and on the first paint, so the shell stays cacheable.
    • It calls /api/auth/session only when the hint cookie is present, so an anonymous page view makes no session call; a stale hint is cleared.
    • It handles ?signout=true by calling the local logout and stripping the parameter.

Configuration

readCortexSsoSettings(process.env) reads everything (src/settings.ts). CORTEX_SSO_BASE_URL is the switch.

Variable Meaning
CORTEX_SSO_BASE_URL Cortex's OAuth platform. Unset: sign-on is OFF — no login control, every auth route answers 404. CI and verify build this way
CORTEX_MICROSITE_URL The hosted login and account microsite. Discovery does not name it
CORTEX_SSO_CLIENT_ID The OAuth client id (EUROLEAGUE on stage)
CORTEX_SSO_ISSUER The iss Cortex writes into its ID tokens — compared character for character, trailing slash included
NEXTAUTH_URL This site's public address: the callback and logout-return addresses are built from it
AUTH_SESSION_MAX_AGE_SECONDS Optional. A whole number above zero; default 86 400 (24 hours)
NEXTAUTH_SECRET Read by NextAuth itself at request time, never through the club config. Each deployed environment sets its own

With the switch set, every other required variable must be set too, or the config throws naming the missing ones — at build as well as at runtime, because club.config.ts loads in both.

.env.example ships the stage values (https://oauth-stage.fanscore.com, client EUROLEAGUE), so a developer runs with sign-on on. The local site must be on http://localhost:3000: that is the callback Cortex has registered.

How the app wires it

File Role
apps/web/club.config.ts readCortexSsoSettings(process.env) → features.auth = { adapter: { id: "cortex", options } } (an adapter seam, not env markers)
apps/web/src/lib/auth.ts The ONE config point, server-only: cortexSso (settings or undefined) and authOff() (404, no-store)
apps/web/src/app/api/auth/[...nextauth]/route.ts NextAuth(createCortexAuthOptions(cortexSso))
apps/web/src/app/api/auth/login/return/route.ts, logout/route.ts, logout/return/route.ts, logout/local/route.ts The package's handlers; each answers authOff() when sign-on is off
apps/web/src/app/[locale]/layout.tsx Passes micrositeAccountUrl(cortexSso) to the chrome — the same for every visitor, so the shell stays cached
apps/web/src/components/site-chrome.tsx useAccountSession and startLogin; signed in, the header shows the reader's initial, linking to the microsite's account page
apps/web/src/lib/analytics.ts Reads the hint cookie so a possibly signed-in reader's first page_viewed waits for user_id
apps/web/src/lib/code-of-conduct.ts, packages/code-of-conduct/src/server/reporter-route.ts readSessionProfile for the report's reporter
apps/web/next.config.ts Listed in transpilePackages (raw TS source, no build)

Only the /api/auth/* routes read the session. The app runs cacheComponents; a layout or page that called getServerSession() or cookies() would become per-visitor and uncacheable. The header learns the session in the browser instead, through useAccountSession.

The stage-test app

packages/cortex-sso/stage-test/ is a standalone Next app for testing a REAL login and logout against Cortex's stage platform without Contentful or a full site build. It mounts the package's own route handlers, options and client hook (no copies), on the same five /api/auth/ paths, and runs cacheComponents with a cached page so the caching rule is exercised too.

cp packages/cortex-sso/stage-test/.env.example packages/cortex-sso/stage-test/.env.local   # then set NEXTAUTH_SECRET
pnpm --filter @v4/cortex-sso stage-test    # next dev on :3000, then open /news

Port 3000 is mandatory (the registered addresses). Its README has the journey table and the troubleshooting:

  • invalid_origin from Cortex — the Issuer configured in Cortex's dashboard does not match.
  • An error page straight after login — CORTEX_SSO_ISSUER is not exactly the token's iss.
  • A logout that ends on ?signout=true instead of your page — the post-logout address is not registered exactly.

Scripts and tests

test / test:watch (vitest, jsdom), lint, check-types, stage-test. Suites: settings, links, options, routes, profile, client/use-account-session, client/start-logout.

Where it lives

File What
packages/cortex-sso/src/settings.ts The environment variables and the on/off switch
packages/cortex-sso/src/options.ts The hand-written provider, the token exchange, the callbacks
packages/cortex-sso/src/routes.ts The login-return and the three logout handlers
packages/cortex-sso/src/links.ts Addresses, parameters and the return-path guard
packages/cortex-sso/src/client/ useAccountSession, startLogin, startLogout
packages/cortex-sso/stage-test/ The standalone stage login app and its README