@starci/auth-oidc
v0.1.3
Published
The OIDC client of a StarCi full app (Keycloak) behind the same PrincipalVerifier interface as @starci/auth: a NestJS module (issuer discovery, JWKS with caching and rotation, algorithm-pinned token checks, role mapping, the authorization code flow with P
Maintainers
Readme
@starci/auth-oidc
The sign-in of a full StarCi app: the OIDC client of its identity provider (Keycloak), behind the same PrincipalVerifier interface as @starci/auth (the lite edition), so the guards, @Public and @Credential of an app do not change between editions. One package, four entries.
| entry | holds |
|---|---|
| @starci/auth-oidc/server | OidcModule.register / registerAsync (also createOidc without Nest): issuer discovery, the provider's JWKS with caching and rotation, the token verifier (algorithm pinned, issuer, audience, expiry, typ), OidcPrincipalVerifier, role mapping, and OidcClient, the authorization code flow (PKCE S256, state, nonce, code exchange, ID token verification, RP-initiated logout) |
| @starci/auth-oidc/client | createOidcClient: the browser half of a public client: PKCE redirect, callback, one shared refresh, the access token in memory, sign-out only when the provider ends the session |
| @starci/auth-oidc/contracts | algorithms, wire codes, claim names, the principal and the outcome shapes both sides read |
| @starci/auth-oidc/testing | FakeOidcIssuer: discovery, JWKS, token minting, the token endpoint and failure injection, in memory or on a socket, so an app tests without Keycloak |
An app reaches the package only through integrations/keycloak (back end) and modules/auth/client.ts (front end); the lint refuses jose and every other JWT library in an app.
Back end
OidcModule.registerAsync({
isGlobal: true,
inject: [HTTP_CLIENT, CLOCK],
useFactory: (http: HttpClient, clock: Clock) => ({
...parsedOptions, // issuer, audience, client registration, role mapping, from the app's config loader
http: { request: (request) => http.request({ correlationId: "identity-keycloak", ...request }) },
clock,
}),
})The package reads no environment. The app parses its settings (BE-CONFIG-2) and passes typed options; the client secret is passed as a { reveal(): string } holder or text and revealed only for the token request. Every provider request goes through the OidcHttp port, so the app's bounded HTTP client (platform/http) is the one door; without the port the platform fetch is used with the request's deadline.
| a bearer token | is checked by |
|---|---|
| the algorithm | pinned to algorithms (RS256) before any key is looked up; none, HMAC and unlisted algorithms are refused |
| the key | the JWKS of the discovered jwks_uri, selected by kid; cached 10 minutes; an unknown kid refreshes it at most once per 30 seconds (rotation); an unreachable provider keeps the cached keys for 24 hours |
| issuer, audience, expiry, not-before | the registration and the clock, with a clock skew of 5 seconds |
| the token type | an ID token or a refresh token presented as a bearer is refused (typ must be Bearer) |
| the roles | realm_access.roles, resource_access.<client>.roles of the mapped clients and the mapped flat claims; a claim of the wrong shape or past 128 roles refuses the token |
verify answers { kind: "ok", value: OidcPrincipal } or { kind: "refused", code: "PASSPORT_ACCESS_TOKEN_INVALID", detail: { reason } } (the code @starci/auth refuses with; the reason is for the log). A provider that cannot be asked and has nothing cached throws an OidcError of kind unavailable, which the app maps to a 503, never a 401: an identity-provider outage does not sign anyone out.
Endpoints a discovery document advertises are trusted only on the issuer's origin, over https, without credentials; http: is admitted only with allowInsecureHttp (a developer machine or a test double).
The glue in an app
integrations/keycloak (slot be.integrations.keycloak) is declaration only, five files; no unit spec sits beside them (the proof is the integration spec of src/tests/integration/keycloak/, which drives an outage with FakeOidcIssuer, and the guard's own spec):
| file | holds |
|---|---|
| keycloak.options.ts | the typed registered client (issuer, audience, clientId, clientSecret: Secret, callbackUri, postLogoutUri, timeoutMs) |
| keycloak.config.ts | parseKeycloakConfig(env: EnvSource): env.url, env.string, env.secret, env.int; no default address or credential |
| keycloak.decorators.ts | the token of the package's verifier (OIDC_PRINCIPAL_VERIFIER), which domain/identity provides under its own ACCESS_TOKEN_VERIFIER |
| keycloak.registration.ts | keycloakModule(options, { isGlobal }) = OidcModule.registerAsync over HTTP_CLIENT and CLOCK, the http option adapting HttpClient.request with its correlation id |
| index.ts | what the rest of the app reads: the config parser, the module function, the token and the package's PrincipalVerifier and OidcPrincipal types |
AuthGuard of domain/identity reads the bearer token and asks the injected PrincipalVerifier, exactly as the lite guard asks @starci/auth's: a verified principal is left on the request, a refusal is IDENTITY_UNAUTHENTICATED, and an unavailable error is not caught (the errors filter maps its kind to a 503).
Front end
// modules/auth/client.ts: the one instance
export const auth = createOidcClient({ issuer: () => issuer(), clientId: "web", redirectUri: () => `${origin()}/auth/callback`, postLogoutRedirectUri: () => origin() })signIn() redirects to the provider; the callback page calls completeSignIn(window.location.href); restore() signs in again without a page after a reload (a prompt=none redirect, tried once); accessToken(), renew(), headers(), subscribe, getSnapshot, watch and signOut() have the shape of the @starci/auth/client session surface. The refresh token lives in memory with the access token and never in storage; a refresh ends the session only on a 401, a 403 or a 400 invalid_grant, and a 429, a 5xx or a lost connection is retried with backoff.
Tests
npm test typechecks (including the assignability of the verifier and the client to the @starci/auth interfaces), lints, and runs the node:test specs under 90% line coverage. The suite needs @starci/auth built next to it (file:../auth).
const issuer = await FakeOidcIssuer.create({ audience: "api" })
const moduleRef = await Test.createTestingModule({ imports: [OidcModule.register(issuer.options({ callbackUri }))] }).compile()
const token = await issuer.mintAccessToken({ sub: "user-1", roles: ["member"] })
issuer.setDown(true) // the provider is unreachable: verification throws an unavailable error