@_mustachio/openauth 0.13.2 → 0.14.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 +21 -3
- package/dist/esm/domain/callback.js +30 -21
- package/dist/esm/domain/client-credentials.js +16 -0
- package/dist/esm/domain/method-dispatch.js +1 -0
- package/dist/esm/domain/method-route.js +12 -0
- package/dist/esm/domain/mount.js +29 -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 +15 -0
- package/dist/esm/http/handlers/method-route.js +8 -3
- package/dist/esm/http/handlers/token.js +2 -0
- package/dist/esm/http/middleware/tenant.js +5 -20
- package/dist/esm/index.js +4 -0
- package/dist/esm/methods/code.js +9 -8
- package/dist/esm/methods/oauth2-generic.js +2 -1
- package/dist/esm/methods/passkey.js +3 -2
- package/dist/esm/methods/password.js +9 -8
- package/dist/esm/methods/saml-sp/metadata.js +2 -1
- 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 +6 -5
- package/dist/types/domain/callback.d.ts.map +1 -1
- package/dist/types/domain/client-credentials.d.ts +3 -1
- package/dist/types/domain/client-credentials.d.ts.map +1 -1
- package/dist/types/domain/method-dispatch.d.ts +5 -0
- package/dist/types/domain/method-dispatch.d.ts.map +1 -1
- package/dist/types/domain/method-route.d.ts +6 -0
- package/dist/types/domain/method-route.d.ts.map +1 -1
- package/dist/types/domain/mount.d.ts +90 -0
- package/dist/types/domain/mount.d.ts.map +1 -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.d.ts +7 -1
- package/dist/types/domain/token.d.ts.map +1 -1
- package/dist/types/http/context.d.ts +3 -0
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/handlers/method-route.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 +17 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/methods/code.d.ts.map +1 -1
- package/dist/types/methods/oauth2-generic.d.ts.map +1 -1
- package/dist/types/methods/passkey.d.ts.map +1 -1
- package/dist/types/methods/password.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/metadata.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 +49 -39
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/method.d.ts +11 -0
- package/dist/types/types/method.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/client.ts +145 -129
- package/src/domain/authorize.ts +25 -4
- package/src/domain/callback.ts +40 -36
- package/src/domain/client-credentials.ts +20 -1
- package/src/domain/method-dispatch.ts +6 -0
- package/src/domain/method-route.ts +18 -0
- package/src/domain/mount.ts +113 -0
- package/src/domain/refresh.ts +39 -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.ts +25 -1
- package/src/http/context.ts +3 -0
- package/src/http/handlers/method-route.ts +9 -5
- package/src/http/handlers/token.ts +2 -0
- package/src/http/middleware/tenant.ts +5 -24
- package/src/index.ts +18 -2
- package/src/methods/code.ts +15 -6
- package/src/methods/oauth2-generic.ts +6 -1
- package/src/methods/passkey.ts +6 -2
- package/src/methods/password.ts +19 -4
- package/src/methods/saml-sp/metadata.ts +5 -1
- package/src/ports/audit-log.ts +26 -1
- package/src/types/idp.ts +49 -42
- package/src/types/method.ts +11 -0
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,
|
|
@@ -4,6 +4,7 @@ import { err, isErr, ok } from "../types/result";
|
|
|
4
4
|
import { safeAudit } from "./audit";
|
|
5
5
|
import { randomId, randomToken } from "./crypto";
|
|
6
6
|
import { dispatchMethod } from "./method-dispatch";
|
|
7
|
+
import { callbackTarget } from "./mount";
|
|
7
8
|
import { mintStateEnvelope } from "./state-envelope";
|
|
8
9
|
import { saveEncryptedCode } from "./token";
|
|
9
10
|
var DEFAULT_FLOW_TTL_MS = 10 * 60 * 1000;
|
|
@@ -54,9 +55,15 @@ async function startAuthorize(input, deps) {
|
|
|
54
55
|
const nonce = (deps.newNonce ?? randomToken)();
|
|
55
56
|
const flowTtl = deps.flowTtlMs ?? DEFAULT_FLOW_TTL_MS;
|
|
56
57
|
const now = deps.clock();
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
const {
|
|
59
|
+
host: callbackHost,
|
|
60
|
+
path: callbackPath,
|
|
61
|
+
url: callbackUrl
|
|
62
|
+
} = callbackTarget({
|
|
63
|
+
issuerUrl: deps.issuerUrl,
|
|
64
|
+
methodId,
|
|
65
|
+
callbackHost: deps.callbackHostFor?.(tenant.id)
|
|
66
|
+
});
|
|
60
67
|
const record = {
|
|
61
68
|
flowId,
|
|
62
69
|
tenantId: tenant.id,
|
|
@@ -103,6 +110,7 @@ async function startAuthorize(input, deps) {
|
|
|
103
110
|
flow: record,
|
|
104
111
|
cookies: input.cookies,
|
|
105
112
|
sessionStore: deps.sessionStore,
|
|
113
|
+
issuerUrl: deps.issuerUrl,
|
|
106
114
|
dispatch: {
|
|
107
115
|
state: stateEnvelope,
|
|
108
116
|
callbackUrl,
|
|
@@ -203,6 +211,16 @@ async function issueCodeFromInlineSuccess(result, record, deps) {
|
|
|
203
211
|
}, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
|
|
204
212
|
if (isErr(saved))
|
|
205
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
|
+
});
|
|
206
224
|
return ok({
|
|
207
225
|
kind: "issue-code",
|
|
208
226
|
code,
|
|
@@ -4,27 +4,10 @@ import { err, isErr, ok } from "../types/result";
|
|
|
4
4
|
import { safeAudit } from "./audit";
|
|
5
5
|
import { randomToken } from "./crypto";
|
|
6
6
|
import { dispatchMethod } from "./method-dispatch";
|
|
7
|
+
import { callbackTarget } from "./mount";
|
|
7
8
|
import { saveEncryptedCode } from "./token";
|
|
8
|
-
import { verifyStateEnvelope } from "./state-envelope";
|
|
9
|
+
import { extractCallbackState, verifyStateEnvelope } from "./state-envelope";
|
|
9
10
|
import { AUTH_CODE_TTL_MS } from "./authorize";
|
|
10
|
-
async function extractCallbackState(req) {
|
|
11
|
-
const fromQuery = new URL(req.url).searchParams.get("state");
|
|
12
|
-
if (fromQuery)
|
|
13
|
-
return fromQuery;
|
|
14
|
-
if (req.method !== "POST")
|
|
15
|
-
return null;
|
|
16
|
-
const ct = req.headers.get("content-type") ?? "";
|
|
17
|
-
if (!ct.includes("application/x-www-form-urlencoded"))
|
|
18
|
-
return null;
|
|
19
|
-
try {
|
|
20
|
-
const body = await req.clone().text();
|
|
21
|
-
const form = new URLSearchParams(body);
|
|
22
|
-
const v = form.get("state") ?? form.get("RelayState");
|
|
23
|
-
return v && v.length > 0 ? v : null;
|
|
24
|
-
} catch {
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
11
|
async function handleCallback(input, deps) {
|
|
29
12
|
const url = new URL(input.rawRequest.url);
|
|
30
13
|
const state = await extractCallbackState(input.rawRequest);
|
|
@@ -114,6 +97,7 @@ async function handleCallback(input, deps) {
|
|
|
114
97
|
flow,
|
|
115
98
|
cookies: input.cookies,
|
|
116
99
|
sessionStore: deps.sessionStore,
|
|
100
|
+
issuerUrl: input.issuerUrl,
|
|
117
101
|
dispatch: null
|
|
118
102
|
});
|
|
119
103
|
if (isErr(dispatched))
|
|
@@ -145,6 +129,16 @@ async function translate(result, flow, deps) {
|
|
|
145
129
|
}, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
|
|
146
130
|
if (isErr(saved))
|
|
147
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
|
+
});
|
|
148
142
|
return ok({
|
|
149
143
|
kind: "issue-code",
|
|
150
144
|
code,
|
|
@@ -192,7 +186,7 @@ async function translate(result, flow, deps) {
|
|
|
192
186
|
async function tryIdpInitiated(input, deps, url) {
|
|
193
187
|
if (input.rawRequest.method !== "POST")
|
|
194
188
|
return null;
|
|
195
|
-
if (!input.tenant
|
|
189
|
+
if (!input.tenant)
|
|
196
190
|
return null;
|
|
197
191
|
const segments = url.pathname.split("/").filter(Boolean);
|
|
198
192
|
if (segments.length < 2 || segments[0] !== "cb")
|
|
@@ -204,7 +198,11 @@ async function tryIdpInitiated(input, deps, url) {
|
|
|
204
198
|
const method = methodRes.value;
|
|
205
199
|
if (method.unsolicitedCallback !== true)
|
|
206
200
|
return null;
|
|
207
|
-
const callbackUrl =
|
|
201
|
+
const { url: callbackUrl } = callbackTarget({
|
|
202
|
+
issuerUrl: input.issuerUrl,
|
|
203
|
+
methodId,
|
|
204
|
+
callbackHost: url.host
|
|
205
|
+
});
|
|
208
206
|
const dispatched = await dispatchMethod({
|
|
209
207
|
method,
|
|
210
208
|
route: "GET /callback",
|
|
@@ -214,6 +212,7 @@ async function tryIdpInitiated(input, deps, url) {
|
|
|
214
212
|
flow: null,
|
|
215
213
|
cookies: input.cookies,
|
|
216
214
|
sessionStore: deps.sessionStore,
|
|
215
|
+
issuerUrl: input.issuerUrl,
|
|
217
216
|
dispatch: { state: "", callbackUrl, issuerUrl: input.issuerUrl }
|
|
218
217
|
});
|
|
219
218
|
if (isErr(dispatched))
|
|
@@ -275,6 +274,16 @@ async function tryIdpInitiated(input, deps, url) {
|
|
|
275
274
|
}, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
|
|
276
275
|
if (isErr(saved))
|
|
277
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
|
+
});
|
|
278
287
|
return ok({
|
|
279
288
|
kind: "issue-code",
|
|
280
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,
|
|
@@ -38,6 +38,7 @@ async function handleMethodRoute(input, deps) {
|
|
|
38
38
|
flow,
|
|
39
39
|
cookies: input.cookies,
|
|
40
40
|
sessionStore: deps.sessionStore,
|
|
41
|
+
issuerUrl: input.issuerUrl,
|
|
41
42
|
dispatch: null
|
|
42
43
|
});
|
|
43
44
|
if (isErr(dispatched))
|
|
@@ -73,6 +74,16 @@ async function translate(result, flow, deps) {
|
|
|
73
74
|
}, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
|
|
74
75
|
if (isErr(saved))
|
|
75
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
|
+
});
|
|
76
87
|
return ok({
|
|
77
88
|
kind: "issue-code",
|
|
78
89
|
code,
|
|
@@ -120,6 +131,7 @@ async function handlePublicMethodRoute(input, deps) {
|
|
|
120
131
|
flow: null,
|
|
121
132
|
cookies: new Map,
|
|
122
133
|
sessionStore: deps.sessionStore,
|
|
134
|
+
issuerUrl: input.dispatch.issuerUrl,
|
|
123
135
|
dispatch: input.dispatch
|
|
124
136
|
});
|
|
125
137
|
if (isErr(dispatched))
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/domain/mount.ts
|
|
2
|
+
function mountPath(issuerUrl) {
|
|
3
|
+
let pathname;
|
|
4
|
+
try {
|
|
5
|
+
pathname = new URL(issuerUrl).pathname;
|
|
6
|
+
} catch {
|
|
7
|
+
return "";
|
|
8
|
+
}
|
|
9
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
10
|
+
return segments.length === 0 ? "" : `/${segments.join("/")}`;
|
|
11
|
+
}
|
|
12
|
+
function mountedPath(issuerUrl, path) {
|
|
13
|
+
return `${mountPath(issuerUrl)}${path}`;
|
|
14
|
+
}
|
|
15
|
+
function callbackTarget(input) {
|
|
16
|
+
const issuer = new URL(input.issuerUrl);
|
|
17
|
+
const host = input.callbackHost ?? issuer.host;
|
|
18
|
+
const path = `/cb/${input.methodId}`;
|
|
19
|
+
return {
|
|
20
|
+
host,
|
|
21
|
+
path,
|
|
22
|
+
url: `${issuer.protocol}//${host}${mountPath(input.issuerUrl)}${path}`
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export {
|
|
26
|
+
callbackTarget,
|
|
27
|
+
mountPath,
|
|
28
|
+
mountedPath
|
|
29
|
+
};
|
|
@@ -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({
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
handleMethodRoute,
|
|
4
4
|
handlePublicMethodRoute
|
|
5
5
|
} from "../../domain/method-route";
|
|
6
|
+
import { callbackTarget } from "../../domain/mount";
|
|
6
7
|
import { authError } from "../../types/error";
|
|
7
8
|
import { isErr } from "../../types/result";
|
|
8
9
|
import { applyResponsePolicy, cacheControlHeader } from "../cookies";
|
|
@@ -24,8 +25,11 @@ function makeMethodRouteHandler(deps) {
|
|
|
24
25
|
const resolved = await deps.methodCache.resolve(tenant.config, methodId);
|
|
25
26
|
if (!isErr(resolved) && resolved.value.publicRoutes?.includes(routeKey)) {
|
|
26
27
|
const issuerUrl = c.get("issuerUrl");
|
|
27
|
-
const
|
|
28
|
-
|
|
28
|
+
const { url: callbackUrl } = callbackTarget({
|
|
29
|
+
issuerUrl,
|
|
30
|
+
methodId,
|
|
31
|
+
callbackHost: deps.callbackHostFor?.(tenant.id)
|
|
32
|
+
});
|
|
29
33
|
const pub = await handlePublicMethodRoute({
|
|
30
34
|
rawRequest: c.req.raw,
|
|
31
35
|
tenant,
|
|
@@ -75,7 +79,8 @@ function makeMethodRouteHandler(deps) {
|
|
|
75
79
|
subPath,
|
|
76
80
|
httpMethod,
|
|
77
81
|
flowId,
|
|
78
|
-
cookies
|
|
82
|
+
cookies,
|
|
83
|
+
issuerUrl: c.get("issuerUrl")
|
|
79
84
|
}, {
|
|
80
85
|
sessionStore: deps.sessionStore,
|
|
81
86
|
tokenStore: deps.tokenStore,
|
|
@@ -67,6 +67,7 @@ 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,
|
|
@@ -99,6 +100,7 @@ function makeTokenHandler(deps) {
|
|
|
99
100
|
...deps.auditLog ? { auditLog: deps.auditLog } : {},
|
|
100
101
|
methodCache: deps.methodCache,
|
|
101
102
|
success: deps.success,
|
|
103
|
+
subjects: deps.subjects,
|
|
102
104
|
...deps.persistUpstreamTokens ? { persistUpstreamTokens: deps.persistUpstreamTokens } : {},
|
|
103
105
|
issuerUrl: c.get("issuerUrl"),
|
|
104
106
|
clock: deps.clock,
|
|
@@ -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
|