@reactionary/oidc 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,19 @@
1
- # Temporary Holding Version
1
+ # @reactionary/oidc
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shared OpenID Connect relying party for backends that cannot run an external login themselves.
4
+
5
+ A backend that **hosts** the redirect (for example Medusa) implements `startExternalLogin` and `completeExternalLogin` directly. A backend that does not extends `OidcIdentityCapability` instead: this package runs discovery, authorization code with PKCE, nonce and ID-token validation, and the backend implements one method, `exchangeExternalIdentity`, which turns the verified identity into a backend session.
6
+
7
+ The storefront sees the same three calls either way: `listProviders`, `startExternalLogin`, `completeExternalLogin`.
8
+
9
+ ## Configuring providers
10
+
11
+ Backends that use this package take an `externalIdentityProviders` list in their configuration. See `ExternalIdentityProviderSchema`.
12
+
13
+ ## Testing
14
+
15
+ `@reactionary/oidc/testing` exports a fake identity provider (a local HTTP server with discovery, JWKS, token endpoint and signed ID tokens) and `runExternalLoginConformance`, which every backend's spec should run.
16
+
17
+ ## Building and testing
18
+
19
+ Run `nx build oidc` and `nx test oidc`.
@@ -0,0 +1,113 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __decorateClass = (decorators, target, key, kind) => {
4
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
5
+ for (var i = decorators.length - 1, decorator; i >= 0; i--)
6
+ if (decorator = decorators[i])
7
+ result = (kind ? decorator(target, key, result) : decorator(result)) || result;
8
+ if (kind && result) __defProp(target, key, result);
9
+ return result;
10
+ };
11
+ import {
12
+ ExternalLoginRedirectSchema,
13
+ IdentityCapability,
14
+ IdentityMutationCompleteExternalLoginSchema,
15
+ IdentityMutationStartExternalLoginSchema,
16
+ IdentityProviderSchema,
17
+ IdentityQueryProvidersSchema,
18
+ IdentitySchema,
19
+ PASSWORD_IDENTITY_PROVIDER,
20
+ Reactionary,
21
+ error,
22
+ success
23
+ } from "@reactionary/core";
24
+ import { codeChallenge, randomToken } from "../client/pkce.js";
25
+ import { resolveMetadata } from "../client/discovery.js";
26
+ import { buildAuthorizationUrl, exchangeCode, verifyIdToken } from "../client/relying-party.js";
27
+ class OidcIdentityCapability extends IdentityCapability {
28
+ externalProviders;
29
+ constructor(cache, context, externalProviders) {
30
+ super(cache, context);
31
+ this.externalProviders = externalProviders ?? [];
32
+ }
33
+ async listProviders() {
34
+ return success([
35
+ PASSWORD_IDENTITY_PROVIDER,
36
+ ...this.externalProviders.map((provider) => ({
37
+ identifier: { key: provider.key },
38
+ displayName: provider.displayName,
39
+ flow: "redirect",
40
+ ...provider.brand ? { brand: provider.brand } : {}
41
+ }))
42
+ ]);
43
+ }
44
+ async startExternalLogin(payload) {
45
+ const provider = this.externalProviders.find((candidate) => candidate.key === payload.provider.key);
46
+ if (!provider) {
47
+ return error({ type: "InvalidInput", error: `Unknown identity provider ${payload.provider.key}` });
48
+ }
49
+ const metadata = await resolveMetadata(provider);
50
+ const state = randomToken();
51
+ const nonce = randomToken();
52
+ const codeVerifier = randomToken(48);
53
+ const location = buildAuthorizationUrl(provider, metadata, {
54
+ callbackUrl: payload.callbackUrl,
55
+ state,
56
+ nonce,
57
+ codeChallenge: await codeChallenge(codeVerifier)
58
+ });
59
+ this.setPendingExternalLogin(provider.key, state, { nonce, codeVerifier });
60
+ return success({ location });
61
+ }
62
+ async completeExternalLogin(payload) {
63
+ const provider = this.externalProviders.find((candidate) => candidate.key === payload.provider.key);
64
+ if (!provider) {
65
+ return error({ type: "InvalidInput", error: `Unknown identity provider ${payload.provider.key}` });
66
+ }
67
+ const params = payload.callbackParameters;
68
+ const pending = this.takePendingExternalLogin(provider.key, params["state"]);
69
+ if (!pending) {
70
+ return error({ type: "InvalidInput", error: "The login was not started in this session, or has expired" });
71
+ }
72
+ if (params["error"]) {
73
+ return error({ type: "InvalidInput", error: `The identity provider refused the login: ${params["error"]}` });
74
+ }
75
+ const code = params["code"];
76
+ const { nonce, codeVerifier } = pending.data;
77
+ if (!code || !nonce || !codeVerifier) {
78
+ return error({ type: "InvalidInput", error: "The login response has no authorization code" });
79
+ }
80
+ const metadata = await resolveMetadata(provider);
81
+ const tokens = await exchangeCode(provider, metadata, { code, callbackUrl: payload.callbackUrl, codeVerifier });
82
+ const claims = await verifyIdToken(provider, metadata, tokens.idToken, nonce);
83
+ if (provider.requireVerifiedEmail !== false && claims["email_verified"] !== true) {
84
+ return error({ type: "InvalidInput", error: "The identity provider did not confirm a verified email address" });
85
+ }
86
+ const result = await this.exchangeExternalIdentity({ provider, claims, tokens });
87
+ if (result.success) {
88
+ this.updateIdentityContext(result.value);
89
+ }
90
+ return result;
91
+ }
92
+ }
93
+ __decorateClass([
94
+ Reactionary({
95
+ inputSchema: IdentityQueryProvidersSchema,
96
+ outputSchema: IdentityProviderSchema.array()
97
+ })
98
+ ], OidcIdentityCapability.prototype, "listProviders", 1);
99
+ __decorateClass([
100
+ Reactionary({
101
+ inputSchema: IdentityMutationStartExternalLoginSchema,
102
+ outputSchema: ExternalLoginRedirectSchema
103
+ })
104
+ ], OidcIdentityCapability.prototype, "startExternalLogin", 1);
105
+ __decorateClass([
106
+ Reactionary({
107
+ inputSchema: IdentityMutationCompleteExternalLoginSchema,
108
+ outputSchema: IdentitySchema
109
+ })
110
+ ], OidcIdentityCapability.prototype, "completeExternalLogin", 1);
111
+ export {
112
+ OidcIdentityCapability
113
+ };
@@ -0,0 +1,56 @@
1
+ import * as z from "zod";
2
+ const DISCOVERY_TTL_MS = 60 * 60 * 1e3;
3
+ const DiscoveryDocumentSchema = z.looseObject({
4
+ issuer: z.string(),
5
+ authorization_endpoint: z.string(),
6
+ token_endpoint: z.string(),
7
+ jwks_uri: z.string(),
8
+ end_session_endpoint: z.string().optional()
9
+ });
10
+ const cache = /* @__PURE__ */ new Map();
11
+ function clearDiscoveryCache() {
12
+ cache.clear();
13
+ }
14
+ async function resolveMetadata(provider) {
15
+ if (provider.authorizationEndpoint && provider.tokenEndpoint && provider.jwksUri) {
16
+ return {
17
+ issuer: provider.issuer,
18
+ authorizationEndpoint: provider.authorizationEndpoint,
19
+ tokenEndpoint: provider.tokenEndpoint,
20
+ jwksUri: provider.jwksUri
21
+ };
22
+ }
23
+ let discovered = cache.get(provider.issuer);
24
+ if (!discovered || discovered.expires < Date.now()) {
25
+ const url = `${provider.issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
26
+ const response = await fetch(url, { headers: { accept: "application/json" } });
27
+ if (!response.ok) {
28
+ throw new Error(`Discovery failed for ${provider.key}: HTTP ${response.status}`);
29
+ }
30
+ const document = DiscoveryDocumentSchema.parse(await response.json());
31
+ if (document.issuer !== provider.issuer) {
32
+ throw new Error(`Discovery document of ${provider.key} names a different issuer than configured`);
33
+ }
34
+ discovered = {
35
+ expires: Date.now() + DISCOVERY_TTL_MS,
36
+ metadata: {
37
+ issuer: document.issuer,
38
+ authorizationEndpoint: document.authorization_endpoint,
39
+ tokenEndpoint: document.token_endpoint,
40
+ jwksUri: document.jwks_uri,
41
+ ...document.end_session_endpoint ? { endSessionEndpoint: document.end_session_endpoint } : {}
42
+ }
43
+ };
44
+ cache.set(provider.issuer, discovered);
45
+ }
46
+ return {
47
+ ...discovered.metadata,
48
+ authorizationEndpoint: provider.authorizationEndpoint ?? discovered.metadata.authorizationEndpoint,
49
+ tokenEndpoint: provider.tokenEndpoint ?? discovered.metadata.tokenEndpoint,
50
+ jwksUri: provider.jwksUri ?? discovered.metadata.jwksUri
51
+ };
52
+ }
53
+ export {
54
+ clearDiscoveryCache,
55
+ resolveMetadata
56
+ };
package/client/pkce.js ADDED
@@ -0,0 +1,20 @@
1
+ function base64Url(bytes) {
2
+ let binary = "";
3
+ bytes.forEach((byte) => {
4
+ binary += String.fromCharCode(byte);
5
+ });
6
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
7
+ }
8
+ function randomToken(byteLength = 32) {
9
+ const bytes = new Uint8Array(byteLength);
10
+ crypto.getRandomValues(bytes);
11
+ return base64Url(bytes);
12
+ }
13
+ async function codeChallenge(verifier) {
14
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
15
+ return base64Url(new Uint8Array(digest));
16
+ }
17
+ export {
18
+ codeChallenge,
19
+ randomToken
20
+ };
@@ -0,0 +1,111 @@
1
+ import * as z from "zod";
2
+ import { createRemoteJWKSet, jwtVerify } from "jose";
3
+ import { DEFAULT_SCOPES } from "../schema/external-identity-provider.schema.js";
4
+ const ALLOWED_ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "ES256", "ES384"];
5
+ const CLOCK_TOLERANCE_SECONDS = 30;
6
+ const RefreshResponseSchema = z.looseObject({
7
+ access_token: z.string(),
8
+ refresh_token: z.string().optional(),
9
+ id_token: z.string().optional(),
10
+ expires_in: z.number().optional()
11
+ });
12
+ const TokenResponseSchema = z.looseObject({
13
+ id_token: z.string(),
14
+ access_token: z.string().optional(),
15
+ refresh_token: z.string().optional(),
16
+ expires_in: z.number().optional()
17
+ });
18
+ function buildAuthorizationUrl(provider, metadata, request) {
19
+ const url = new URL(metadata.authorizationEndpoint);
20
+ url.searchParams.set("response_type", "code");
21
+ url.searchParams.set("client_id", provider.clientId);
22
+ url.searchParams.set("redirect_uri", request.callbackUrl);
23
+ url.searchParams.set("scope", (provider.scopes ?? DEFAULT_SCOPES).join(" "));
24
+ url.searchParams.set("state", request.state);
25
+ url.searchParams.set("nonce", request.nonce);
26
+ url.searchParams.set("code_challenge", request.codeChallenge);
27
+ url.searchParams.set("code_challenge_method", "S256");
28
+ for (const [key, value] of Object.entries(provider.authorizationParams ?? {})) {
29
+ url.searchParams.set(key, value);
30
+ }
31
+ return url.toString();
32
+ }
33
+ async function exchangeCode(provider, metadata, request) {
34
+ const body = new URLSearchParams({
35
+ grant_type: "authorization_code",
36
+ code: request.code,
37
+ redirect_uri: request.callbackUrl,
38
+ client_id: provider.clientId,
39
+ code_verifier: request.codeVerifier
40
+ });
41
+ if (provider.clientSecret) {
42
+ body.set("client_secret", provider.clientSecret);
43
+ }
44
+ const response = await fetch(metadata.tokenEndpoint, {
45
+ method: "POST",
46
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
47
+ body
48
+ });
49
+ if (!response.ok) {
50
+ throw new Error(`The identity provider refused the authorization code: HTTP ${response.status}`);
51
+ }
52
+ const tokens = TokenResponseSchema.parse(await response.json());
53
+ return {
54
+ idToken: tokens.id_token,
55
+ ...tokens.access_token ? { accessToken: tokens.access_token } : {},
56
+ ...tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {},
57
+ ...tokens.expires_in !== void 0 ? { expiresIn: tokens.expires_in } : {}
58
+ };
59
+ }
60
+ async function refreshTokens(provider, metadata, refreshToken) {
61
+ const body = new URLSearchParams({
62
+ grant_type: "refresh_token",
63
+ refresh_token: refreshToken,
64
+ client_id: provider.clientId
65
+ });
66
+ if (provider.clientSecret) {
67
+ body.set("client_secret", provider.clientSecret);
68
+ }
69
+ const response = await fetch(metadata.tokenEndpoint, {
70
+ method: "POST",
71
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
72
+ body
73
+ });
74
+ if (!response.ok) {
75
+ throw new Error(`The identity provider refused the refresh token: HTTP ${response.status}`);
76
+ }
77
+ const tokens = RefreshResponseSchema.parse(await response.json());
78
+ return {
79
+ accessToken: tokens.access_token,
80
+ ...tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {},
81
+ ...tokens.id_token ? { idToken: tokens.id_token } : {},
82
+ ...tokens.expires_in !== void 0 ? { expiresIn: tokens.expires_in } : {}
83
+ };
84
+ }
85
+ const jwksByUri = /* @__PURE__ */ new Map();
86
+ function keysFor(jwksUri) {
87
+ let keys = jwksByUri.get(jwksUri);
88
+ if (!keys) {
89
+ keys = createRemoteJWKSet(new URL(jwksUri));
90
+ jwksByUri.set(jwksUri, keys);
91
+ }
92
+ return keys;
93
+ }
94
+ async function verifyIdToken(provider, metadata, idToken, expectedNonce) {
95
+ const { payload } = await jwtVerify(idToken, keysFor(metadata.jwksUri), {
96
+ issuer: metadata.issuer,
97
+ audience: provider.clientId,
98
+ algorithms: ALLOWED_ALGORITHMS,
99
+ clockTolerance: CLOCK_TOLERANCE_SECONDS
100
+ });
101
+ if (payload["nonce"] !== expectedNonce) {
102
+ throw new Error("The ID token nonce does not match this login");
103
+ }
104
+ return payload;
105
+ }
106
+ export {
107
+ buildAuthorizationUrl,
108
+ exchangeCode,
109
+ refreshTokens,
110
+ verifyIdToken
111
+ };
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./capabilities/oidc-identity.capability.js";
2
+ export * from "./client/discovery.js";
3
+ export * from "./client/pkce.js";
4
+ export * from "./client/relying-party.js";
5
+ export * from "./schema/external-identity-provider.schema.js";
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@reactionary/oidc",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./index.js",
6
+ "types": "./src/index.d.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Solteq/reactionary.git"
10
+ },
11
+ "dependencies": {
12
+ "@reactionary/core": "0.0.1",
13
+ "zod": "4.1.9",
14
+ "jose": "^6.1.0"
15
+ },
16
+ "devDependencies": {
17
+ "vitest": "^4.0.9"
18
+ },
19
+ "sideEffects": false,
20
+ "peerDependencies": {
21
+ "vitest": "^4.0.9"
22
+ },
23
+ "peerDependenciesMeta": {
24
+ "vitest": {
25
+ "optional": true
26
+ }
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "types": "./src/index.d.ts",
31
+ "default": "./index.js"
32
+ },
33
+ "./testing": {
34
+ "types": "./src/testing/index.d.ts",
35
+ "default": "./testing/index.js"
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,38 @@
1
+ import * as z from "zod";
2
+ const RESERVED_AUTHORIZATION_PARAMS = [
3
+ "client_id",
4
+ "response_type",
5
+ "redirect_uri",
6
+ "scope",
7
+ "state",
8
+ "nonce",
9
+ "code_challenge",
10
+ "code_challenge_method"
11
+ ];
12
+ const DEFAULT_SCOPES = ["openid", "email", "profile"];
13
+ function isSecureUrl(value) {
14
+ const url = new URL(value);
15
+ return url.protocol === "https:" || ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
16
+ }
17
+ const secureUrlSchema = z.url().refine(isSecureUrl, { error: "Must be an https URL (http is only allowed for localhost)" });
18
+ const ExternalIdentityProviderSchema = z.looseObject({
19
+ key: z.string().min(1).meta({ description: "Stable identifier the storefront passes back to startExternalLogin." }),
20
+ displayName: z.string().min(1),
21
+ brand: z.string().optional().meta({ description: "Neutral brand slug (google, apple, ...) so a storefront can choose its own icon." }),
22
+ issuer: secureUrlSchema.meta({ description: "The issuer URL. Used for discovery and as the expected iss claim." }),
23
+ clientId: z.string().min(1),
24
+ clientSecret: z.string().optional().meta({ description: "Without a secret the client is public and relies on PKCE alone." }),
25
+ scopes: z.array(z.string()).optional().meta({ description: `Defaults to ${DEFAULT_SCOPES.join(" ")}.` }),
26
+ authorizationParams: z.record(z.string(), z.string()).refine((params) => Object.keys(params).every((key) => !RESERVED_AUTHORIZATION_PARAMS.includes(key)), {
27
+ error: `authorizationParams cannot set ${RESERVED_AUTHORIZATION_PARAMS.join(", ")}`
28
+ }).optional().meta({ description: 'Fixed extra authorize parameters, e.g. { prompt: "login" } or { connection: "apple" } for Auth0.' }),
29
+ requireVerifiedEmail: z.boolean().optional().meta({ description: "Require email_verified to be true. Defaults to true; some providers (Azure B2C) never send the claim." }),
30
+ authorizationEndpoint: secureUrlSchema.optional(),
31
+ tokenEndpoint: secureUrlSchema.optional(),
32
+ jwksUri: secureUrlSchema.optional()
33
+ });
34
+ export {
35
+ DEFAULT_SCOPES,
36
+ ExternalIdentityProviderSchema,
37
+ RESERVED_AUTHORIZATION_PARAMS
38
+ };
@@ -0,0 +1,28 @@
1
+ import { type Cache, type ExternalLoginRedirect, type Identity, type IdentityConflictError, IdentityCapability, type IdentityMutationCompleteExternalLogin, type IdentityMutationStartExternalLogin, type IdentityProvider, type RequestContext, type Result } from '@reactionary/core';
2
+ import type { JWTPayload } from 'jose';
3
+ import { type OidcTokens } from '../client/relying-party.js';
4
+ import type { ExternalIdentityProvider } from '../schema/external-identity-provider.schema.js';
5
+ export type ExchangeExternalIdentityInput = {
6
+ provider: ExternalIdentityProvider;
7
+ /** The verified ID token claims. */
8
+ claims: JWTPayload;
9
+ tokens: OidcTokens;
10
+ };
11
+ /**
12
+ * Base class for backends that cannot run an external login themselves. This class runs the OpenID Connect part
13
+ * (discovery, authorization code with PKCE, nonce, ID-token validation, binding the callback to the browser that
14
+ * started it); the backend implements exchangeExternalIdentity to turn the verified identity into a backend session.
15
+ */
16
+ export declare abstract class OidcIdentityCapability<TIdentity extends Identity = Identity> extends IdentityCapability<TIdentity> {
17
+ protected readonly externalProviders: ExternalIdentityProvider[];
18
+ constructor(cache: Cache, context: RequestContext, externalProviders: ExternalIdentityProvider[] | undefined);
19
+ /**
20
+ * Creates (or finds) the backend customer for a verified external identity and establishes the backend session,
21
+ * including whatever the backend needs so a guest cart carries over. Returns IdentityConflict when the backend
22
+ * refuses to link the identity to an existing account.
23
+ */
24
+ protected abstract exchangeExternalIdentity(input: ExchangeExternalIdentityInput): Promise<Result<TIdentity, IdentityConflictError>>;
25
+ listProviders(): Promise<Result<IdentityProvider[]>>;
26
+ startExternalLogin(payload: IdentityMutationStartExternalLogin): Promise<Result<ExternalLoginRedirect>>;
27
+ completeExternalLogin(payload: IdentityMutationCompleteExternalLogin): Promise<Result<TIdentity, IdentityConflictError>>;
28
+ }
@@ -0,0 +1,16 @@
1
+ import type { ExternalIdentityProvider } from '../schema/external-identity-provider.schema.js';
2
+ export type OidcMetadata = {
3
+ /** The value ID tokens must carry in their iss claim. */
4
+ issuer: string;
5
+ authorizationEndpoint: string;
6
+ tokenEndpoint: string;
7
+ jwksUri: string;
8
+ endSessionEndpoint?: string;
9
+ };
10
+ export declare function clearDiscoveryCache(): void;
11
+ /**
12
+ * The endpoints of a provider, from its discovery document. When all three endpoints are configured discovery is
13
+ * skipped, which is how providers whose discovery URL differs from their issuer (Azure B2C) are supported.
14
+ * Configured endpoints win over discovered ones; the configured issuer is always the expected iss.
15
+ */
16
+ export declare function resolveMetadata(provider: ExternalIdentityProvider): Promise<OidcMetadata>;
@@ -0,0 +1,4 @@
1
+ /** A URL-safe random value, used for state, nonce and the PKCE code verifier. */
2
+ export declare function randomToken(byteLength?: number): string;
3
+ /** The S256 code challenge for a code verifier. */
4
+ export declare function codeChallenge(verifier: string): Promise<string>;
@@ -0,0 +1,31 @@
1
+ import { type JWTPayload } from 'jose';
2
+ import { type ExternalIdentityProvider } from '../schema/external-identity-provider.schema.js';
3
+ import type { OidcMetadata } from './discovery.js';
4
+ export type OidcTokens = {
5
+ idToken: string;
6
+ accessToken?: string;
7
+ refreshToken?: string;
8
+ expiresIn?: number;
9
+ };
10
+ export type OidcRefreshedTokens = {
11
+ accessToken: string;
12
+ /** The identity provider rotates refresh tokens only sometimes; when it does not, the old one stays valid. */
13
+ refreshToken?: string;
14
+ idToken?: string;
15
+ expiresIn?: number;
16
+ };
17
+ export declare function buildAuthorizationUrl(provider: ExternalIdentityProvider, metadata: OidcMetadata, request: {
18
+ callbackUrl: string;
19
+ state: string;
20
+ nonce: string;
21
+ codeChallenge: string;
22
+ }): string;
23
+ export declare function exchangeCode(provider: ExternalIdentityProvider, metadata: OidcMetadata, request: {
24
+ code: string;
25
+ callbackUrl: string;
26
+ codeVerifier: string;
27
+ }): Promise<OidcTokens>;
28
+ /** Trades a refresh token for a new access token, authenticating the client the same way exchangeCode does. */
29
+ export declare function refreshTokens(provider: ExternalIdentityProvider, metadata: OidcMetadata, refreshToken: string): Promise<OidcRefreshedTokens>;
30
+ /** Verifies signature, issuer, audience, expiry and the nonce, and returns the claims. */
31
+ export declare function verifyIdToken(provider: ExternalIdentityProvider, metadata: OidcMetadata, idToken: string, expectedNonce: string): Promise<JWTPayload>;
package/src/index.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './capabilities/oidc-identity.capability.js';
2
+ export * from './client/discovery.js';
3
+ export * from './client/pkce.js';
4
+ export * from './client/relying-party.js';
5
+ export * from './schema/external-identity-provider.schema.js';
@@ -0,0 +1,24 @@
1
+ import * as z from 'zod';
2
+ /** Authorization request parameters that carry the security of the flow or its shape; configuration must not replace them. */
3
+ export declare const RESERVED_AUTHORIZATION_PARAMS: string[];
4
+ export declare const DEFAULT_SCOPES: string[];
5
+ /**
6
+ * An OpenID Connect identity provider a backend accepts logins from, for backends that cannot list their own.
7
+ * Defaults (scopes, requireVerifiedEmail) are applied where they are used rather than here, so that this type
8
+ * can be written as a plain object in a backend's configuration.
9
+ */
10
+ export declare const ExternalIdentityProviderSchema: z.ZodObject<{
11
+ key: z.ZodString;
12
+ displayName: z.ZodString;
13
+ brand: z.ZodOptional<z.ZodString>;
14
+ issuer: z.ZodURL;
15
+ clientId: z.ZodString;
16
+ clientSecret: z.ZodOptional<z.ZodString>;
17
+ scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
18
+ authorizationParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
19
+ requireVerifiedEmail: z.ZodOptional<z.ZodBoolean>;
20
+ authorizationEndpoint: z.ZodOptional<z.ZodURL>;
21
+ tokenEndpoint: z.ZodOptional<z.ZodURL>;
22
+ jwksUri: z.ZodOptional<z.ZodURL>;
23
+ }, z.core.$loose>;
24
+ export type ExternalIdentityProvider = z.infer<typeof ExternalIdentityProviderSchema>;
@@ -0,0 +1,16 @@
1
+ import { type IdentityCapability, type RequestContext } from '@reactionary/core';
2
+ import type { ExternalIdentityProvider } from '../schema/external-identity-provider.schema.js';
3
+ export type ExternalLoginConformanceOptions = {
4
+ /** Name of the backend, for the test titles. */
5
+ name: string;
6
+ /**
7
+ * Builds the backend's identity capability with the given providers, set up so that a verified identity from
8
+ * the fake provider can be exchanged for a Registered session.
9
+ */
10
+ createCapability: (providers: ExternalIdentityProvider[], context: RequestContext) => IdentityCapability;
11
+ };
12
+ /**
13
+ * The scenarios every backend that uses the shared OpenID Connect client must pass, written against the public
14
+ * IdentityCapability contract only, so the storefront can rely on identical behaviour.
15
+ */
16
+ export declare function runExternalLoginConformance(options: ExternalLoginConformanceOptions): void;
@@ -0,0 +1,36 @@
1
+ import { type JWTPayload } from 'jose';
2
+ export type FakeIdTokenTamper = 'signature' | 'audience' | 'issuer' | 'nonce' | 'expired';
3
+ export type FakeApproval = {
4
+ claims?: JWTPayload;
5
+ /** Makes the issued ID token invalid in one specific way. */
6
+ tamper?: FakeIdTokenTamper;
7
+ };
8
+ /**
9
+ * A local OpenID Connect provider for tests: discovery, JWKS, token endpoint, and signed ID tokens. The shopper's
10
+ * trip through the provider is simulated by approve(), which returns the callback URL the browser would be sent to.
11
+ */
12
+ export declare class FakeOidcProvider {
13
+ private readonly server;
14
+ private readonly signingKey;
15
+ private readonly otherKey;
16
+ private readonly jwk;
17
+ readonly issuer: string;
18
+ readonly clientId = "test-client";
19
+ readonly clientSecret = "test-secret";
20
+ failTokenEndpoint: boolean;
21
+ omitAccessToken: boolean;
22
+ /** Refresh tokens the provider has issued and not yet seen refused. */
23
+ refreshCalls: number;
24
+ rotateRefreshToken: boolean;
25
+ private readonly codes;
26
+ private constructor();
27
+ static start(): Promise<FakeOidcProvider>;
28
+ close(): Promise<void>;
29
+ /**
30
+ * What the identity provider does when the shopper opens the authorization URL and signs in: remember the
31
+ * request and return the callback URL, with code and state, the browser is redirected to.
32
+ */
33
+ approve(authorizationUrl: string, approval?: FakeApproval): string;
34
+ private idToken;
35
+ private handle;
36
+ }
@@ -0,0 +1,2 @@
1
+ export * from './conformance.js';
2
+ export * from './fake-idp.js';
@@ -0,0 +1,132 @@
1
+ import { createInitialRequestContext } from "@reactionary/core";
2
+ import { afterAll, assert, beforeAll, beforeEach, describe, expect, it } from "vitest";
3
+ import { clearDiscoveryCache } from "../client/discovery.js";
4
+ import { FakeOidcProvider } from "./fake-idp.js";
5
+ const CALLBACK_URL = "https://shop.example/auth/callback/fake";
6
+ function runExternalLoginConformance(options) {
7
+ describe(`External login conformance: ${options.name}`, () => {
8
+ let idp;
9
+ let provider;
10
+ let context;
11
+ let identity;
12
+ beforeAll(async () => {
13
+ idp = await FakeOidcProvider.start();
14
+ provider = {
15
+ key: "fake",
16
+ displayName: "Fake IdP",
17
+ brand: "google",
18
+ issuer: idp.issuer,
19
+ clientId: idp.clientId,
20
+ clientSecret: idp.clientSecret
21
+ };
22
+ });
23
+ afterAll(async () => {
24
+ await idp.close();
25
+ });
26
+ beforeEach(() => {
27
+ clearDiscoveryCache();
28
+ idp.failTokenEndpoint = false;
29
+ context = createInitialRequestContext();
30
+ identity = options.createCapability([provider], context);
31
+ });
32
+ async function start() {
33
+ const started = await identity.startExternalLogin({ provider: { key: "fake" }, callbackUrl: CALLBACK_URL });
34
+ assert(started.success);
35
+ return started.value.location;
36
+ }
37
+ function callbackParameters(callbackUrl) {
38
+ return Object.fromEntries(new URL(callbackUrl).searchParams);
39
+ }
40
+ it("offers password login and the configured provider, with its brand", async () => {
41
+ const providers = await identity.listProviders({});
42
+ assert(providers.success);
43
+ expect(providers.value.map((p) => [p.identifier.key, p.flow, p.brand])).toEqual([
44
+ ["password", "credentials", "email"],
45
+ ["fake", "redirect", "google"]
46
+ ]);
47
+ });
48
+ it("starts at the identity provider with PKCE and binds the login to the session", async () => {
49
+ const location = new URL(await start());
50
+ expect(location.origin).toBe(idp.issuer);
51
+ expect(location.searchParams.get("code_challenge_method")).toBe("S256");
52
+ expect(location.searchParams.get("redirect_uri")).toBe(CALLBACK_URL);
53
+ expect(context.session.identityContext.pendingExternalLogin?.provider).toBe("fake");
54
+ });
55
+ it("completes a login and returns a registered identity", async () => {
56
+ const callback = idp.approve(await start());
57
+ const completed = await identity.completeExternalLogin({
58
+ provider: { key: "fake" },
59
+ callbackUrl: CALLBACK_URL,
60
+ callbackParameters: callbackParameters(callback)
61
+ });
62
+ assert(completed.success);
63
+ expect(completed.value.type).toBe("Registered");
64
+ expect(context.session.identityContext.identity.type).toBe("Registered");
65
+ expect(context.session.identityContext.pendingExternalLogin).toBeUndefined();
66
+ });
67
+ it("rejects a callback whose state this session did not issue", async () => {
68
+ const callback = idp.approve(await start());
69
+ const completed = await identity.completeExternalLogin({
70
+ provider: { key: "fake" },
71
+ callbackUrl: CALLBACK_URL,
72
+ callbackParameters: { ...callbackParameters(callback), state: "forged" }
73
+ });
74
+ assert(!completed.success);
75
+ expect(completed.error.type).toBe("InvalidInput");
76
+ });
77
+ it("rejects a callback that is replayed", async () => {
78
+ const callback = idp.approve(await start());
79
+ const parameters = callbackParameters(callback);
80
+ await identity.completeExternalLogin({ provider: { key: "fake" }, callbackUrl: CALLBACK_URL, callbackParameters: parameters });
81
+ const replay = await identity.completeExternalLogin({ provider: { key: "fake" }, callbackUrl: CALLBACK_URL, callbackParameters: parameters });
82
+ assert(!replay.success);
83
+ });
84
+ it("rejects a login the shopper abandoned at the identity provider", async () => {
85
+ const callback = new URL(idp.approve(await start()));
86
+ const completed = await identity.completeExternalLogin({
87
+ provider: { key: "fake" },
88
+ callbackUrl: CALLBACK_URL,
89
+ callbackParameters: { state: callback.searchParams.get("state") ?? "", error: "access_denied" }
90
+ });
91
+ assert(!completed.success);
92
+ expect(completed.error.type).toBe("InvalidInput");
93
+ });
94
+ it("rejects an unknown provider", async () => {
95
+ const started = await identity.startExternalLogin({ provider: { key: "nope" }, callbackUrl: CALLBACK_URL });
96
+ assert(!started.success);
97
+ expect(started.error.type).toBe("InvalidInput");
98
+ });
99
+ it.each(["signature", "audience", "issuer", "nonce", "expired"])("rejects an ID token with a bad %s", async (tamper) => {
100
+ const callback = idp.approve(await start(), { tamper });
101
+ const completed = await identity.completeExternalLogin({
102
+ provider: { key: "fake" },
103
+ callbackUrl: CALLBACK_URL,
104
+ callbackParameters: callbackParameters(callback)
105
+ });
106
+ assert(!completed.success);
107
+ expect(context.session.identityContext.identity.type).not.toBe("Registered");
108
+ });
109
+ it("rejects an unverified email", async () => {
110
+ const callback = idp.approve(await start(), { claims: { email_verified: false } });
111
+ const completed = await identity.completeExternalLogin({
112
+ provider: { key: "fake" },
113
+ callbackUrl: CALLBACK_URL,
114
+ callbackParameters: callbackParameters(callback)
115
+ });
116
+ assert(!completed.success);
117
+ });
118
+ it("fails when the identity provider refuses the authorization code", async () => {
119
+ const callback = idp.approve(await start());
120
+ idp.failTokenEndpoint = true;
121
+ const completed = await identity.completeExternalLogin({
122
+ provider: { key: "fake" },
123
+ callbackUrl: CALLBACK_URL,
124
+ callbackParameters: callbackParameters(callback)
125
+ });
126
+ assert(!completed.success);
127
+ });
128
+ });
129
+ }
130
+ export {
131
+ runExternalLoginConformance
132
+ };
@@ -0,0 +1,138 @@
1
+ import { createServer } from "node:http";
2
+ import { createHash } from "node:crypto";
3
+ import { SignJWT, exportJWK, generateKeyPair } from "jose";
4
+ const KEY_ID = "fake-idp-key";
5
+ class FakeOidcProvider {
6
+ constructor(server, signingKey, otherKey, jwk, issuer) {
7
+ this.server = server;
8
+ this.signingKey = signingKey;
9
+ this.otherKey = otherKey;
10
+ this.jwk = jwk;
11
+ this.issuer = issuer;
12
+ }
13
+ server;
14
+ signingKey;
15
+ otherKey;
16
+ jwk;
17
+ issuer;
18
+ clientId = "test-client";
19
+ clientSecret = "test-secret";
20
+ failTokenEndpoint = false;
21
+ omitAccessToken = false;
22
+ /** Refresh tokens the provider has issued and not yet seen refused. */
23
+ refreshCalls = 0;
24
+ rotateRefreshToken = false;
25
+ codes = /* @__PURE__ */ new Map();
26
+ static async start() {
27
+ const { publicKey, privateKey } = await generateKeyPair("RS256");
28
+ const other = await generateKeyPair("RS256");
29
+ const jwk = { ...await exportJWK(publicKey), kid: KEY_ID, alg: "RS256", use: "sig" };
30
+ const server = createServer();
31
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
32
+ const { port } = server.address();
33
+ const provider = new FakeOidcProvider(server, privateKey, other.privateKey, jwk, `http://127.0.0.1:${port}`);
34
+ server.on("request", (req, res) => {
35
+ void provider.handle(req.method ?? "GET", req.url ?? "/", req, res);
36
+ });
37
+ return provider;
38
+ }
39
+ async close() {
40
+ await new Promise((resolve) => this.server.close(() => resolve()));
41
+ }
42
+ /**
43
+ * What the identity provider does when the shopper opens the authorization URL and signs in: remember the
44
+ * request and return the callback URL, with code and state, the browser is redirected to.
45
+ */
46
+ approve(authorizationUrl, approval = {}) {
47
+ const url = new URL(authorizationUrl);
48
+ const params = url.searchParams;
49
+ if (url.origin !== this.issuer || url.pathname !== "/authorize") {
50
+ throw new Error(`Not an authorization URL of this provider: ${authorizationUrl}`);
51
+ }
52
+ if (params.get("code_challenge_method") !== "S256" || params.get("response_type") !== "code") {
53
+ throw new Error("The authorization request must use the code flow with PKCE S256");
54
+ }
55
+ const code = `code-${this.codes.size + 1}`;
56
+ this.codes.set(code, {
57
+ clientId: params.get("client_id") ?? "",
58
+ redirectUri: params.get("redirect_uri") ?? "",
59
+ codeChallenge: params.get("code_challenge") ?? "",
60
+ nonce: params.get("nonce") ?? "",
61
+ approval: { claims: approval.claims ?? {}, ...approval.tamper ? { tamper: approval.tamper } : {} }
62
+ });
63
+ const callback = new URL(params.get("redirect_uri") ?? "");
64
+ callback.searchParams.set("code", code);
65
+ callback.searchParams.set("state", params.get("state") ?? "");
66
+ return callback.toString();
67
+ }
68
+ async idToken(issued) {
69
+ const tamper = issued.approval.tamper;
70
+ const now = Math.floor(Date.now() / 1e3);
71
+ const claims = {
72
+ sub: "user-1",
73
+ email: "ada@example.com",
74
+ email_verified: true,
75
+ name: "Ada Lovelace",
76
+ given_name: "Ada",
77
+ family_name: "Lovelace",
78
+ ...issued.approval.claims,
79
+ nonce: tamper === "nonce" ? "someone-elses-nonce" : issued.nonce
80
+ };
81
+ return new SignJWT(claims).setProtectedHeader({ alg: "RS256", kid: KEY_ID }).setIssuer(tamper === "issuer" ? "https://evil.example" : this.issuer).setAudience(tamper === "audience" ? "another-client" : issued.clientId).setIssuedAt(tamper === "expired" ? now - 7200 : now).setExpirationTime(tamper === "expired" ? now - 3600 : now + 300).sign(tamper === "signature" ? this.otherKey : this.signingKey);
82
+ }
83
+ async handle(method, path, req, res) {
84
+ const send = (status, body) => {
85
+ res.statusCode = status;
86
+ res.setHeader("content-type", "application/json");
87
+ res.end(JSON.stringify(body));
88
+ };
89
+ if (path === "/.well-known/openid-configuration") {
90
+ return send(200, {
91
+ issuer: this.issuer,
92
+ authorization_endpoint: `${this.issuer}/authorize`,
93
+ token_endpoint: `${this.issuer}/token`,
94
+ jwks_uri: `${this.issuer}/jwks`,
95
+ end_session_endpoint: `${this.issuer}/logout`
96
+ });
97
+ }
98
+ if (path === "/jwks") {
99
+ return send(200, { keys: [this.jwk] });
100
+ }
101
+ if (path === "/token" && method === "POST") {
102
+ const chunks = [];
103
+ for await (const chunk of req) {
104
+ chunks.push(chunk);
105
+ }
106
+ const form = new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
107
+ if (form.get("grant_type") === "refresh_token") {
108
+ this.refreshCalls += 1;
109
+ if (this.failTokenEndpoint || form.get("refresh_token") !== "refresh-token" || form.get("client_id") !== this.clientId) {
110
+ return send(400, { error: "invalid_grant" });
111
+ }
112
+ return send(200, {
113
+ token_type: "Bearer",
114
+ access_token: `access-token-${this.refreshCalls + 1}`,
115
+ expires_in: 300,
116
+ ...this.rotateRefreshToken ? { refresh_token: "refresh-token" } : {}
117
+ });
118
+ }
119
+ const issued = this.codes.get(form.get("code") ?? "");
120
+ this.codes.delete(form.get("code") ?? "");
121
+ const challenge = createHash("sha256").update(form.get("code_verifier") ?? "").digest("base64url");
122
+ if (this.failTokenEndpoint || !issued || form.get("client_id") !== issued.clientId || form.get("redirect_uri") !== issued.redirectUri || challenge !== issued.codeChallenge || form.has("client_secret") && form.get("client_secret") !== this.clientSecret) {
123
+ return send(400, { error: "invalid_grant" });
124
+ }
125
+ return send(200, {
126
+ token_type: "Bearer",
127
+ ...this.omitAccessToken ? {} : { access_token: "access-token" },
128
+ refresh_token: "refresh-token",
129
+ expires_in: 300,
130
+ id_token: await this.idToken(issued)
131
+ });
132
+ }
133
+ send(404, { error: "not_found" });
134
+ }
135
+ }
136
+ export {
137
+ FakeOidcProvider
138
+ };
@@ -0,0 +1,2 @@
1
+ export * from "./conformance.js";
2
+ export * from "./fake-idp.js";