Skip to content

OpenID Connect ​

@priemskiyyy/anchor-oidc reads the oidc-client-ts UserManager your application configured, for Okta, Keycloak, Zitadel, Authentik, Cognito's hosted UI, Entra or any other OpenID Connect provider. It answers the stored user as a session of its ID token's issuer and subject, hears every sign-in, renewal and removal through the UserManager's events, mints the user's access token for each token profile, and ends the session by removing the user and handing back the provider's end-session page. Signing in, handling the callback and every redirect stay with anchor.native, which is your UserManager.

Install ​

sh
pnpm add @priemskiyyy/anchor @priemskiyyy/anchor-oidc oidc-client-ts

The adapter supports oidc-client-ts 3.

Create an Anchor ​

ts
import { Anchor } from "@priemskiyyy/anchor";
import { accessToken, oidc } from "@priemskiyyy/anchor-oidc";
import { UserManager } from "oidc-client-ts";

const userManager = new UserManager({
  authority: "https://id.example.com/realms/acme",
  client_id: "web",
  redirect_uri: `${location.origin}/callback`,
  post_logout_redirect_uri: location.origin,
  scope: "openid profile email offline_access",
});

export const anchor = new Anchor({
  adapter: oidc({ sdk: userManager, tokens: { api: accessToken() } }),
});

if (location.pathname === "/callback") {
  await anchor.native.signinCallback();
}

await anchor.start();

signinCallback(), signinPopup() and signinSilent() store the user and raise userLoaded, which Anchor hears; nothing needs anchor.sync().

Options ​

OptionTypeDefaultWhat it does
sdkUserManagerrequiredThe UserManager your application configured.
user(profile: IdTokenClaims) => TUsera snapshot of the profile claimsMaps the ID token claims of user.profile to the plain data a session carries.
tokensRecord<string, OidcAccessToken>noneThe token profiles, each accessToken().

Capabilities ​

CapabilityOpenID Connect
ObservationPush: userLoaded and userUnloaded report every sign-in, renewal and removal; userSignedOut, from the session monitor, asks for a read.
Token profilesaccessToken(): the user's access token.
Forced refreshYes: signinSilent(), through the refresh token or the silent iframe.
EndLocal: removeUser(). Remote: pending until the browser opens the provider's end-session page.
Navigation after endYes: the provider's end_session_endpoint with the parameters signoutRedirect() sends, or null when the provider publishes no such endpoint.
RuntimesBrowsers.

How a session is mapped ​

SessionFrom the stored user's ID token claims
principal.authorityprofile.iss, your provider's issuer
principal.subjectprofile.sub
principal.tenantnull
contextorg_id, organization, sid and act: another organization, another provider session or another actor is a new session
user{ sub, name, given_name, family_name, preferred_username, email, email_verified, picture }, or your mapper's result
guestfalse
impersonatedtrue when the ID token carries an act claim

OpenID Connect has no standard organization claim: the adapter reads org_id, as Auth0 and others name it, and organization, as Keycloak names it. A provider that names it otherwise adds nothing to the context, and its sid still tells two logins apart. A multi-tenant provider such as Entra puts the tenant in the issuer, so two tenants are two authorities.

Tokens ​

session.getToken(profile) reads getUser() and checks that the stored user is still the session's issuer, subject and context. It answers the stored access_token with its token_type (Bearer or DPoP) as the scheme, and calls signinSilent() first when the token has expired or the caller asks for forceRefresh. The renewed user must still be the session's, or the token rejects with SESSION_CHANGED. login_required, interaction_required, account_selection_required, consent_required and invalid_grant, a refresh token the provider no longer accepts, reject with INTERACTION_REQUIRED: call signinRedirect() or signinPopup() yourself. Any other failure, such as a silent iframe that timed out, rejects with TOKEN_FAILED and the oidc-client-ts error as its cause.

Ending the session ​

session.end() calls removeUser() only while the stored user is still the session's, and answers { local: "completed", remote: "unknown", navigation: { url } }. The page is the provider's end_session_endpoint with what signoutRedirect() sends: the ID token as id_token_hint, your post_logout_redirect_uri (with client_id when there is no ID token) and your extraQueryParams. It carries no state, as signoutRedirect() sends none by default, so signoutCallback() on the return page finds nothing missing. The provider's session survives until the browser opens that page, so open it:

ts
const result = await session.end();

if (result.navigation !== null) {
  location.assign(result.navigation.url);
}

A provider that publishes no end_session_endpoint answers { local: "completed", remote: "not-applicable", navigation: null }; metadata that cannot be read once the user was removed leaves remote unknown and navigation null. When the UserManager holds another user, the adapter removes nobody and answers not-applicable. revokeTokensOnSignout applies to signoutRedirect() only; to revoke, call anchor.native.revokeTokens() before session.end().

Behavior ​

  • The factory is cold: creating the adapter touches nothing on the UserManager.
  • A read answers from getUser(): no stored user is signed-out. Reading never raises userLoaded.
  • userLoaded and userUnloaded push the new state, the renewal signinSilent() stores included. userSignedOut asks for a read instead, because the UserManager keeps the user until you remove it, and the session monitor also raises it when it merely fails to reach the provider.
  • oidc-client-ts awaits its event callbacks and fails its own operation when one throws, so a user mapper that throws while the SDK signs someone in asks Anchor for a read instead: the sign-in succeeds, and the failed read is a diagnostic.
  • A profile without iss or sub, from an OAuth flow without the openid scope, is outside the contract; the first state is then unavailable.
  • dispose() removes exactly the three callbacks the adapter added, through the functions their add* calls answered.

What Anchor does not normalize ​

Sign-in redirects and popups and their callbacks, silent renewal scheduling (automaticSilentRenew), the session monitor and its check-session iframe, token revocation, DPoP proofs, the user info endpoint and claim merging stay with oidc-client-ts. Read them through anchor.native.

The adapter runs against a fake modelled on oidc-client-ts 3.5.0 and against the real UserManager in Node, over an in-process token endpoint; see the verification matrix. The package README is the reference.

Released under the MIT License.