npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-guard instead 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-nest

Usage

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: subjectId is the principal id the boundary resolved, and orgRole / platformRoles are what the boundary read from its own tables. The provider token's org_roles, tenant claim and realm_access are 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's sub). 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 with isActive, for an organisation picker. SessionService.setActiveOrg(session, orgId | null) is a NEW MINT; the boundary validates membership and a non-member is refused with 403 PRINCIPAL_CONTEXT_REFUSED, the session keeping the context it had.
  • An unavailable organisation list fails the sign-in with 502 PRINCIPAL_BOUNDARY_FAILED rather than landing org-less, so an outage never looks like "no organisation selected".
  • Refresh. The context is re-exchanged inside SESSION_REFRESH_LEAD_SECONDS of 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 answers 502 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. parseFrontendOrigins is exported, so a service enabling CORS can reuse the same list.
  • Sign-in: GET /auth/login?returnTo= takes a relative path or an https URL 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 with OIDC_RETURN_ORIGIN_NOT_ALLOWED. With no returnTo, 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-session state. The provider returns the browser to GET /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 answers 200 with endSessionUrl: null.
  • The literal origin null never 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 Location is 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_FAILED and PRINCIPAL_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/logout is a fetch and always answers JSON.

Peer requirements

  • @nestjs/common and @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/me and /auth/logout instead of Promise<void>. Every consumer of @xemahq/platform-common already 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