@_mustachio/openauth 0.13.3 → 0.15.0
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/dist/esm/client.js +61 -62
- package/dist/esm/domain/authorize.js +10 -0
- package/dist/esm/domain/callback.js +21 -19
- package/dist/esm/domain/client-credentials.js +16 -0
- package/dist/esm/domain/method-route.js +10 -0
- package/dist/esm/domain/refresh.js +16 -5
- package/dist/esm/domain/register.js +8 -2
- package/dist/esm/domain/state-envelope.js +19 -0
- package/dist/esm/domain/subject.js +35 -0
- package/dist/esm/domain/token.js +28 -0
- package/dist/esm/http/handlers/token.js +6 -0
- package/dist/esm/http/middleware/tenant.js +5 -20
- package/dist/esm/index.js +2 -0
- package/dist/types/client.d.ts +61 -40
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/domain/authorize.d.ts.map +1 -1
- package/dist/types/domain/callback.d.ts.map +1 -1
- package/dist/types/domain/client-credentials.d.ts +6 -2
- package/dist/types/domain/client-credentials.d.ts.map +1 -1
- package/dist/types/domain/method-route.d.ts.map +1 -1
- package/dist/types/domain/refresh.d.ts +3 -0
- package/dist/types/domain/refresh.d.ts.map +1 -1
- package/dist/types/domain/register.d.ts.map +1 -1
- package/dist/types/domain/state-envelope.d.ts +22 -0
- package/dist/types/domain/state-envelope.d.ts.map +1 -1
- package/dist/types/domain/subject.d.ts +48 -0
- package/dist/types/domain/subject.d.ts.map +1 -0
- package/dist/types/domain/token-exchange.d.ts +3 -1
- package/dist/types/domain/token-exchange.d.ts.map +1 -1
- package/dist/types/domain/token.d.ts +11 -2
- package/dist/types/domain/token.d.ts.map +1 -1
- package/dist/types/http/context.d.ts +6 -0
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/handlers/token.d.ts.map +1 -1
- package/dist/types/http/middleware/tenant.d.ts.map +1 -1
- package/dist/types/http/schemas/revocation.d.ts +4 -4
- package/dist/types/http/schemas/token.d.ts +12 -12
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/ports/audit-log.d.ts +25 -1
- package/dist/types/ports/audit-log.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +105 -38
- package/dist/types/types/idp.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +145 -129
- package/src/domain/authorize.ts +10 -0
- package/src/domain/callback.ts +22 -29
- package/src/domain/client-credentials.ts +27 -2
- package/src/domain/method-route.ts +10 -0
- package/src/domain/refresh.ts +42 -10
- package/src/domain/register.ts +27 -8
- package/src/domain/state-envelope.ts +40 -0
- package/src/domain/subject.ts +103 -0
- package/src/domain/token-exchange.ts +3 -1
- package/src/domain/token.ts +54 -2
- package/src/http/context.ts +6 -0
- package/src/http/handlers/token.ts +6 -0
- package/src/http/middleware/tenant.ts +5 -24
- package/src/index.ts +3 -2
- package/src/ports/audit-log.ts +26 -1
- package/src/types/idp.ts +107 -41
package/dist/esm/client.js
CHANGED
|
@@ -37,59 +37,59 @@ function createClient(input) {
|
|
|
37
37
|
jwksCache.set(issuer, result);
|
|
38
38
|
return result;
|
|
39
39
|
}
|
|
40
|
+
function applyClientAuth(headers, body) {
|
|
41
|
+
const secret = input.clientSecret;
|
|
42
|
+
if (secret === undefined)
|
|
43
|
+
return;
|
|
44
|
+
if ((input.tokenEndpointAuthMethod ?? "client_secret_basic") === "client_secret_post") {
|
|
45
|
+
body.set("client_secret", secret);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const cred = `${encodeURIComponent(input.clientID)}:${encodeURIComponent(secret)}`;
|
|
49
|
+
headers["authorization"] = `Basic ${btoa(cred)}`;
|
|
50
|
+
}
|
|
40
51
|
const result = {
|
|
41
|
-
async authorize(redirectURI,
|
|
42
|
-
const
|
|
52
|
+
async authorize(redirectURI, opts) {
|
|
53
|
+
const wk = await getIssuer();
|
|
54
|
+
const url = new URL(wk.authorization_endpoint);
|
|
55
|
+
const pkce = await generatePKCE();
|
|
43
56
|
const challenge = {
|
|
44
|
-
state: crypto.randomUUID()
|
|
57
|
+
state: crypto.randomUUID(),
|
|
58
|
+
verifier: pkce.verifier
|
|
45
59
|
};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
url.searchParams.set("client_id", input.clientID);
|
|
61
|
+
url.searchParams.set("redirect_uri", redirectURI);
|
|
62
|
+
url.searchParams.set("response_type", "code");
|
|
63
|
+
url.searchParams.set("state", challenge.state);
|
|
64
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
65
|
+
url.searchParams.set("code_challenge", pkce.challenge);
|
|
50
66
|
if (opts?.provider)
|
|
51
|
-
|
|
67
|
+
url.searchParams.set("provider", opts.provider);
|
|
52
68
|
if (opts?.scope !== undefined) {
|
|
53
69
|
const scope = Array.isArray(opts.scope) ? opts.scope.join(" ") : opts.scope;
|
|
54
70
|
if (scope)
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
if (opts?.pkce && response === "code") {
|
|
58
|
-
const pkce = await generatePKCE();
|
|
59
|
-
result.searchParams.set("code_challenge_method", "S256");
|
|
60
|
-
result.searchParams.set("code_challenge", pkce.challenge);
|
|
61
|
-
challenge.verifier = pkce.verifier;
|
|
71
|
+
url.searchParams.set("scope", scope);
|
|
62
72
|
}
|
|
63
|
-
return {
|
|
64
|
-
challenge,
|
|
65
|
-
url: result.toString()
|
|
66
|
-
};
|
|
67
|
-
},
|
|
68
|
-
async pkce(redirectURI, opts) {
|
|
69
|
-
const result = new URL(issuer + "/authorize");
|
|
70
|
-
if (opts?.provider)
|
|
71
|
-
result.searchParams.set("provider", opts.provider);
|
|
72
|
-
result.searchParams.set("client_id", input.clientID);
|
|
73
|
-
result.searchParams.set("redirect_uri", redirectURI);
|
|
74
|
-
result.searchParams.set("response_type", "code");
|
|
75
|
-
const pkce = await generatePKCE();
|
|
76
|
-
result.searchParams.set("code_challenge_method", "S256");
|
|
77
|
-
result.searchParams.set("code_challenge", pkce.challenge);
|
|
78
|
-
return [pkce.verifier, result.toString()];
|
|
73
|
+
return { challenge, url: url.toString() };
|
|
79
74
|
},
|
|
80
75
|
async exchange(code, redirectURI, verifier) {
|
|
81
|
-
const
|
|
76
|
+
const wk = await getIssuer();
|
|
77
|
+
const body = new URLSearchParams({
|
|
78
|
+
code,
|
|
79
|
+
redirect_uri: redirectURI,
|
|
80
|
+
grant_type: "authorization_code",
|
|
81
|
+
client_id: input.clientID
|
|
82
|
+
});
|
|
83
|
+
if (verifier)
|
|
84
|
+
body.set("code_verifier", verifier);
|
|
85
|
+
const headers = {
|
|
86
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
87
|
+
};
|
|
88
|
+
applyClientAuth(headers, body);
|
|
89
|
+
const tokens = await f(wk.token_endpoint, {
|
|
82
90
|
method: "POST",
|
|
83
|
-
headers
|
|
84
|
-
|
|
85
|
-
},
|
|
86
|
-
body: new URLSearchParams({
|
|
87
|
-
code,
|
|
88
|
-
redirect_uri: redirectURI,
|
|
89
|
-
grant_type: "authorization_code",
|
|
90
|
-
client_id: input.clientID,
|
|
91
|
-
code_verifier: verifier || ""
|
|
92
|
-
}).toString()
|
|
91
|
+
headers,
|
|
92
|
+
body: body.toString()
|
|
93
93
|
});
|
|
94
94
|
const json = await tokens.json();
|
|
95
95
|
if (!tokens.ok) {
|
|
@@ -121,15 +121,20 @@ function createClient(input) {
|
|
|
121
121
|
};
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
-
const
|
|
124
|
+
const wk = await getIssuer();
|
|
125
|
+
const body = new URLSearchParams({
|
|
126
|
+
grant_type: "refresh_token",
|
|
127
|
+
refresh_token: refresh,
|
|
128
|
+
client_id: input.clientID
|
|
129
|
+
});
|
|
130
|
+
const headers = {
|
|
131
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
132
|
+
};
|
|
133
|
+
applyClientAuth(headers, body);
|
|
134
|
+
const tokens = await f(wk.token_endpoint, {
|
|
125
135
|
method: "POST",
|
|
126
|
-
headers
|
|
127
|
-
|
|
128
|
-
},
|
|
129
|
-
body: new URLSearchParams({
|
|
130
|
-
grant_type: "refresh_token",
|
|
131
|
-
refresh_token: refresh
|
|
132
|
-
}).toString()
|
|
136
|
+
headers,
|
|
137
|
+
body: body.toString()
|
|
133
138
|
});
|
|
134
139
|
const json = await tokens.json();
|
|
135
140
|
if (!tokens.ok) {
|
|
@@ -151,30 +156,24 @@ function createClient(input) {
|
|
|
151
156
|
const jwks = await getJWKS();
|
|
152
157
|
try {
|
|
153
158
|
const result = await jwtVerify(token, jwks, {
|
|
154
|
-
issuer
|
|
159
|
+
issuer,
|
|
160
|
+
audience: options?.audience ?? input.clientID
|
|
155
161
|
});
|
|
156
162
|
const claim = result.payload.claim;
|
|
157
|
-
|
|
158
|
-
let subjectProperties;
|
|
159
|
-
if (claim && typeof claim.type === "string") {
|
|
160
|
-
subjectType = claim.type;
|
|
161
|
-
subjectProperties = claim.properties;
|
|
162
|
-
} else if (result.payload.mode === "access" && typeof result.payload.type === "string") {
|
|
163
|
-
subjectType = result.payload.type;
|
|
164
|
-
subjectProperties = result.payload.properties;
|
|
165
|
-
}
|
|
166
|
-
if (subjectType === undefined) {
|
|
163
|
+
if (!claim || typeof claim.type !== "string") {
|
|
167
164
|
return { err: new InvalidSubjectError };
|
|
168
165
|
}
|
|
166
|
+
const subjectType = claim.type;
|
|
169
167
|
const schema = subjects[subjectType];
|
|
170
168
|
if (!schema) {
|
|
171
169
|
return { err: new InvalidSubjectError };
|
|
172
170
|
}
|
|
173
|
-
const validated = await schema["~standard"].validate(
|
|
171
|
+
const validated = await schema["~standard"].validate(claim.properties);
|
|
174
172
|
if (validated.issues) {
|
|
175
173
|
return { err: new InvalidSubjectError };
|
|
176
174
|
}
|
|
177
175
|
return {
|
|
176
|
+
err: false,
|
|
178
177
|
aud: result.payload.aud,
|
|
179
178
|
subject: {
|
|
180
179
|
type: subjectType,
|
|
@@ -211,6 +211,16 @@ async function issueCodeFromInlineSuccess(result, record, deps) {
|
|
|
211
211
|
}, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
|
|
212
212
|
if (isErr(saved))
|
|
213
213
|
return err(saved.error);
|
|
214
|
+
await safeAudit(deps, {
|
|
215
|
+
kind: "authorize_succeeded",
|
|
216
|
+
tenantId: flow.tenantId,
|
|
217
|
+
clientId: flow.clientId,
|
|
218
|
+
methodId: flow.methodId,
|
|
219
|
+
methodKind: flow.methodKind,
|
|
220
|
+
flowId: flow.flowId,
|
|
221
|
+
providerSubject: result.providerSubject,
|
|
222
|
+
timestamp: now
|
|
223
|
+
});
|
|
214
224
|
return ok({
|
|
215
225
|
kind: "issue-code",
|
|
216
226
|
code,
|
|
@@ -6,26 +6,8 @@ import { randomToken } from "./crypto";
|
|
|
6
6
|
import { dispatchMethod } from "./method-dispatch";
|
|
7
7
|
import { callbackTarget } from "./mount";
|
|
8
8
|
import { saveEncryptedCode } from "./token";
|
|
9
|
-
import { verifyStateEnvelope } from "./state-envelope";
|
|
9
|
+
import { extractCallbackState, verifyStateEnvelope } from "./state-envelope";
|
|
10
10
|
import { AUTH_CODE_TTL_MS } from "./authorize";
|
|
11
|
-
async function extractCallbackState(req) {
|
|
12
|
-
const fromQuery = new URL(req.url).searchParams.get("state");
|
|
13
|
-
if (fromQuery)
|
|
14
|
-
return fromQuery;
|
|
15
|
-
if (req.method !== "POST")
|
|
16
|
-
return null;
|
|
17
|
-
const ct = req.headers.get("content-type") ?? "";
|
|
18
|
-
if (!ct.includes("application/x-www-form-urlencoded"))
|
|
19
|
-
return null;
|
|
20
|
-
try {
|
|
21
|
-
const body = await req.clone().text();
|
|
22
|
-
const form = new URLSearchParams(body);
|
|
23
|
-
const v = form.get("state") ?? form.get("RelayState");
|
|
24
|
-
return v && v.length > 0 ? v : null;
|
|
25
|
-
} catch {
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
11
|
async function handleCallback(input, deps) {
|
|
30
12
|
const url = new URL(input.rawRequest.url);
|
|
31
13
|
const state = await extractCallbackState(input.rawRequest);
|
|
@@ -147,6 +129,16 @@ async function translate(result, flow, deps) {
|
|
|
147
129
|
}, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
|
|
148
130
|
if (isErr(saved))
|
|
149
131
|
return err(saved.error);
|
|
132
|
+
await safeAudit(deps, {
|
|
133
|
+
kind: "authorize_succeeded",
|
|
134
|
+
tenantId: flow.tenantId,
|
|
135
|
+
clientId: flow.clientId,
|
|
136
|
+
methodId: flow.methodId,
|
|
137
|
+
methodKind: flow.methodKind,
|
|
138
|
+
flowId: flow.flowId,
|
|
139
|
+
providerSubject: result.providerSubject,
|
|
140
|
+
timestamp: now
|
|
141
|
+
});
|
|
150
142
|
return ok({
|
|
151
143
|
kind: "issue-code",
|
|
152
144
|
code,
|
|
@@ -282,6 +274,16 @@ async function tryIdpInitiated(input, deps, url) {
|
|
|
282
274
|
}, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
|
|
283
275
|
if (isErr(saved))
|
|
284
276
|
return err(saved.error);
|
|
277
|
+
await safeAudit(deps, {
|
|
278
|
+
kind: "authorize_succeeded",
|
|
279
|
+
tenantId: input.tenant.id,
|
|
280
|
+
clientId: binding.clientId,
|
|
281
|
+
methodId,
|
|
282
|
+
methodKind: method.kind,
|
|
283
|
+
flowId: "",
|
|
284
|
+
providerSubject: result.providerSubject,
|
|
285
|
+
timestamp: now
|
|
286
|
+
});
|
|
285
287
|
return ok({
|
|
286
288
|
kind: "issue-code",
|
|
287
289
|
code,
|
|
@@ -4,6 +4,8 @@ import { err, isErr, ok } from "../types/result";
|
|
|
4
4
|
import { verifyClientCredentials } from "./client-auth";
|
|
5
5
|
import { randomId } from "./crypto";
|
|
6
6
|
import { mintTokens } from "./token";
|
|
7
|
+
import { safeAudit } from "./audit";
|
|
8
|
+
import { validateSubjectClaim } from "./subject";
|
|
7
9
|
async function clientCredentialsGrant(req, deps, tenantId) {
|
|
8
10
|
const tenantCfg = await deps.configStore.getTenantConfig(tenantId);
|
|
9
11
|
if (isErr(tenantCfg))
|
|
@@ -67,6 +69,20 @@ async function clientCredentialsGrant(req, deps, tenantId) {
|
|
|
67
69
|
} catch (e) {
|
|
68
70
|
return err(authError.serverError("success callback threw", e));
|
|
69
71
|
}
|
|
72
|
+
const checked = await validateSubjectClaim(deps.subjects, claim);
|
|
73
|
+
if (isErr(checked)) {
|
|
74
|
+
await safeAudit(deps, {
|
|
75
|
+
kind: "invalid_subject_claim",
|
|
76
|
+
tenantId: tenant.id,
|
|
77
|
+
clientId: client.id,
|
|
78
|
+
subjectType: checked.error.rejection.subjectType,
|
|
79
|
+
reason: checked.error.rejection.reason,
|
|
80
|
+
detail: checked.error.rejection.detail,
|
|
81
|
+
timestamp: deps.clock()
|
|
82
|
+
});
|
|
83
|
+
return err(checked.error);
|
|
84
|
+
}
|
|
85
|
+
claim = checked.value;
|
|
70
86
|
const minted = await mintTokens({
|
|
71
87
|
tenant,
|
|
72
88
|
claim,
|
|
@@ -74,6 +74,16 @@ async function translate(result, flow, deps) {
|
|
|
74
74
|
}, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
|
|
75
75
|
if (isErr(saved))
|
|
76
76
|
return err(saved.error);
|
|
77
|
+
await safeAudit(deps, {
|
|
78
|
+
kind: "authorize_succeeded",
|
|
79
|
+
tenantId: final.tenantId,
|
|
80
|
+
clientId: final.clientId,
|
|
81
|
+
methodId: final.methodId,
|
|
82
|
+
methodKind: final.methodKind,
|
|
83
|
+
flowId: final.flowId,
|
|
84
|
+
providerSubject: result.providerSubject,
|
|
85
|
+
timestamp: now
|
|
86
|
+
});
|
|
77
87
|
return ok({
|
|
78
88
|
kind: "issue-code",
|
|
79
89
|
code,
|
|
@@ -4,6 +4,16 @@ import { err, isErr } from "../types/result";
|
|
|
4
4
|
import { safeAudit } from "./audit";
|
|
5
5
|
import { verifyClientCredentials } from "./client-auth";
|
|
6
6
|
import { mintTokens } from "./token";
|
|
7
|
+
function validateRequestedScopes(requested, granted) {
|
|
8
|
+
if (!requested)
|
|
9
|
+
return null;
|
|
10
|
+
for (const s of requested.split(" ").filter(Boolean)) {
|
|
11
|
+
if (!granted.includes(s)) {
|
|
12
|
+
return authError.invalidScope(`requested scope "${s}" not granted by original refresh token`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
7
17
|
async function refreshTokens(req, deps) {
|
|
8
18
|
const peek = await deps.tokenStore.peekRefresh(req.refreshToken);
|
|
9
19
|
if (isErr(peek))
|
|
@@ -39,6 +49,9 @@ async function refreshTokens(req, deps) {
|
|
|
39
49
|
return err(authError.invalidDpopProof("refresh token DPoP jkt does not match the original binding"));
|
|
40
50
|
}
|
|
41
51
|
}
|
|
52
|
+
const scopeErr = validateRequestedScopes(req.scope, peekedPayload.scopes);
|
|
53
|
+
if (scopeErr)
|
|
54
|
+
return err(scopeErr);
|
|
42
55
|
const consumed = await deps.tokenStore.consumeRefresh(req.refreshToken, {
|
|
43
56
|
reuseWindowMs: deps.reuseWindowMs
|
|
44
57
|
});
|
|
@@ -56,12 +69,10 @@ async function refreshTokens(req, deps) {
|
|
|
56
69
|
return err(consumed.error);
|
|
57
70
|
}
|
|
58
71
|
const payload = consumed.value;
|
|
72
|
+
const authoritativeScopeErr = validateRequestedScopes(req.scope, payload.scopes);
|
|
73
|
+
if (authoritativeScopeErr)
|
|
74
|
+
return err(authoritativeScopeErr);
|
|
59
75
|
const requestedScopes = req.scope ? req.scope.split(" ").filter(Boolean) : payload.scopes;
|
|
60
|
-
for (const s of requestedScopes) {
|
|
61
|
-
if (!payload.scopes.includes(s)) {
|
|
62
|
-
return err(authError.invalidScope(`requested scope "${s}" not granted by original refresh token`));
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
76
|
const tenant = {
|
|
66
77
|
id: payload.tenantId,
|
|
67
78
|
config: tenantCfg.value,
|
|
@@ -50,12 +50,18 @@ async function registerNewClient(request, tenant, deps) {
|
|
|
50
50
|
};
|
|
51
51
|
const hookResult = await deps.registerClient({
|
|
52
52
|
tenant,
|
|
53
|
-
request
|
|
53
|
+
request,
|
|
54
|
+
client: clientConfig,
|
|
55
|
+
...secret !== undefined ? { secret } : {}
|
|
54
56
|
});
|
|
55
57
|
if (isErr(hookResult))
|
|
56
58
|
return err(hookResult.error);
|
|
57
59
|
const persisted = hookResult.value.client;
|
|
58
|
-
const
|
|
60
|
+
const keptOurSecret = persisted.type === "confidential" && clientConfig.type === "confidential" && persisted.secretHash === clientConfig.secretHash;
|
|
61
|
+
const persistedSecret = hookResult.value.secret ?? (keptOurSecret ? secret : undefined);
|
|
62
|
+
if (persisted.type === "confidential" && persistedSecret === undefined) {
|
|
63
|
+
return err(authError.serverError("registerClient persisted a confidential client with a substituted " + "secretHash but returned no matching plaintext secret"));
|
|
64
|
+
}
|
|
59
65
|
const issuedAt = Math.floor(deps.clock() / 1000);
|
|
60
66
|
return ok({
|
|
61
67
|
client_id: persisted.id,
|
|
@@ -74,7 +74,26 @@ function isEnvelopeShape(value) {
|
|
|
74
74
|
const v = value;
|
|
75
75
|
return typeof v.tenantId === "string" && typeof v.flowId === "string" && typeof v.nonce === "string" && typeof v.kid === "string";
|
|
76
76
|
}
|
|
77
|
+
async function extractCallbackState(req) {
|
|
78
|
+
const fromQuery = new URL(req.url).searchParams.get("state");
|
|
79
|
+
if (fromQuery)
|
|
80
|
+
return fromQuery;
|
|
81
|
+
if (req.method !== "POST")
|
|
82
|
+
return null;
|
|
83
|
+
const ct = req.headers.get("content-type") ?? "";
|
|
84
|
+
if (!ct.toLowerCase().includes("application/x-www-form-urlencoded")) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const form = new URLSearchParams(await req.clone().text());
|
|
89
|
+
const v = form.get("state") ?? form.get("RelayState");
|
|
90
|
+
return v && v.length > 0 ? v : null;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
77
95
|
export {
|
|
96
|
+
extractCallbackState,
|
|
78
97
|
mintStateEnvelope,
|
|
79
98
|
verifyStateEnvelope
|
|
80
99
|
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// src/domain/subject.ts
|
|
2
|
+
import { authError } from "../types/error";
|
|
3
|
+
import { err, ok } from "../types/result";
|
|
4
|
+
async function validateSubjectClaim(subjects, claim) {
|
|
5
|
+
const declared = Object.keys(subjects);
|
|
6
|
+
const subjectType = typeof claim?.type === "string" ? claim.type : "";
|
|
7
|
+
const schema = subjectType ? subjects[subjectType] : undefined;
|
|
8
|
+
if (!schema) {
|
|
9
|
+
return err(Object.assign(authError.serverError(`success() returned subject type "${subjectType}", which is not declared in \`subjects\``), {
|
|
10
|
+
rejection: {
|
|
11
|
+
reason: "unknown-type",
|
|
12
|
+
subjectType,
|
|
13
|
+
detail: `declared: ${declared.join(", ") || "(none)"}`
|
|
14
|
+
}
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
const validated = await schema["~standard"].validate(claim.properties);
|
|
18
|
+
if (validated.issues) {
|
|
19
|
+
const detail = validated.issues.map((i) => (i.path ?? []).map(String).join(".") || "(root)").join(", ") || "(root)";
|
|
20
|
+
return err(Object.assign(authError.serverError(`success() returned properties that violate the "${subjectType}" schema`), {
|
|
21
|
+
rejection: {
|
|
22
|
+
reason: "invalid-properties",
|
|
23
|
+
subjectType,
|
|
24
|
+
detail
|
|
25
|
+
}
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
return ok({
|
|
29
|
+
type: subjectType,
|
|
30
|
+
properties: validated.value
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export {
|
|
34
|
+
validateSubjectClaim
|
|
35
|
+
};
|
package/dist/esm/domain/token.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { buildIdTokenClaims, shouldIssueIdToken } from "./id-token";
|
|
16
16
|
import { signAccessToken, signIdToken } from "./jwt";
|
|
17
17
|
import { validatePkce } from "./pkce";
|
|
18
|
+
import { validateSubjectClaim } from "./subject";
|
|
18
19
|
async function saveEncryptedCode(code, payload, ttl, deps) {
|
|
19
20
|
const keyResult = await deps.keyStore.currentEncryptionKey();
|
|
20
21
|
if (isErr(keyResult))
|
|
@@ -91,6 +92,20 @@ async function exchangeCode(req, deps) {
|
|
|
91
92
|
} catch (e) {
|
|
92
93
|
return err(authError.serverError("success callback threw", e));
|
|
93
94
|
}
|
|
95
|
+
const checked = await validateSubjectClaim(deps.subjects, claim);
|
|
96
|
+
if (isErr(checked)) {
|
|
97
|
+
await safeAudit(deps, {
|
|
98
|
+
kind: "invalid_subject_claim",
|
|
99
|
+
tenantId: payload.tenantId,
|
|
100
|
+
clientId: payload.clientId,
|
|
101
|
+
subjectType: checked.error.rejection.subjectType,
|
|
102
|
+
reason: checked.error.rejection.reason,
|
|
103
|
+
detail: checked.error.rejection.detail,
|
|
104
|
+
timestamp: deps.clock()
|
|
105
|
+
});
|
|
106
|
+
return err(checked.error);
|
|
107
|
+
}
|
|
108
|
+
claim = checked.value;
|
|
94
109
|
if (deps.persistUpstreamTokens) {
|
|
95
110
|
try {
|
|
96
111
|
await deps.persistUpstreamTokens({
|
|
@@ -124,6 +139,19 @@ async function mintTokens(args) {
|
|
|
124
139
|
const now = deps.clock();
|
|
125
140
|
const receivingClient = tenant.config.clients.find((c) => c.id === payload.clientId);
|
|
126
141
|
const subjectId = await deriveSubjectId(claim, receivingClient?.sectorIdentifier);
|
|
142
|
+
if (deps.onTokenIssued) {
|
|
143
|
+
try {
|
|
144
|
+
await deps.onTokenIssued({
|
|
145
|
+
tenant,
|
|
146
|
+
clientId: payload.clientId,
|
|
147
|
+
subjectId,
|
|
148
|
+
claim,
|
|
149
|
+
...skipRefresh ? {} : { family }
|
|
150
|
+
});
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return err(authError.serverError("onTokenIssued hook threw", e));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
127
155
|
const keyRes = await deps.keyStore.currentSigningKey();
|
|
128
156
|
if (isErr(keyRes))
|
|
129
157
|
return err(keyRes.error);
|
|
@@ -67,9 +67,11 @@ function makeTokenHandler(deps) {
|
|
|
67
67
|
keyStore: deps.keyStore,
|
|
68
68
|
...deps.auditLog ? { auditLog: deps.auditLog } : {},
|
|
69
69
|
success: deps.success,
|
|
70
|
+
subjects: deps.subjects,
|
|
70
71
|
...deps.persistUpstreamTokens ? { persistUpstreamTokens: deps.persistUpstreamTokens } : {},
|
|
71
72
|
issuerUrl: c.get("issuerUrl"),
|
|
72
73
|
clock: deps.clock,
|
|
74
|
+
...deps.onTokenIssued ? { onTokenIssued: deps.onTokenIssued } : {},
|
|
73
75
|
...deps.customScopeClaims !== undefined ? { customScopeClaims: deps.customScopeClaims } : {}
|
|
74
76
|
});
|
|
75
77
|
if (isErr(result))
|
|
@@ -99,9 +101,11 @@ function makeTokenHandler(deps) {
|
|
|
99
101
|
...deps.auditLog ? { auditLog: deps.auditLog } : {},
|
|
100
102
|
methodCache: deps.methodCache,
|
|
101
103
|
success: deps.success,
|
|
104
|
+
subjects: deps.subjects,
|
|
102
105
|
...deps.persistUpstreamTokens ? { persistUpstreamTokens: deps.persistUpstreamTokens } : {},
|
|
103
106
|
issuerUrl: c.get("issuerUrl"),
|
|
104
107
|
clock: deps.clock,
|
|
108
|
+
...deps.onTokenIssued ? { onTokenIssued: deps.onTokenIssued } : {},
|
|
105
109
|
...deps.customScopeClaims !== undefined ? { customScopeClaims: deps.customScopeClaims } : {}
|
|
106
110
|
}, tenantRes.value);
|
|
107
111
|
if (isErr(result))
|
|
@@ -123,6 +127,7 @@ function makeTokenHandler(deps) {
|
|
|
123
127
|
...deps.auditLog ? { auditLog: deps.auditLog } : {},
|
|
124
128
|
issuerUrl: c.get("issuerUrl"),
|
|
125
129
|
clock: deps.clock,
|
|
130
|
+
...deps.onTokenIssued ? { onTokenIssued: deps.onTokenIssued } : {},
|
|
126
131
|
...deps.customScopeClaims !== undefined ? { customScopeClaims: deps.customScopeClaims } : {}
|
|
127
132
|
});
|
|
128
133
|
if (isErr(refreshResult))
|
|
@@ -147,6 +152,7 @@ function makeTokenHandler(deps) {
|
|
|
147
152
|
...deps.exchangeAudience ? { exchangeAudience: deps.exchangeAudience } : {},
|
|
148
153
|
issuerUrl: c.get("issuerUrl"),
|
|
149
154
|
clock: deps.clock,
|
|
155
|
+
...deps.onTokenIssued ? { onTokenIssued: deps.onTokenIssued } : {},
|
|
150
156
|
...deps.customScopeClaims !== undefined ? { customScopeClaims: deps.customScopeClaims } : {}
|
|
151
157
|
});
|
|
152
158
|
if (isErr(exchangeResult)) {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// src/http/middleware/tenant.ts
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
extractCallbackState,
|
|
4
|
+
verifyStateEnvelope
|
|
5
|
+
} from "../../domain/state-envelope";
|
|
3
6
|
import { isErr } from "../../types/result";
|
|
4
7
|
import { parseCookieHeader } from "../cookies";
|
|
5
8
|
import {
|
|
@@ -90,7 +93,7 @@ function buildTenantContext(req, id, config, custom) {
|
|
|
90
93
|
};
|
|
91
94
|
}
|
|
92
95
|
async function runCallbackRecovery(req, deps) {
|
|
93
|
-
const state = await
|
|
96
|
+
const state = await extractCallbackState(req);
|
|
94
97
|
if (state) {
|
|
95
98
|
const env = await verifyStateEnvelope(state, deps.stateKeys);
|
|
96
99
|
if (env.ok) {
|
|
@@ -103,24 +106,6 @@ async function runCallbackRecovery(req, deps) {
|
|
|
103
106
|
}
|
|
104
107
|
return { kind: "fresh-request" };
|
|
105
108
|
}
|
|
106
|
-
async function extractStateParam(req) {
|
|
107
|
-
const url = new URL(req.url);
|
|
108
|
-
const fromQuery = url.searchParams.get("state");
|
|
109
|
-
if (fromQuery)
|
|
110
|
-
return fromQuery;
|
|
111
|
-
if (req.method !== "POST")
|
|
112
|
-
return null;
|
|
113
|
-
const ct = req.headers.get("content-type") ?? "";
|
|
114
|
-
if (!ct.toLowerCase().startsWith("application/x-www-form-urlencoded")) {
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
try {
|
|
118
|
-
const text = await req.clone().text();
|
|
119
|
-
return new URLSearchParams(text).get("state");
|
|
120
|
-
} catch {
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
109
|
export {
|
|
125
110
|
bootstrapMiddleware,
|
|
126
111
|
tenantMiddleware
|
package/dist/esm/index.js
CHANGED
|
@@ -67,6 +67,8 @@ function createIdP(opts) {
|
|
|
67
67
|
...opts.callbackHostFor ? { callbackHostFor: opts.callbackHostFor } : {},
|
|
68
68
|
resolveTenant: opts.resolveTenant,
|
|
69
69
|
success: opts.success,
|
|
70
|
+
subjects: opts.subjects,
|
|
71
|
+
...opts.onTokenIssued ? { onTokenIssued: opts.onTokenIssued } : {},
|
|
70
72
|
...opts.onLogout ? { onLogout: opts.onLogout } : {},
|
|
71
73
|
...opts.persistUpstreamTokens ? { persistUpstreamTokens: opts.persistUpstreamTokens } : {},
|
|
72
74
|
...opts.exchangeAudience ? { exchangeAudience: opts.exchangeAudience } : {},
|