@xemahq/oidc-session-nest
v3.0.0
Published
Server-side OIDC session for a NestJS backend-for-frontend: the browser holds one httpOnly cookie and never a Xema token. Performs the authorization-code exchange, keeps the token set in a session store the application owns, refreshes it under a lease so
Readme
@xemahq/oidc-session-nest
The browser holds a cookie. The server holds the tokens.
Overview
This package belongs to Layer 1 — it is an SDK a NestJS application composes, not a service. It gives a backend-for-frontend a complete server-side OIDC session: the authorization-code exchange with PKCE, a session store the application owns, an exchange of the provider's access token at the identity boundary for a short-lived principal context, a refresh that happens once no matter how many requests or replicas ask for it, and an ambient request scope the generated Xema clients read their bearer from.
The browser receives exactly one thing — an httpOnly cookie carrying an opaque
id. It never receives an access token, a refresh token or an id token, so there
is no token in localStorage for a script to read and nothing to leak through a
postMessage, a source map or a crash reporter.
When to use it
- Use this when a browser talks to a backend you ship, and that backend talks to Xema. It is the recommended shape for browsers and it is not mandated: a server-to-server integration needs no session at all, and a mobile client that already has an OIDC library keeps it.
- Reach for
@xemahq/oidc-guardinstead when you only need to VERIFY a token somebody else obtained — this package composes it for exactly that and adds the session around it.
Installation
pnpm add @xemahq/oidc-session-nestUsage
import { IdentityBootstrapService } from '@xemahq/identity-client';
import {
InMemorySessionStore,
OidcSessionModule,
loadOidcSessionOptionsFromEnv,
sessionClientTransport,
} from '@xemahq/oidc-session-nest';
import { KERNEL_STATE_TOKEN, resolveHttpUrlEagerly } from '@xemahq/service-registry-nest';
@Module({
imports: [
OidcSessionModule.forRootAsync({
inject: [ConfigService, IdentityBootstrapService, KERNEL_STATE_TOKEN],
useFactory: async (config, identity, kernelState) => ({
options: loadOidcSessionOptionsFromEnv((name) => config.get(name), {
// A peer address comes from the registry, never from the environment.
boundaryUrl: await resolveHttpUrlEagerly(kernelState, 'identity-api'),
}),
store: new InMemorySessionStore(), // RedisSessionStore when scaled
// This application's own credential towards the identity service.
serviceCredential: identity,
}),
}),
],
})
export class AppModule {}
// Once, at boot — every request then calls peers as whoever is signed in.
configureClient({ baseUrl, ...sessionClientTransport() });Fence a route with OidcSessionGuard and read the caller with @SessionActor().
A route without the guard still gets the ambient scope, so an anonymous page can
render — it simply has no bearer to call Xema with.
Peers receive a principal context, never the provider's token
After the code exchange, the provider's ACCESS token is exchanged at the
identity boundary (POST <boundary>/internal/principal-context) for a signed
principal context (≤ 300 s) for the configured trust domain. That context is the
only bearer sessionClientTransport ever hands a generated client; the
provider's access, refresh and id tokens stay in the session store as the input
of the next exchange.
- Who the caller is comes from the context:
subjectIdis the principal id the boundary resolved, andorgRole/platformRolesare what the boundary read from its own tables. The provider token'sorg_roles, tenant claim andrealm_accessare not read at all — a customer's provider can put anything in them. - One organisation per context. At sign-in the principal's organisations
are read from the identity service
(
GET <boundary>/internal/principals/:principalId/organizations, keyed by the context'ssub). Exactly one ACTIVE organisation is selected for the person — a second mint for it; none or several leave the session org-less.SessionService.listOrganizations(session)returns the same list, deactivated ones included withisActive, for an organisation picker.SessionService.setActiveOrg(session, orgId | null)is a NEW MINT; the boundary validates membership and a non-member is refused with403 PRINCIPAL_CONTEXT_REFUSED, the session keeping the context it had. - An unavailable organisation list fails the sign-in with
502 PRINCIPAL_BOUNDARY_FAILEDrather than landing org-less, so an outage never looks like "no organisation selected". - Refresh. The context is re-exchanged inside
SESSION_REFRESH_LEAD_SECONDSof its expiry. When the provider's access token is itself near expiry at that moment, it is refreshed first through the provider's refresh token. A refusal of the re-mint (the person left the org) ends the session; a boundary fault keeps it and answers502 PRINCIPAL_BOUNDARY_FAILED. - No fallback. A boundary that refuses or fails at sign-in refuses the sign-in; nothing ever forwards the provider's token in its place.
| Variable | Option | Meaning |
|---|---|---|
| — (an input) | boundary.url | The identity service's base URL. A peer address: resolve it from the registry and pass it to loadOidcSessionOptionsFromEnv(read, { boundaryUrl }). |
| IDENTITY_BOUNDARY_ISSUER | boundary.issuer | The issuer contexts are signed as; keys are read from <issuer>/jwks.json. |
| IDENTITY_BOUNDARY_AUDIENCE | boundary.audience | The trust domain this application's peers sit in. |
| IDENTITY_BOUNDARY_REALM | boundary.realm | Optional. Absent mints in the realm of the service credential. |
| OIDC_API_SCOPE | oidc.apiScope | Optional. The scope naming the API the access token is for — e.g. api://<app-id>/access_as_user for Microsoft Entra, which otherwise issues a Microsoft Graph token the boundary refuses. Sent at sign-in and on every refresh. |
The module factory also returns serviceCredential — the
IdentityBootstrapService a Xema service already holds — which authenticates the
exchange to the identity service.
Pre-filling the sign-in
GET /auth/login?loginHint=<identifier> forwards the standard OIDC
login_hint — the identifier a person already typed on your own screen, which
the provider can pre-fill or route on. An unusable value (blank, over
MAX_LOGIN_HINT_LENGTH, or carrying a control character) is dropped and the
sign-in proceeds without it. It goes through the same URL builder as state,
nonce and redirect_uri, so it is encoded rather than spliced.
A frontend on its own origin
By default the backend serves only its own origin — the origin of
OIDC_REDIRECT_URI — and a returnTo must be a relative path. When the
frontend lives elsewhere (https://app.example.com calling
https://api.example.com), list its origins:
| Variable | Option | Meaning |
|---|---|---|
| OIDC_FRONTEND_ORIGINS | frontend.origins | Comma-separated exact origins, e.g. https://app.example.com,https://admin.example.com. |
| OIDC_FRONTEND_DEFAULT_ORIGIN | frontend.defaultOrigin | One of them — where a browser lands when there is nowhere else to go. |
| OIDC_POST_LOGOUT_REDIRECT_URI | oidc.postLogoutRedirectUri | This backend's GET /auth/logged-out, registered with the provider. |
- Each origin is
https://host[:port], written canonically: lower-case, no default port, no path, query, fragment, userinfo or trailing slash. A wildcard,http, a duplicate or an empty list fails at boot, and the two frontend variables are set together or not at all.parseFrontendOriginsis exported, so a service enabling CORS can reuse the same list. - Sign-in:
GET /auth/login?returnTo=takes a relative path or anhttpsURL whose origin exactly equals a listed one; its path, query and fragment are kept. It is stored with the single-use sign-in state, and the callback redirects to the stored value only. Anything else is refused withOIDC_RETURN_ORIGIN_NOT_ALLOWED. With noreturnTo, the callback lands on<default origin>/. - Sign-out:
POST /auth/logout?returnTo=(query) takes the same values. The address is parked under a random single-use key (5 minutes) in the session store, and the key travels as the end-sessionstate. The provider returns the browser toGET /auth/logged-out, which takes the key and redirects to the address. A missing, used or lapsed key lands on<default origin><OIDC_NAVIGATION_ERROR_PATH or />. The response is still{ endSessionUrl }, and a sign-out with no session answers200withendSessionUrl: null. - The literal origin
nullnever matches anything.
The write fence
The session cookie is sent with every request to this host, whichever page made
it. So a POST, PUT, PATCH or DELETE that carries the session cookie is
refused with 403 SESSION_ORIGIN_NOT_ALLOWED unless its Origin header exactly
equals a listed frontend origin or this backend's own origin. A missing Origin
and null are refused too. The fence covers every route, POST /auth/logout
included, and runs before the session is read. Requests without the session
cookie, such as bearer-authenticated service calls, and GET requests pass
through untouched.
Sign-in errors on the application's own page
GET /auth/login and GET /auth/callback are browser navigations, not
fetches. By default a refusal there is a JSON 400, which the browser shows as
raw JSON on a backend address.
Set OIDC_NAVIGATION_ERROR_PATH (or navigationErrorPath) to a path on the
sign-in page, and every refusal on those two routes becomes a redirect to it
with the error code:
302 Location: https://app.example.com/sign-in?error=OIDC_RETURN_ORIGIN_NOT_ALLOWED- With a frontend list the path is appended to the default frontend origin;
without one the
Locationis relative to this backend. Either way it can only reach an address the deployment listed. The value must start with a single/and carry no query or fragment. Anything else fails at boot. - The parameter is always
error, and it carries only the code. The detail text stays in the server log, where each refusal is logged with its code. - The codes a sign-in page can receive are
OIDC_RETURN_ORIGIN_NOT_ALLOWED,OIDC_LOGIN_STATE_UNKNOWN,OIDC_CALLBACK_REJECTED,OIDC_TOKEN_ENDPOINT_FAILEDandPRINCIPAL_BOUNDARY_FAILED. - With the variable unset, the two routes keep answering JSON. There is no
default path, because a page that does not read
?error=would hide the refusal. POST /auth/logoutis a fetch and always answers JSON.
Peer requirements
@nestjs/commonand@nestjs/core^10 || ^11— the framework this mounts into.@nestjs/swagger^7 || ^8 || ^11— the routes declare their wire contract (SessionActorDto,LogoutResponseDto, the two redirects), so a consumer's generated client types/auth/meand/auth/logoutinstead ofPromise<void>. Every consumer of@xemahq/platform-commonalready has it.@xemahq/oidc-guard>=0.6.0— verifies every token this package accepts, the provider's and the boundary's.@xemahq/platform-common>=0.28.2— supplies the org claim and header names.@xemahq/xema-decorators>=0.15.0— declares the browser routes public so a global JWT guard does not fence the sign-in door.@xemahq/contracts>=0.4.0— reached through the types above.
License
Apache-2.0 © Xema — xema.dev
