@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 +18 -2
- package/capabilities/oidc-identity.capability.js +113 -0
- package/client/discovery.js +56 -0
- package/client/pkce.js +20 -0
- package/client/relying-party.js +111 -0
- package/index.js +5 -0
- package/package.json +36 -4
- package/schema/external-identity-provider.schema.js +38 -0
- package/src/capabilities/oidc-identity.capability.d.ts +28 -0
- package/src/client/discovery.d.ts +16 -0
- package/src/client/pkce.d.ts +4 -0
- package/src/client/relying-party.d.ts +31 -0
- package/src/index.d.ts +5 -0
- package/src/schema/external-identity-provider.schema.d.ts +24 -0
- package/src/testing/conformance.d.ts +16 -0
- package/src/testing/fake-idp.d.ts +36 -0
- package/src/testing/index.d.ts +2 -0
- package/testing/conformance.js +132 -0
- package/testing/fake-idp.js +138 -0
- package/testing/index.js +2 -0
package/README.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @reactionary/oidc
|
|
2
2
|
|
|
3
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reactionary/oidc",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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,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,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
|
+
};
|
package/testing/index.js
ADDED