@_mustachio/openauth 0.13.3 → 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 +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 +15 -0
- package/dist/esm/http/handlers/token.js +2 -0
- package/dist/esm/http/middleware/tenant.js +5 -20
- package/dist/esm/index.js +1 -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 +3 -1
- 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.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/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 +33 -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 +20 -1
- package/src/domain/method-route.ts +10 -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/token.ts +2 -0
- package/src/http/middleware/tenant.ts +5 -24
- package/src/index.ts +1 -2
- package/src/ports/audit-log.ts +26 -1
- package/src/types/idp.ts +33 -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({
|
|
@@ -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
|
package/dist/esm/index.js
CHANGED
|
@@ -67,6 +67,7 @@ function createIdP(opts) {
|
|
|
67
67
|
...opts.callbackHostFor ? { callbackHostFor: opts.callbackHostFor } : {},
|
|
68
68
|
resolveTenant: opts.resolveTenant,
|
|
69
69
|
success: opts.success,
|
|
70
|
+
subjects: opts.subjects,
|
|
70
71
|
...opts.onLogout ? { onLogout: opts.onLogout } : {},
|
|
71
72
|
...opts.persistUpstreamTokens ? { persistUpstreamTokens: opts.persistUpstreamTokens } : {},
|
|
72
73
|
...opts.exchangeAudience ? { exchangeAudience: opts.exchangeAudience } : {},
|
package/dist/types/client.d.ts
CHANGED
|
@@ -90,26 +90,45 @@ export interface ClientInput {
|
|
|
90
90
|
*/
|
|
91
91
|
issuer?: string;
|
|
92
92
|
/**
|
|
93
|
-
*
|
|
93
|
+
* The client secret, for **confidential** clients only.
|
|
94
94
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
* Enable the PKCE flow. This is for SPA apps.
|
|
95
|
+
* Supply this for a server-side app registered as a confidential client;
|
|
96
|
+
* `exchange()` and `refresh()` then authenticate at `/token`. Omit it for
|
|
97
|
+
* public clients (SPA, mobile, CLI) — a secret cannot be kept in code
|
|
98
|
+
* that ships to users, and the IdP requires PKCE there instead.
|
|
99
|
+
*
|
|
100
|
+
* Without this, a confidential client's `exchange()` is rejected with
|
|
101
|
+
* `invalid_client`: the token endpoint has no way to authenticate it.
|
|
103
102
|
*
|
|
103
|
+
* @example
|
|
104
104
|
* ```ts
|
|
105
105
|
* {
|
|
106
|
-
*
|
|
106
|
+
* clientID: "my-server-app",
|
|
107
|
+
* clientSecret: process.env.CLIENT_SECRET
|
|
107
108
|
* }
|
|
108
109
|
* ```
|
|
110
|
+
*/
|
|
111
|
+
clientSecret?: string;
|
|
112
|
+
/**
|
|
113
|
+
* How to present `clientSecret` at the token endpoint.
|
|
109
114
|
*
|
|
110
|
-
*
|
|
115
|
+
* `client_secret_basic` (the default) sends HTTP Basic credentials, which
|
|
116
|
+
* is what RFC 6749 §2.3.1 prefers and what the IdP parses first.
|
|
117
|
+
* `client_secret_post` puts them in the form body. Both are advertised in
|
|
118
|
+
* discovery as `token_endpoint_auth_methods_supported`.
|
|
119
|
+
*
|
|
120
|
+
* @default "client_secret_basic"
|
|
121
|
+
*/
|
|
122
|
+
tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post";
|
|
123
|
+
/**
|
|
124
|
+
* Optionally, override the internally used fetch function.
|
|
125
|
+
*
|
|
126
|
+
* This is useful if you are using a polyfilled fetch function in your application and you
|
|
127
|
+
* want the client to use it too.
|
|
111
128
|
*/
|
|
112
|
-
|
|
129
|
+
fetch?: FetchLike;
|
|
130
|
+
}
|
|
131
|
+
export interface AuthorizeOptions {
|
|
113
132
|
/**
|
|
114
133
|
* The provider you want to use for the OAuth flow.
|
|
115
134
|
*
|
|
@@ -238,7 +257,13 @@ export interface VerifyOptions {
|
|
|
238
257
|
*/
|
|
239
258
|
issuer?: string;
|
|
240
259
|
/**
|
|
241
|
-
*
|
|
260
|
+
* The audience to require on the token.
|
|
261
|
+
*
|
|
262
|
+
* Defaults to this client's `clientID`, which is what the IdP puts in
|
|
263
|
+
* `aud` for an ordinary login. Set this when verifying a token minted
|
|
264
|
+
* for a **resource** — an `/authorize` call that passed `audience` puts
|
|
265
|
+
* that value in `aud` instead, so a resource server verifying it must
|
|
266
|
+
* name itself here.
|
|
242
267
|
*/
|
|
243
268
|
audience?: string;
|
|
244
269
|
/**
|
|
@@ -251,9 +276,14 @@ export interface VerifyOptions {
|
|
|
251
276
|
}
|
|
252
277
|
export interface VerifyResult<T extends SubjectSchema> {
|
|
253
278
|
/**
|
|
254
|
-
* This is always `
|
|
279
|
+
* This is always `false` when the verify is successful.
|
|
280
|
+
*
|
|
281
|
+
* A literal, not an optional — `err?: undefined` would leave the
|
|
282
|
+
* property present on this arm, so `"err" in result` narrowed nothing
|
|
283
|
+
* and callers had to test truthiness instead. Matches `ExchangeSuccess`
|
|
284
|
+
* and `RefreshSuccess`.
|
|
255
285
|
*/
|
|
256
|
-
err
|
|
286
|
+
err: false;
|
|
257
287
|
/**
|
|
258
288
|
* Returns the refreshed tokens only if they’ve been refreshed.
|
|
259
289
|
*
|
|
@@ -297,38 +327,29 @@ export interface VerifyError {
|
|
|
297
327
|
*/
|
|
298
328
|
export interface Client {
|
|
299
329
|
/**
|
|
300
|
-
* Start the
|
|
330
|
+
* Start the authorization code flow.
|
|
301
331
|
*
|
|
302
332
|
* ```ts
|
|
303
|
-
* const { url } = await client.authorize(<redirect_uri
|
|
333
|
+
* const { challenge, url } = await client.authorize(<redirect_uri>)
|
|
334
|
+
* // store `challenge`, then redirect the user to `url`
|
|
304
335
|
* ```
|
|
305
336
|
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* secure.
|
|
337
|
+
* Returns the URL to send the user to, and a `challenge` carrying the
|
|
338
|
+
* CSRF `state` and the PKCE `verifier`. Persist the challenge (a cookie
|
|
339
|
+
* server-side, `sessionStorage` in a SPA) and hand the verifier back to
|
|
340
|
+
* {@link Client.exchange} when the user returns.
|
|
311
341
|
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* OAuth flow.
|
|
318
|
-
*
|
|
319
|
-
* For SPA apps, we recommend using the PKCE flow.
|
|
320
|
-
*
|
|
321
|
-
* ```ts {4}
|
|
322
|
-
* const { challenge, url } = await client.authorize(
|
|
323
|
-
* <redirect_uri>,
|
|
324
|
-
* "code",
|
|
325
|
-
* { pkce: true }
|
|
326
|
-
* )
|
|
327
|
-
* ```
|
|
342
|
+
* **PKCE is always used.** The IdP requires it for public clients, and
|
|
343
|
+
* OAuth 2.1 §7.5.1 recommends it for confidential ones too, so there is
|
|
344
|
+
* no reason to offer it as a toggle. Before 0.14.0 it was opt-in and
|
|
345
|
+
* off by default, which meant the documented server-side flow could not
|
|
346
|
+
* complete against a public client at all.
|
|
328
347
|
*
|
|
329
|
-
*
|
|
348
|
+
* Only the authorization code flow is supported. The implicit flow
|
|
349
|
+
* (`response_type=token`) is removed in OAuth 2.1 and the IdP rejects
|
|
350
|
+
* it with `unsupported_response_type`.
|
|
330
351
|
*/
|
|
331
|
-
authorize(redirectURI: string,
|
|
352
|
+
authorize(redirectURI: string, opts?: AuthorizeOptions): Promise<AuthorizeResult>;
|
|
332
353
|
/**
|
|
333
354
|
* Exchange the code for access and refresh tokens.
|
|
334
355
|
*
|