@_mustachio/openauth 0.10.0 → 0.11.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/adapters/memory/session-store.js +22 -0
- package/dist/esm/adapters/memory/token-store.js +13 -0
- package/dist/esm/client.js +9 -2
- package/dist/esm/domain/authorize.js +5 -0
- package/dist/esm/domain/callback.js +3 -0
- package/dist/esm/domain/discovery.js +44 -2
- package/dist/esm/domain/dpop.js +122 -0
- package/dist/esm/domain/id-token.js +94 -0
- package/dist/esm/domain/introspect.js +5 -1
- package/dist/esm/domain/jwt.js +28 -0
- package/dist/esm/domain/logout.js +75 -0
- package/dist/esm/domain/method-route.js +3 -0
- package/dist/esm/domain/par.js +51 -0
- package/dist/esm/domain/refresh.js +12 -1
- package/dist/esm/domain/register.js +74 -0
- package/dist/esm/domain/token-exchange.js +1 -0
- package/dist/esm/domain/token.js +48 -7
- package/dist/esm/domain/userinfo.js +38 -3
- package/dist/esm/http/handlers/authorize.js +90 -3
- package/dist/esm/http/handlers/end-session.js +64 -0
- package/dist/esm/http/handlers/metadata.js +5 -2
- package/dist/esm/http/handlers/par.js +53 -0
- package/dist/esm/http/handlers/register.js +46 -0
- package/dist/esm/http/handlers/token.js +36 -6
- package/dist/esm/http/handlers/userinfo.js +35 -29
- package/dist/esm/http/router.js +10 -0
- package/dist/esm/http/schemas/authorize.js +7 -1
- package/dist/esm/http/schemas/end-session.js +13 -0
- package/dist/esm/http/schemas/par.js +9 -0
- package/dist/esm/http/schemas/register.js +16 -0
- package/dist/esm/index.js +7 -2
- package/dist/esm/types/error.js +5 -0
- package/dist/types/adapters/memory/session-store.d.ts +3 -1
- package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
- package/dist/types/adapters/memory/token-store.d.ts +1 -0
- package/dist/types/adapters/memory/token-store.d.ts.map +1 -1
- package/dist/types/client.d.ts +19 -0
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/domain/authorize.d.ts.map +1 -1
- package/dist/types/domain/client-credentials.d.ts +2 -0
- package/dist/types/domain/client-credentials.d.ts.map +1 -1
- package/dist/types/domain/discovery.d.ts +37 -0
- package/dist/types/domain/discovery.d.ts.map +1 -1
- package/dist/types/domain/dpop.d.ts +61 -0
- package/dist/types/domain/dpop.d.ts.map +1 -0
- package/dist/types/domain/id-token.d.ts +83 -0
- package/dist/types/domain/id-token.d.ts.map +1 -0
- package/dist/types/domain/introspect.d.ts +8 -0
- package/dist/types/domain/introspect.d.ts.map +1 -1
- package/dist/types/domain/jwt.d.ts +27 -1
- package/dist/types/domain/jwt.d.ts.map +1 -1
- package/dist/types/domain/logout.d.ts +69 -0
- package/dist/types/domain/logout.d.ts.map +1 -0
- package/dist/types/domain/par.d.ts +62 -0
- package/dist/types/domain/par.d.ts.map +1 -0
- package/dist/types/domain/refresh.d.ts +9 -0
- package/dist/types/domain/refresh.d.ts.map +1 -1
- package/dist/types/domain/register.d.ts +33 -0
- package/dist/types/domain/register.d.ts.map +1 -0
- package/dist/types/domain/token-exchange.d.ts +2 -0
- package/dist/types/domain/token-exchange.d.ts.map +1 -1
- package/dist/types/domain/token.d.ts +41 -1
- package/dist/types/domain/token.d.ts.map +1 -1
- package/dist/types/domain/userinfo.d.ts +44 -5
- package/dist/types/domain/userinfo.d.ts.map +1 -1
- package/dist/types/http/context.d.ts +4 -1
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/handlers/authorize.d.ts.map +1 -1
- package/dist/types/http/handlers/end-session.d.ts +3 -0
- package/dist/types/http/handlers/end-session.d.ts.map +1 -0
- package/dist/types/http/handlers/metadata.d.ts +1 -1
- package/dist/types/http/handlers/metadata.d.ts.map +1 -1
- package/dist/types/http/handlers/par.d.ts +3 -0
- package/dist/types/http/handlers/par.d.ts.map +1 -0
- package/dist/types/http/handlers/register.d.ts +3 -0
- package/dist/types/http/handlers/register.d.ts.map +1 -0
- package/dist/types/http/handlers/token.d.ts.map +1 -1
- package/dist/types/http/handlers/userinfo.d.ts.map +1 -1
- package/dist/types/http/router.d.ts.map +1 -1
- package/dist/types/http/schemas/authorize.d.ts +40 -0
- package/dist/types/http/schemas/authorize.d.ts.map +1 -1
- package/dist/types/http/schemas/end-session.d.ts +31 -0
- package/dist/types/http/schemas/end-session.d.ts.map +1 -0
- package/dist/types/http/schemas/par.d.ts +20 -0
- package/dist/types/http/schemas/par.d.ts.map +1 -0
- package/dist/types/http/schemas/register.d.ts +42 -0
- package/dist/types/http/schemas/register.d.ts.map +1 -0
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/ports/audit-log.d.ts +27 -0
- package/dist/types/ports/audit-log.d.ts.map +1 -1
- package/dist/types/ports/session-store.d.ts +27 -0
- package/dist/types/ports/session-store.d.ts.map +1 -1
- package/dist/types/ports/token-store.d.ts +12 -0
- package/dist/types/ports/token-store.d.ts.map +1 -1
- package/dist/types/types/authorization.d.ts +23 -0
- package/dist/types/types/authorization.d.ts.map +1 -1
- package/dist/types/types/error.d.ts +9 -0
- package/dist/types/types/error.d.ts.map +1 -1
- package/dist/types/types/flow.d.ts +10 -0
- package/dist/types/types/flow.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +91 -1
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/tenant.d.ts +37 -0
- package/dist/types/types/tenant.d.ts.map +1 -1
- package/dist/types/types/token.d.ts +128 -0
- package/dist/types/types/token.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/memory/session-store.ts +44 -1
- package/src/adapters/memory/token-store.ts +16 -0
- package/src/client.ts +32 -0
- package/src/domain/authorize.ts +9 -0
- package/src/domain/callback.ts +5 -0
- package/src/domain/client-credentials.ts +2 -0
- package/src/domain/discovery.ts +80 -2
- package/src/domain/dpop.ts +293 -0
- package/src/domain/id-token.ts +213 -0
- package/src/domain/introspect.ts +10 -0
- package/src/domain/jwt.ts +80 -1
- package/src/domain/logout.ts +180 -0
- package/src/domain/method-route.ts +5 -0
- package/src/domain/par.ts +141 -0
- package/src/domain/refresh.ts +41 -0
- package/src/domain/register.ts +168 -0
- package/src/domain/token-exchange.ts +11 -0
- package/src/domain/token.ts +134 -8
- package/src/domain/userinfo.ts +121 -6
- package/src/http/context.ts +4 -0
- package/src/http/handlers/authorize.ts +144 -3
- package/src/http/handlers/end-session.ts +106 -0
- package/src/http/handlers/metadata.ts +7 -2
- package/src/http/handlers/par.ts +84 -0
- package/src/http/handlers/register.ts +70 -0
- package/src/http/handlers/token.ts +56 -0
- package/src/http/handlers/userinfo.ts +62 -38
- package/src/http/router.ts +19 -0
- package/src/http/schemas/authorize.ts +27 -0
- package/src/http/schemas/end-session.ts +23 -0
- package/src/http/schemas/par.ts +23 -0
- package/src/http/schemas/register.ts +29 -0
- package/src/index.ts +18 -1
- package/src/ports/CONSISTENCY.md +52 -21
- package/src/ports/audit-log.ts +29 -0
- package/src/ports/session-store.ts +33 -0
- package/src/ports/token-store.ts +13 -0
- package/src/types/authorization.ts +25 -0
- package/src/types/error.ts +23 -0
- package/src/types/flow.ts +10 -0
- package/src/types/idp.ts +96 -0
- package/src/types/tenant.ts +37 -0
- package/src/types/token.ts +135 -0
|
@@ -7,6 +7,7 @@ class MemorySessionStore {
|
|
|
7
7
|
#clock;
|
|
8
8
|
#flows = new Map;
|
|
9
9
|
#sessions = new Map;
|
|
10
|
+
#par = new Map;
|
|
10
11
|
constructor(opts = {}) {
|
|
11
12
|
this.#clock = opts.clock ?? realClock;
|
|
12
13
|
}
|
|
@@ -76,6 +77,27 @@ class MemorySessionStore {
|
|
|
76
77
|
this.#sessions.delete(sessionId);
|
|
77
78
|
return ok(undefined);
|
|
78
79
|
}
|
|
80
|
+
async savePar(requestUri, payload, ttl) {
|
|
81
|
+
if (ttl <= 0) {
|
|
82
|
+
return err(authError.internalError(`savePar: ttl must be positive, got ${ttl}`));
|
|
83
|
+
}
|
|
84
|
+
this.#par.set(requestUri, {
|
|
85
|
+
record: payload,
|
|
86
|
+
expiresAt: this.#clock() + ttl
|
|
87
|
+
});
|
|
88
|
+
return ok(undefined);
|
|
89
|
+
}
|
|
90
|
+
async consumePar(requestUri) {
|
|
91
|
+
const stored = this.#par.get(requestUri);
|
|
92
|
+
if (!stored) {
|
|
93
|
+
return err(authError.unknownState(`par "${requestUri}" unknown or already consumed`));
|
|
94
|
+
}
|
|
95
|
+
this.#par.delete(requestUri);
|
|
96
|
+
if (this.#clock() >= stored.expiresAt) {
|
|
97
|
+
return err(authError.unknownState(`par "${requestUri}" expired`));
|
|
98
|
+
}
|
|
99
|
+
return ok(stored.record);
|
|
100
|
+
}
|
|
79
101
|
}
|
|
80
102
|
export {
|
|
81
103
|
MemorySessionStore
|
|
@@ -8,6 +8,7 @@ class MemoryTokenStore {
|
|
|
8
8
|
#clock;
|
|
9
9
|
#codes = new Map;
|
|
10
10
|
#refresh = new Map;
|
|
11
|
+
#dpopJtis = new Map;
|
|
11
12
|
constructor(opts = {}) {
|
|
12
13
|
this.#clock = opts.clock ?? realClock;
|
|
13
14
|
}
|
|
@@ -85,6 +86,18 @@ class MemoryTokenStore {
|
|
|
85
86
|
}
|
|
86
87
|
return ok(undefined);
|
|
87
88
|
}
|
|
89
|
+
async recordDpopJti(jti, ttlMs) {
|
|
90
|
+
const now = this.#clock();
|
|
91
|
+
for (const [k, exp] of this.#dpopJtis) {
|
|
92
|
+
if (exp <= now)
|
|
93
|
+
this.#dpopJtis.delete(k);
|
|
94
|
+
}
|
|
95
|
+
if (this.#dpopJtis.has(jti)) {
|
|
96
|
+
return err(authError.invalidGrant(`dpop jti "${jti}" replayed`));
|
|
97
|
+
}
|
|
98
|
+
this.#dpopJtis.set(jti, now + ttlMs);
|
|
99
|
+
return ok(undefined);
|
|
100
|
+
}
|
|
88
101
|
}
|
|
89
102
|
export {
|
|
90
103
|
MemoryTokenStore
|
package/dist/esm/client.js
CHANGED
|
@@ -49,6 +49,11 @@ function createClient(input) {
|
|
|
49
49
|
result2.searchParams.set("state", challenge.state);
|
|
50
50
|
if (opts?.provider)
|
|
51
51
|
result2.searchParams.set("provider", opts.provider);
|
|
52
|
+
if (opts?.scope !== undefined) {
|
|
53
|
+
const scope = Array.isArray(opts.scope) ? opts.scope.join(" ") : opts.scope;
|
|
54
|
+
if (scope)
|
|
55
|
+
result2.searchParams.set("scope", scope);
|
|
56
|
+
}
|
|
52
57
|
if (opts?.pkce && response === "code") {
|
|
53
58
|
const pkce = await generatePKCE();
|
|
54
59
|
result2.searchParams.set("code_challenge_method", "S256");
|
|
@@ -97,7 +102,8 @@ function createClient(input) {
|
|
|
97
102
|
tokens: {
|
|
98
103
|
access: json.access_token,
|
|
99
104
|
refresh: json.refresh_token,
|
|
100
|
-
expiresIn: json.expires_in
|
|
105
|
+
expiresIn: json.expires_in,
|
|
106
|
+
...typeof json.id_token === "string" ? { idToken: json.id_token } : {}
|
|
101
107
|
}
|
|
102
108
|
};
|
|
103
109
|
},
|
|
@@ -136,7 +142,8 @@ function createClient(input) {
|
|
|
136
142
|
tokens: {
|
|
137
143
|
access: json.access_token,
|
|
138
144
|
refresh: json.refresh_token,
|
|
139
|
-
expiresIn: json.expires_in
|
|
145
|
+
expiresIn: json.expires_in,
|
|
146
|
+
...typeof json.id_token === "string" ? { idToken: json.id_token } : {}
|
|
140
147
|
}
|
|
141
148
|
};
|
|
142
149
|
},
|
|
@@ -73,6 +73,8 @@ async function startAuthorize(input, deps) {
|
|
|
73
73
|
prompt: req.prompt,
|
|
74
74
|
uiLocales: req.uiLocales,
|
|
75
75
|
nonce,
|
|
76
|
+
...req.nonce !== undefined ? { appNonce: req.nonce } : {},
|
|
77
|
+
...req.claimsRequest !== undefined ? { claimsRequest: req.claimsRequest } : {},
|
|
76
78
|
clientPkce: req.codeChallenge ? { challenge: req.codeChallenge, method: "S256" } : undefined,
|
|
77
79
|
methodState: null,
|
|
78
80
|
context: tenant.request.custom,
|
|
@@ -194,6 +196,9 @@ async function issueCodeFromInlineSuccess(result, record, deps) {
|
|
|
194
196
|
context: flow.context ?? null,
|
|
195
197
|
providerSubject: result.providerSubject,
|
|
196
198
|
properties: result.properties,
|
|
199
|
+
...flow.appNonce !== undefined ? { appNonce: flow.appNonce } : {},
|
|
200
|
+
...flow.claimsRequest !== undefined ? { claimsRequest: flow.claimsRequest } : {},
|
|
201
|
+
authTime: Math.floor(now / 1000),
|
|
197
202
|
expiresAt: now + AUTH_CODE_TTL_MS
|
|
198
203
|
}, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
|
|
199
204
|
if (isErr(saved))
|
|
@@ -117,6 +117,9 @@ async function translate(result, flow, deps) {
|
|
|
117
117
|
context: flow.context ?? null,
|
|
118
118
|
providerSubject: result.providerSubject,
|
|
119
119
|
properties: result.properties,
|
|
120
|
+
...flow.appNonce !== undefined ? { appNonce: flow.appNonce } : {},
|
|
121
|
+
...flow.claimsRequest !== undefined ? { claimsRequest: flow.claimsRequest } : {},
|
|
122
|
+
authTime: Math.floor(now / 1000),
|
|
120
123
|
expiresAt: now + AUTH_CODE_TTL_MS
|
|
121
124
|
}, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
|
|
122
125
|
if (isErr(saved))
|
|
@@ -4,6 +4,12 @@ import { buildJwksDocument } from "./jwt";
|
|
|
4
4
|
function buildDiscoveryDocument(deps) {
|
|
5
5
|
const base = deps.issuerUrl.replace(/\/+$/, "");
|
|
6
6
|
const p = deps.paths ?? {};
|
|
7
|
+
const customScopeKeys = Object.keys(deps.customScopeClaims ?? {});
|
|
8
|
+
const customClaimNames = new Set;
|
|
9
|
+
for (const list of Object.values(deps.customScopeClaims ?? {})) {
|
|
10
|
+
for (const name of list)
|
|
11
|
+
customClaimNames.add(name);
|
|
12
|
+
}
|
|
7
13
|
return {
|
|
8
14
|
issuer: deps.issuerUrl,
|
|
9
15
|
authorization_endpoint: `${base}${p.authorize ?? "/authorize"}`,
|
|
@@ -12,15 +18,51 @@ function buildDiscoveryDocument(deps) {
|
|
|
12
18
|
jwks_uri: `${base}${p.jwks ?? "/.well-known/jwks.json"}`,
|
|
13
19
|
revocation_endpoint: `${base}${p.revoke ?? "/revoke"}`,
|
|
14
20
|
introspection_endpoint: `${base}${p.introspect ?? "/introspect"}`,
|
|
21
|
+
end_session_endpoint: `${base}${p.endSession ?? "/end_session"}`,
|
|
22
|
+
registration_endpoint: `${base}${p.register ?? "/register"}`,
|
|
23
|
+
pushed_authorization_request_endpoint: `${base}${p.par ?? "/par"}`,
|
|
24
|
+
require_pushed_authorization_requests: false,
|
|
25
|
+
dpop_signing_alg_values_supported: ["ES256", "EdDSA"],
|
|
15
26
|
response_types_supported: ["code"],
|
|
16
27
|
grant_types_supported: [
|
|
17
28
|
"authorization_code",
|
|
18
29
|
"refresh_token",
|
|
19
30
|
"client_credentials"
|
|
20
31
|
],
|
|
21
|
-
subject_types_supported: ["public"],
|
|
32
|
+
subject_types_supported: ["public", "pairwise"],
|
|
22
33
|
id_token_signing_alg_values_supported: ["ES256", "EdDSA"],
|
|
23
|
-
scopes_supported:
|
|
34
|
+
scopes_supported: [
|
|
35
|
+
...deps.scopes ?? ["openid", "email", "profile"],
|
|
36
|
+
...customScopeKeys
|
|
37
|
+
],
|
|
38
|
+
claims_supported: [
|
|
39
|
+
"sub",
|
|
40
|
+
"iss",
|
|
41
|
+
"aud",
|
|
42
|
+
"exp",
|
|
43
|
+
"iat",
|
|
44
|
+
"auth_time",
|
|
45
|
+
"nonce",
|
|
46
|
+
"amr",
|
|
47
|
+
"at_hash",
|
|
48
|
+
"email",
|
|
49
|
+
"email_verified",
|
|
50
|
+
"name",
|
|
51
|
+
"given_name",
|
|
52
|
+
"family_name",
|
|
53
|
+
"preferred_username",
|
|
54
|
+
"picture",
|
|
55
|
+
"locale",
|
|
56
|
+
"phone_number",
|
|
57
|
+
"phone_number_verified",
|
|
58
|
+
"address",
|
|
59
|
+
...customClaimNames
|
|
60
|
+
],
|
|
61
|
+
claims_parameter_supported: true,
|
|
62
|
+
request_parameter_supported: false,
|
|
63
|
+
request_uri_parameter_supported: false,
|
|
64
|
+
require_request_uri_registration: false,
|
|
65
|
+
ui_locales_supported: deps.uiLocales ?? ["en"],
|
|
24
66
|
token_endpoint_auth_methods_supported: [
|
|
25
67
|
"client_secret_basic",
|
|
26
68
|
"client_secret_post",
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// src/domain/dpop.ts
|
|
2
|
+
import {
|
|
3
|
+
calculateJwkThumbprint,
|
|
4
|
+
decodeProtectedHeader,
|
|
5
|
+
importJWK,
|
|
6
|
+
jwtVerify
|
|
7
|
+
} from "jose";
|
|
8
|
+
import { authError } from "../types/error";
|
|
9
|
+
import { err, isErr, ok } from "../types/result";
|
|
10
|
+
import { base64url, sha256, utf8 } from "./crypto";
|
|
11
|
+
var DPOP_ASYMMETRIC_ALGS = new Set(["ES256", "EdDSA"]);
|
|
12
|
+
var DEFAULT_DPOP_IAT_TOLERANCE_SEC = 60;
|
|
13
|
+
var DEFAULT_DPOP_JTI_TTL_MS = 2 * DEFAULT_DPOP_IAT_TOLERANCE_SEC * 1000;
|
|
14
|
+
async function verifyDpopProof(input, deps) {
|
|
15
|
+
if (!deps.tokenStore.recordDpopJti) {
|
|
16
|
+
return err(authError.invalidDpopProof("token-store adapter does not support DPoP replay protection"));
|
|
17
|
+
}
|
|
18
|
+
let header;
|
|
19
|
+
try {
|
|
20
|
+
header = decodeProtectedHeader(input.proofJwt);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
return err(authError.invalidDpopProof(`dpop proof header is not a valid compact JWS: ${stringifyError(e)}`));
|
|
23
|
+
}
|
|
24
|
+
if (header.typ !== "dpop+jwt") {
|
|
25
|
+
return err(authError.invalidDpopProof(`dpop proof header.typ must be "dpop+jwt", got "${String(header.typ)}"`));
|
|
26
|
+
}
|
|
27
|
+
if (!header.alg || !DPOP_ASYMMETRIC_ALGS.has(header.alg)) {
|
|
28
|
+
return err(authError.invalidDpopProof(`dpop proof alg "${String(header.alg)}" not in {ES256, EdDSA}`));
|
|
29
|
+
}
|
|
30
|
+
const jwk = header.jwk;
|
|
31
|
+
if (!jwk || typeof jwk !== "object") {
|
|
32
|
+
return err(authError.invalidDpopProof("dpop proof header.jwk missing or not an object"));
|
|
33
|
+
}
|
|
34
|
+
if ("d" in jwk) {
|
|
35
|
+
return err(authError.invalidDpopProof("dpop proof header.jwk must be a public key (private material present)"));
|
|
36
|
+
}
|
|
37
|
+
let key;
|
|
38
|
+
try {
|
|
39
|
+
key = await importJWK(jwk, header.alg);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return err(authError.invalidDpopProof(`dpop proof JWK import failed: ${stringifyError(e)}`));
|
|
42
|
+
}
|
|
43
|
+
let verified;
|
|
44
|
+
try {
|
|
45
|
+
verified = await jwtVerify(input.proofJwt, key, {
|
|
46
|
+
algorithms: [header.alg]
|
|
47
|
+
});
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return err(authError.invalidDpopProof(`dpop proof signature verification failed: ${stringifyError(e)}`));
|
|
50
|
+
}
|
|
51
|
+
const payload = verified.payload;
|
|
52
|
+
if (typeof payload.htm !== "string") {
|
|
53
|
+
return err(authError.invalidDpopProof("dpop proof missing string htm"));
|
|
54
|
+
}
|
|
55
|
+
if (typeof payload.htu !== "string") {
|
|
56
|
+
return err(authError.invalidDpopProof("dpop proof missing string htu"));
|
|
57
|
+
}
|
|
58
|
+
if (typeof payload.iat !== "number") {
|
|
59
|
+
return err(authError.invalidDpopProof("dpop proof missing numeric iat"));
|
|
60
|
+
}
|
|
61
|
+
if (typeof payload.jti !== "string" || payload.jti.length === 0) {
|
|
62
|
+
return err(authError.invalidDpopProof("dpop proof missing non-empty string jti"));
|
|
63
|
+
}
|
|
64
|
+
if (payload.htm !== input.htm) {
|
|
65
|
+
return err(authError.invalidDpopProof(`dpop htm "${payload.htm}" does not match request method "${input.htm}"`));
|
|
66
|
+
}
|
|
67
|
+
if (payload.htu !== input.htu) {
|
|
68
|
+
return err(authError.invalidDpopProof(`dpop htu "${payload.htu}" does not match request uri "${input.htu}"`));
|
|
69
|
+
}
|
|
70
|
+
const tolerance = input.iatToleranceSec ?? DEFAULT_DPOP_IAT_TOLERANCE_SEC;
|
|
71
|
+
const skew = Math.abs(input.nowSec - payload.iat);
|
|
72
|
+
if (skew > tolerance) {
|
|
73
|
+
return err(authError.invalidDpopProof(`dpop iat skew ${skew}s exceeds tolerance ${tolerance}s`));
|
|
74
|
+
}
|
|
75
|
+
if (input.expectedAth !== undefined) {
|
|
76
|
+
if (typeof payload.ath !== "string" || payload.ath !== input.expectedAth) {
|
|
77
|
+
return err(authError.invalidDpopProof("dpop ath does not match SHA-256 of the access token"));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const recorded = await deps.tokenStore.recordDpopJti(payload.jti, deps.jtiTtlMs ?? DEFAULT_DPOP_JTI_TTL_MS);
|
|
81
|
+
if (isErr(recorded)) {
|
|
82
|
+
return err(authError.invalidDpopProof(`dpop jti "${payload.jti}" replayed`, {
|
|
83
|
+
jti: payload.jti
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
let jkt;
|
|
87
|
+
try {
|
|
88
|
+
jkt = await calculateJwkThumbprint(jwk, "sha256");
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return err(authError.invalidDpopProof(`dpop jkt thumbprint computation failed: ${stringifyError(e)}`));
|
|
91
|
+
}
|
|
92
|
+
return ok({
|
|
93
|
+
jkt,
|
|
94
|
+
payload: {
|
|
95
|
+
htm: payload.htm,
|
|
96
|
+
htu: payload.htu,
|
|
97
|
+
iat: payload.iat,
|
|
98
|
+
jti: payload.jti,
|
|
99
|
+
...payload.ath !== undefined ? { ath: payload.ath } : {},
|
|
100
|
+
...payload.nonce !== undefined ? { nonce: payload.nonce } : {}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function canonicalHtu(rawUrl) {
|
|
105
|
+
const u = new URL(rawUrl);
|
|
106
|
+
u.search = "";
|
|
107
|
+
u.hash = "";
|
|
108
|
+
return u.toString();
|
|
109
|
+
}
|
|
110
|
+
async function computeAth(accessToken) {
|
|
111
|
+
return base64url.encode(await sha256(utf8.encode(accessToken)));
|
|
112
|
+
}
|
|
113
|
+
function stringifyError(e) {
|
|
114
|
+
return e instanceof Error ? e.message : String(e);
|
|
115
|
+
}
|
|
116
|
+
export {
|
|
117
|
+
verifyDpopProof,
|
|
118
|
+
computeAth,
|
|
119
|
+
canonicalHtu,
|
|
120
|
+
DEFAULT_DPOP_JTI_TTL_MS,
|
|
121
|
+
DEFAULT_DPOP_IAT_TOLERANCE_SEC
|
|
122
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// src/domain/id-token.ts
|
|
2
|
+
import { base64url, sha256, utf8 } from "./crypto";
|
|
3
|
+
var SCOPE_TO_CLAIMS = {
|
|
4
|
+
profile: [
|
|
5
|
+
"name",
|
|
6
|
+
"given_name",
|
|
7
|
+
"family_name",
|
|
8
|
+
"middle_name",
|
|
9
|
+
"nickname",
|
|
10
|
+
"preferred_username",
|
|
11
|
+
"profile",
|
|
12
|
+
"picture",
|
|
13
|
+
"website",
|
|
14
|
+
"gender",
|
|
15
|
+
"birthdate",
|
|
16
|
+
"zoneinfo",
|
|
17
|
+
"locale",
|
|
18
|
+
"updated_at"
|
|
19
|
+
],
|
|
20
|
+
email: ["email", "email_verified"],
|
|
21
|
+
phone: ["phone_number", "phone_number_verified"],
|
|
22
|
+
address: ["address"]
|
|
23
|
+
};
|
|
24
|
+
function deriveAmr(methodKind) {
|
|
25
|
+
switch (methodKind) {
|
|
26
|
+
case "password":
|
|
27
|
+
return ["pwd"];
|
|
28
|
+
case "code":
|
|
29
|
+
return ["otp"];
|
|
30
|
+
case "passkey":
|
|
31
|
+
return ["hwk"];
|
|
32
|
+
default:
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function computeAtHash(accessToken) {
|
|
37
|
+
const digest = await sha256(utf8.encode(accessToken));
|
|
38
|
+
const half = digest.slice(0, digest.byteLength / 2);
|
|
39
|
+
return base64url.encode(half);
|
|
40
|
+
}
|
|
41
|
+
function pickScopedClaims(claim, scopes, extra = [], customMappings = {}) {
|
|
42
|
+
const props = claim.properties;
|
|
43
|
+
if (!props || typeof props !== "object")
|
|
44
|
+
return {};
|
|
45
|
+
const mappings = {
|
|
46
|
+
...customMappings,
|
|
47
|
+
...SCOPE_TO_CLAIMS
|
|
48
|
+
};
|
|
49
|
+
const granted = new Set;
|
|
50
|
+
for (const scope of scopes) {
|
|
51
|
+
const list = mappings[scope];
|
|
52
|
+
if (!list)
|
|
53
|
+
continue;
|
|
54
|
+
for (const c of list)
|
|
55
|
+
granted.add(c);
|
|
56
|
+
}
|
|
57
|
+
for (const name of extra)
|
|
58
|
+
granted.add(name);
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const key of granted) {
|
|
61
|
+
if (key in props && props[key] !== undefined)
|
|
62
|
+
out[key] = props[key];
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
var DEFAULT_ID_TOKEN_TTL_MS = 5 * 60 * 1000;
|
|
67
|
+
async function buildIdTokenClaims(input) {
|
|
68
|
+
const ttl = input.ttlMs ?? DEFAULT_ID_TOKEN_TTL_MS;
|
|
69
|
+
const amr = deriveAmr(input.methodKind);
|
|
70
|
+
const extraClaims = Object.keys(input.claimsRequest?.id_token ?? {});
|
|
71
|
+
return {
|
|
72
|
+
iss: input.issuerUrl,
|
|
73
|
+
sub: input.subjectId,
|
|
74
|
+
aud: input.audience,
|
|
75
|
+
exp: Math.floor((input.now + ttl) / 1000),
|
|
76
|
+
iat: Math.floor(input.now / 1000),
|
|
77
|
+
auth_time: input.authTime,
|
|
78
|
+
at_hash: await computeAtHash(input.accessToken),
|
|
79
|
+
...input.appNonce !== undefined ? { nonce: input.appNonce } : {},
|
|
80
|
+
...amr ? { amr } : {},
|
|
81
|
+
...pickScopedClaims(input.claim, input.scopes, extraClaims, input.customScopeClaims ?? {})
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function shouldIssueIdToken(scopes) {
|
|
85
|
+
return scopes.includes("openid");
|
|
86
|
+
}
|
|
87
|
+
export {
|
|
88
|
+
shouldIssueIdToken,
|
|
89
|
+
pickScopedClaims,
|
|
90
|
+
deriveAmr,
|
|
91
|
+
computeAtHash,
|
|
92
|
+
buildIdTokenClaims,
|
|
93
|
+
DEFAULT_ID_TOKEN_TTL_MS
|
|
94
|
+
};
|
|
@@ -32,6 +32,7 @@ async function introspect(req, deps) {
|
|
|
32
32
|
if (claims.aud !== req.clientId) {
|
|
33
33
|
return ok({ active: false });
|
|
34
34
|
}
|
|
35
|
+
const subjectType = claims.claim?.type;
|
|
35
36
|
return ok({
|
|
36
37
|
active: true,
|
|
37
38
|
sub: claims.sub,
|
|
@@ -41,9 +42,12 @@ async function introspect(req, deps) {
|
|
|
41
42
|
iat: claims.iat,
|
|
42
43
|
scope: claims.scope,
|
|
43
44
|
client_id: claims.aud,
|
|
45
|
+
token_type: claims.cnf?.jkt !== undefined ? "DPoP" : "Bearer",
|
|
44
46
|
tid: claims.tid,
|
|
45
47
|
mid: claims.mid,
|
|
46
|
-
mkind: claims.mkind
|
|
48
|
+
mkind: claims.mkind,
|
|
49
|
+
...subjectType !== undefined ? { subject_type: subjectType } : {},
|
|
50
|
+
...claims.cnf !== undefined ? { cnf: claims.cnf } : {}
|
|
47
51
|
});
|
|
48
52
|
}
|
|
49
53
|
export {
|
package/dist/esm/domain/jwt.js
CHANGED
|
@@ -3,6 +3,32 @@ import { importJWK, jwtVerify, SignJWT } from "jose";
|
|
|
3
3
|
async function signAccessToken(claims, privateKey, alg, kid) {
|
|
4
4
|
return new SignJWT(claims).setProtectedHeader({ alg, typ: "JWT", kid }).sign(privateKey);
|
|
5
5
|
}
|
|
6
|
+
async function signIdToken(claims, privateKey, alg, kid) {
|
|
7
|
+
return new SignJWT(claims).setProtectedHeader({ alg, typ: "JWT", kid }).sign(privateKey);
|
|
8
|
+
}
|
|
9
|
+
async function verifyIdToken(token, keys, options = {}) {
|
|
10
|
+
const algorithms = Array.from(new Set(keys.map((k) => k.alg).filter((a) => ASYMMETRIC_ALGS.has(a))));
|
|
11
|
+
const { payload } = await jwtVerify(token, async (header) => {
|
|
12
|
+
if (!header.alg || header.alg === "none") {
|
|
13
|
+
throw new Error(`verifyIdToken: refusing alg "${header.alg ?? ""}"`);
|
|
14
|
+
}
|
|
15
|
+
const match = keys.find((k) => k.kid === header.kid);
|
|
16
|
+
if (!match) {
|
|
17
|
+
throw new Error(`verifyIdToken: unknown kid "${header.kid}"`);
|
|
18
|
+
}
|
|
19
|
+
if (match.alg !== header.alg) {
|
|
20
|
+
throw new Error(`verifyIdToken: header.alg "${header.alg}" does not match key alg "${match.alg}"`);
|
|
21
|
+
}
|
|
22
|
+
const imported = await importJWK(match.publicJwk, match.alg);
|
|
23
|
+
return imported;
|
|
24
|
+
}, {
|
|
25
|
+
algorithms,
|
|
26
|
+
...options.issuer ? { issuer: options.issuer } : {},
|
|
27
|
+
...options.audience ? { audience: options.audience } : {},
|
|
28
|
+
...options.acceptExpired ? { clockTolerance: Number.MAX_SAFE_INTEGER } : {}
|
|
29
|
+
});
|
|
30
|
+
return payload;
|
|
31
|
+
}
|
|
6
32
|
var ASYMMETRIC_ALGS = new Set(["ES256", "EdDSA"]);
|
|
7
33
|
async function verifyAccessToken(token, keys, options = {}) {
|
|
8
34
|
const algorithms = Array.from(new Set(keys.map((k) => k.alg).filter((a) => ASYMMETRIC_ALGS.has(a))));
|
|
@@ -37,7 +63,9 @@ function buildJwksDocument(keys) {
|
|
|
37
63
|
};
|
|
38
64
|
}
|
|
39
65
|
export {
|
|
66
|
+
verifyIdToken,
|
|
40
67
|
verifyAccessToken,
|
|
68
|
+
signIdToken,
|
|
41
69
|
signAccessToken,
|
|
42
70
|
buildJwksDocument
|
|
43
71
|
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/domain/logout.ts
|
|
2
|
+
import { authError } from "../types/error";
|
|
3
|
+
import { err, isErr, ok } from "../types/result";
|
|
4
|
+
import { safeAudit } from "./audit";
|
|
5
|
+
import { verifyIdToken } from "./jwt";
|
|
6
|
+
import { revokeAllForSubject } from "./revoke";
|
|
7
|
+
async function endSession(req, tenant, deps) {
|
|
8
|
+
let hintSubject;
|
|
9
|
+
let hintAud;
|
|
10
|
+
if (req.idTokenHint) {
|
|
11
|
+
const keysRes = await deps.keyStore.signingKeys();
|
|
12
|
+
if (isErr(keysRes))
|
|
13
|
+
return err(keysRes.error);
|
|
14
|
+
try {
|
|
15
|
+
const claims = await verifyIdToken(req.idTokenHint, keysRes.value, {
|
|
16
|
+
issuer: deps.issuerUrl,
|
|
17
|
+
acceptExpired: true
|
|
18
|
+
});
|
|
19
|
+
hintSubject = claims.sub;
|
|
20
|
+
hintAud = claims.aud;
|
|
21
|
+
} catch (e) {
|
|
22
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
23
|
+
return err(authError.invalidRequest(`id_token_hint failed signature/issuer verification: ${reason}`, "id_token_hint"));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (req.clientId && hintAud && req.clientId !== hintAud) {
|
|
27
|
+
return err(authError.invalidRequest("client_id does not match id_token_hint.aud", "client_id"));
|
|
28
|
+
}
|
|
29
|
+
const resolvedClientId = req.clientId ?? hintAud;
|
|
30
|
+
let client;
|
|
31
|
+
if (resolvedClientId) {
|
|
32
|
+
client = tenant.config.clients.find((c) => c.id === resolvedClientId);
|
|
33
|
+
if (!client) {
|
|
34
|
+
return err(authError.invalidRequest(`unknown client "${resolvedClientId}"`, "client_id"));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (req.postLogoutRedirectUri) {
|
|
38
|
+
if (!client) {
|
|
39
|
+
return err(authError.invalidRequest("post_logout_redirect_uri requires identifying the client via client_id or id_token_hint", "post_logout_redirect_uri"));
|
|
40
|
+
}
|
|
41
|
+
const registered = client.postLogoutRedirectUris ?? [];
|
|
42
|
+
if (!registered.includes(req.postLogoutRedirectUri)) {
|
|
43
|
+
return err(authError.invalidRequest("post_logout_redirect_uri is not registered for this client", "post_logout_redirect_uri"));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (hintSubject) {
|
|
47
|
+
const revoked = await revokeAllForSubject(tenant.id, hintSubject, {
|
|
48
|
+
tokenStore: deps.tokenStore,
|
|
49
|
+
...deps.auditLog ? { auditLog: deps.auditLog } : {},
|
|
50
|
+
clock: deps.clock
|
|
51
|
+
});
|
|
52
|
+
if (isErr(revoked))
|
|
53
|
+
return err(revoked.error);
|
|
54
|
+
}
|
|
55
|
+
await safeAudit(deps, {
|
|
56
|
+
kind: "session_logout",
|
|
57
|
+
tenantId: tenant.id,
|
|
58
|
+
...resolvedClientId ? { clientId: resolvedClientId } : {},
|
|
59
|
+
...hintSubject ? { subjectId: hintSubject } : {},
|
|
60
|
+
timestamp: deps.clock()
|
|
61
|
+
});
|
|
62
|
+
if (req.postLogoutRedirectUri) {
|
|
63
|
+
const url = new URL(req.postLogoutRedirectUri);
|
|
64
|
+
if (req.state !== undefined)
|
|
65
|
+
url.searchParams.set("state", req.state);
|
|
66
|
+
return ok({ kind: "redirect", url: url.toString() });
|
|
67
|
+
}
|
|
68
|
+
return ok({
|
|
69
|
+
kind: "ok",
|
|
70
|
+
...hintSubject ? { subjectId: hintSubject } : {}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export {
|
|
74
|
+
endSession
|
|
75
|
+
};
|
|
@@ -65,6 +65,9 @@ async function translate(result, flow, deps) {
|
|
|
65
65
|
context: final.context ?? null,
|
|
66
66
|
providerSubject: result.providerSubject,
|
|
67
67
|
properties: result.properties,
|
|
68
|
+
...final.appNonce !== undefined ? { appNonce: final.appNonce } : {},
|
|
69
|
+
...final.claimsRequest !== undefined ? { claimsRequest: final.claimsRequest } : {},
|
|
70
|
+
authTime: Math.floor(now / 1000),
|
|
68
71
|
expiresAt: now + AUTH_CODE_TTL_MS
|
|
69
72
|
}, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
|
|
70
73
|
if (isErr(saved))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/domain/par.ts
|
|
2
|
+
import { authError } from "../types/error";
|
|
3
|
+
import { err, isErr, ok } from "../types/result";
|
|
4
|
+
import { verifyClientCredentials } from "./client-auth";
|
|
5
|
+
import { randomToken } from "./crypto";
|
|
6
|
+
var PAR_URI_PREFIX = "urn:ietf:params:oauth:request_uri:";
|
|
7
|
+
var DEFAULT_PAR_TTL_MS = 60 * 1000;
|
|
8
|
+
async function pushAuthorizationRequest(req, tenant, deps) {
|
|
9
|
+
if (!deps.sessionStore.savePar || !deps.sessionStore.consumePar) {
|
|
10
|
+
return err(authError.invalidRequest("session adapter does not implement PAR storage"));
|
|
11
|
+
}
|
|
12
|
+
const client = tenant.config.clients.find((c) => c.id === req.clientId);
|
|
13
|
+
if (!client) {
|
|
14
|
+
return err(authError.invalidClient(`unknown client "${req.clientId}"`));
|
|
15
|
+
}
|
|
16
|
+
if (client.type === "confidential") {
|
|
17
|
+
const authErr = await verifyClientCredentials(client, req.clientSecret);
|
|
18
|
+
if (authErr)
|
|
19
|
+
return err(authErr);
|
|
20
|
+
} else if (req.clientSecret !== undefined) {
|
|
21
|
+
return err(authError.invalidClient("public client must not present client_secret"));
|
|
22
|
+
}
|
|
23
|
+
if (req.params.client_id && req.params.client_id !== req.clientId) {
|
|
24
|
+
return err(authError.invalidRequest("client_id in body conflicts with authenticated client", "client_id"));
|
|
25
|
+
}
|
|
26
|
+
if ("request_uri" in req.params) {
|
|
27
|
+
return err(authError.invalidRequest("request_uri MUST NOT be present in a PAR request", "request_uri"));
|
|
28
|
+
}
|
|
29
|
+
const suffix = (deps.newRequestUriSuffix ?? randomToken)();
|
|
30
|
+
const requestUri = PAR_URI_PREFIX + suffix;
|
|
31
|
+
const ttl = deps.ttlMs ?? DEFAULT_PAR_TTL_MS;
|
|
32
|
+
const now = deps.clock();
|
|
33
|
+
const saved = await deps.sessionStore.savePar(requestUri, {
|
|
34
|
+
requestUri,
|
|
35
|
+
params: { ...req.params, client_id: req.clientId },
|
|
36
|
+
clientId: req.clientId,
|
|
37
|
+
issuedAt: now,
|
|
38
|
+
expiresAt: now + ttl
|
|
39
|
+
}, ttl);
|
|
40
|
+
if (isErr(saved))
|
|
41
|
+
return err(saved.error);
|
|
42
|
+
return ok({
|
|
43
|
+
request_uri: requestUri,
|
|
44
|
+
expires_in: Math.floor(ttl / 1000)
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export {
|
|
48
|
+
pushAuthorizationRequest,
|
|
49
|
+
PAR_URI_PREFIX,
|
|
50
|
+
DEFAULT_PAR_TTL_MS
|
|
51
|
+
};
|
|
@@ -31,6 +31,14 @@ async function refreshTokens(req, deps) {
|
|
|
31
31
|
} else if (client.type === "confidential") {
|
|
32
32
|
return err(authError.invalidGrant(INVALID_REFRESH_DESC));
|
|
33
33
|
}
|
|
34
|
+
if (peekedPayload.dpopJkt !== undefined) {
|
|
35
|
+
if (req.dpopJkt === undefined) {
|
|
36
|
+
return err(authError.invalidDpopProof("refresh token is DPoP-bound; request is missing a DPoP proof"));
|
|
37
|
+
}
|
|
38
|
+
if (req.dpopJkt !== peekedPayload.dpopJkt) {
|
|
39
|
+
return err(authError.invalidDpopProof("refresh token DPoP jkt does not match the original binding"));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
34
42
|
const consumed = await deps.tokenStore.consumeRefresh(req.refreshToken, {
|
|
35
43
|
reuseWindowMs: deps.reuseWindowMs
|
|
36
44
|
});
|
|
@@ -68,7 +76,10 @@ async function refreshTokens(req, deps) {
|
|
|
68
76
|
methodId: payload.methodId,
|
|
69
77
|
methodKind: payload.methodKind,
|
|
70
78
|
scopes: requestedScopes,
|
|
71
|
-
audience: payload.audience
|
|
79
|
+
audience: payload.audience,
|
|
80
|
+
authTime: payload.authTime,
|
|
81
|
+
...payload.dpopJkt !== undefined ? { dpopJkt: payload.dpopJkt } : {},
|
|
82
|
+
...payload.claimsRequest !== undefined ? { claimsRequest: payload.claimsRequest } : {}
|
|
72
83
|
},
|
|
73
84
|
family: payload.family,
|
|
74
85
|
deps
|