@kanzo-tech/auth 0.29.1 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -23
- package/dist/bff-auth.d.ts +1 -2
- package/dist/bff-auth.d.ts.map +1 -1
- package/dist/bff-auth.js +82 -77
- package/dist/bff-auth.js.map +1 -1
- package/dist/can.d.ts +13 -9
- package/dist/can.d.ts.map +1 -1
- package/dist/can.js +6 -6
- package/dist/can.js.map +1 -1
- package/dist/gate.d.ts +4 -4
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +8 -9
- package/dist/gate.js.map +1 -1
- package/dist/index.d.ts +11 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -23
- package/dist/index.js.map +1 -1
- package/dist/issuer.d.ts +16 -0
- package/dist/issuer.d.ts.map +1 -1
- package/dist/issuer.js +43 -25
- package/dist/issuer.js.map +1 -1
- package/dist/next-auth.d.ts +66 -0
- package/dist/next-auth.d.ts.map +1 -0
- package/dist/next-auth.js +43 -0
- package/dist/next-auth.js.map +1 -0
- package/dist/next-bound.d.ts +89 -0
- package/dist/next-bound.d.ts.map +1 -0
- package/dist/next-bound.js +48 -0
- package/dist/next-bound.js.map +1 -0
- package/dist/next-gate.d.ts +4 -0
- package/dist/next-gate.d.ts.map +1 -0
- package/dist/next-gate.js +74 -0
- package/dist/next-gate.js.map +1 -0
- package/dist/next-proxy.d.ts +15 -24
- package/dist/next-proxy.d.ts.map +1 -1
- package/dist/next-proxy.js +47 -39
- package/dist/next-proxy.js.map +1 -1
- package/dist/next-routes.d.ts +4 -21
- package/dist/next-routes.d.ts.map +1 -1
- package/dist/next-routes.js +83 -64
- package/dist/next-routes.js.map +1 -1
- package/dist/next.d.ts +16 -41
- package/dist/next.d.ts.map +1 -1
- package/dist/next.js +2 -10
- package/dist/next.js.map +1 -1
- package/dist/server.d.ts +60 -38
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +183 -159
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +102 -20
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +56 -21
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +40 -23
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/use-session.d.ts +6 -1
- package/dist/use-session.d.ts.map +1 -1
- package/dist/use-session.js +9 -7
- package/dist/use-session.js.map +1 -1
- package/package.json +11 -30
- package/dist/auth-fetch.d.ts +0 -44
- package/dist/auth-fetch.d.ts.map +0 -1
- package/dist/auth-fetch.js +0 -21
- package/dist/auth-fetch.js.map +0 -1
- package/dist/browser.d.ts +0 -61
- package/dist/browser.d.ts.map +0 -1
- package/dist/browser.js +0 -130
- package/dist/browser.js.map +0 -1
- package/dist/host.d.ts +0 -22
- package/dist/host.d.ts.map +0 -1
- package/dist/host.js +0 -10
- package/dist/host.js.map +0 -1
- package/dist/next-middleware.d.ts +0 -23
- package/dist/next-middleware.d.ts.map +0 -1
- package/dist/next-middleware.js +0 -23
- package/dist/next-middleware.js.map +0 -1
- package/dist/next-session.d.ts +0 -44
- package/dist/next-session.d.ts.map +0 -1
- package/dist/next-session.js +0 -11
- package/dist/next-session.js.map +0 -1
- package/dist/next-token.d.ts +0 -56
- package/dist/next-token.d.ts.map +0 -1
- package/dist/next-token.js +0 -9
- package/dist/next-token.js.map +0 -1
- package/dist/use-organization.d.ts +0 -22
- package/dist/use-organization.d.ts.map +0 -1
- package/dist/use-organization.js +0 -20
- package/dist/use-organization.js.map +0 -1
package/dist/server.js
CHANGED
|
@@ -1,224 +1,248 @@
|
|
|
1
|
-
import { buildEndSessionUrl as
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
1
|
+
import { buildEndSessionUrl as R, randomPKCECodeVerifier as C, randomState as U, randomNonce as D, calculatePKCECodeChallenge as L, buildAuthorizationUrl as j, refreshTokenGrant as M, authorizationCodeGrant as P } from "openid-client";
|
|
2
|
+
import { jwtVerify as F } from "jose";
|
|
3
|
+
import { claims as G } from "./claims.js";
|
|
4
|
+
import { sealedCookie as N } from "./cookie-session.js";
|
|
5
|
+
import { cookieValue as we } from "./cookie-session.js";
|
|
6
|
+
import { DEADLINE as H } from "./deadline.js";
|
|
7
|
+
import { issuer as K } from "./issuer.js";
|
|
8
|
+
import { rewriteOrigin as Te } from "./issuer.js";
|
|
9
|
+
import { keyedSingleFlight as V } from "./single-flight.js";
|
|
10
|
+
import { statelessStore as W } from "./store.js";
|
|
11
|
+
import { ticketStore as ye } from "./store.js";
|
|
11
12
|
import { AuthError as m } from "./types.js";
|
|
12
|
-
const
|
|
13
|
-
function
|
|
14
|
-
throw new m(
|
|
13
|
+
const Z = "openid profile email organization:*", $ = 480 * 60, J = 600, X = /^[A-Za-z0-9_-]{16,128}$/, B = "http://schemas.openid.net/event/backchannel-logout", Y = 60;
|
|
14
|
+
function f(o, a, s) {
|
|
15
|
+
throw new m(o, a, {}, s === void 0 ? void 0 : { cause: s });
|
|
15
16
|
}
|
|
16
|
-
function
|
|
17
|
-
if (typeof
|
|
18
|
-
const
|
|
19
|
-
return typeof
|
|
17
|
+
function _(o) {
|
|
18
|
+
if (typeof o != "object" || o === null || !("code" in o)) return;
|
|
19
|
+
const a = o.code;
|
|
20
|
+
return typeof a == "string" ? a : void 0;
|
|
20
21
|
}
|
|
21
|
-
function
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
24
|
-
let
|
|
25
|
-
for (let
|
|
26
|
-
if (
|
|
27
|
-
|
|
22
|
+
function O(o) {
|
|
23
|
+
const a = _(o);
|
|
24
|
+
if (a === "OAUTH_JWT_CLAIM_COMPARISON_FAILED") {
|
|
25
|
+
let s = o;
|
|
26
|
+
for (let u = 0; u < 4 && typeof s == "object" && s !== null; u++) {
|
|
27
|
+
if (s.claim === "nonce") return !0;
|
|
28
|
+
s = s.cause;
|
|
28
29
|
}
|
|
29
30
|
return !1;
|
|
30
31
|
}
|
|
31
|
-
return
|
|
32
|
+
return a === "OAUTH_INVALID_RESPONSE" && o instanceof Error && o.cause instanceof Error && o.cause.message.includes('"nonce"');
|
|
32
33
|
}
|
|
33
|
-
function
|
|
34
|
-
return
|
|
34
|
+
function Q(o) {
|
|
35
|
+
return _(o) === "OAUTH_KEY_SELECTION_FAILED";
|
|
35
36
|
}
|
|
36
|
-
function
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
if (
|
|
37
|
+
function x(o) {
|
|
38
|
+
const a = _(o);
|
|
39
|
+
if (a === "OAUTH_TIMEOUT" || a === "ERR_JWKS_TIMEOUT") return "idp/silent";
|
|
40
|
+
if (a === "OAUTH_RESPONSE_IS_NOT_CONFORM" || a === "ERR_JWKS_INVALID" || a === "ERR_JOSE_GENERIC" || o instanceof TypeError && a === void 0) return "idp/unreachable";
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
} catch (l) {
|
|
56
|
-
return s(l);
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
async drop(a) {
|
|
60
|
-
try {
|
|
61
|
-
await n.drop(a);
|
|
62
|
-
} catch (l) {
|
|
63
|
-
s(l);
|
|
64
|
-
}
|
|
42
|
+
function q(o) {
|
|
43
|
+
return typeof o == "object" && o !== null && o.error === "invalid_grant";
|
|
44
|
+
}
|
|
45
|
+
function ee(o, a) {
|
|
46
|
+
return [...(o ?? Z).split(/\s+/).filter((u) => u !== "" && u !== "organization" && !u.startsWith("organization:")), `organization:${a ?? "*"}`].join(" ");
|
|
47
|
+
}
|
|
48
|
+
const te = /^(\*|[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?)$/, ne = V();
|
|
49
|
+
function oe(o) {
|
|
50
|
+
const a = (s) => async (...u) => {
|
|
51
|
+
try {
|
|
52
|
+
return await s(...u);
|
|
53
|
+
} catch (h) {
|
|
54
|
+
if (h instanceof m && h.code === "session/irrevocable") throw h;
|
|
55
|
+
return f("session/unavailable", "the session store did not answer", h);
|
|
65
56
|
}
|
|
66
57
|
};
|
|
58
|
+
return {
|
|
59
|
+
put: a((s) => o.put(s)),
|
|
60
|
+
update: a((s, u) => o.update(s, u)),
|
|
61
|
+
get: a((s) => o.get(s)),
|
|
62
|
+
drop: a((s) => o.drop(s)),
|
|
63
|
+
dropAll: a((s) => o.dropAll(s))
|
|
64
|
+
};
|
|
67
65
|
}
|
|
68
|
-
function
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
return new m(
|
|
72
|
-
},
|
|
66
|
+
function he(o) {
|
|
67
|
+
const a = K(o), s = oe(o.store ?? W()), u = H, h = (t, e, n) => {
|
|
68
|
+
const i = x(t) ?? e;
|
|
69
|
+
return new m(i, n, i === "idp/silent" ? { after: u } : {}, { cause: t });
|
|
70
|
+
}, T = async () => {
|
|
73
71
|
try {
|
|
74
|
-
return await
|
|
72
|
+
return await a.configuration();
|
|
75
73
|
} catch (t) {
|
|
76
|
-
throw
|
|
74
|
+
throw h(t, "idp/unreachable", "the IdP's discovery document could not be read");
|
|
77
75
|
}
|
|
78
|
-
},
|
|
76
|
+
}, w = N({
|
|
79
77
|
name: "kanzo-session",
|
|
80
|
-
secret:
|
|
81
|
-
maxAge:
|
|
82
|
-
}),
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const i = t.expiresIn(), c = {
|
|
93
|
-
session: r,
|
|
78
|
+
secret: o.secret,
|
|
79
|
+
maxAge: o.maxAge ?? $
|
|
80
|
+
}), E = (t) => N({ name: `kanzo-auth.${t}`, secret: o.secret, maxAge: J }), g = async (t) => {
|
|
81
|
+
const e = await w.read(t);
|
|
82
|
+
return e === null ? null : { ticket: e.ticket, record: await s.get(e.ticket) };
|
|
83
|
+
}, p = async (t, e = "session/absent") => (t !== void 0 && await s.drop(t), { ended: !0, code: e, cookies: t === void 0 ? [] : [w.clear()] }), b = (t, e) => {
|
|
84
|
+
const n = t.claims(), i = n === void 0 ? e == null ? void 0 : e.session : G(n, o);
|
|
85
|
+
i === void 0 && f("token/exchange-failed", "the token response carried no ID token, so it names nobody");
|
|
86
|
+
const r = t.expiresIn(), c = r === void 0 ? void 0 : Date.now() + r * 1e3, l = typeof (n == null ? void 0 : n.sid) == "string" ? n.sid : e == null ? void 0 : e.sid;
|
|
87
|
+
return {
|
|
88
|
+
session: { ...i, expiresAt: c ?? i.expiresAt },
|
|
89
|
+
sid: l,
|
|
94
90
|
accessToken: t.access_token,
|
|
95
|
-
accessTokenExpiresAt:
|
|
91
|
+
accessTokenExpiresAt: c,
|
|
96
92
|
// RFC 10017 requires rotation, so the newly issued token is the only one still valid. An
|
|
97
93
|
// authorization server that did not rotate returns none, and the one we hold stays good.
|
|
98
94
|
refreshToken: t.refresh_token ?? (e == null ? void 0 : e.refreshToken),
|
|
99
95
|
idToken: t.id_token ?? (e == null ? void 0 : e.idToken)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
96
|
+
};
|
|
97
|
+
}, I = (t, e) => ne(t, async () => {
|
|
98
|
+
const n = e.refreshToken;
|
|
99
|
+
if (n === void 0) return p(t);
|
|
100
|
+
let i;
|
|
101
|
+
try {
|
|
102
|
+
i = await M(await T(), n);
|
|
103
|
+
} catch (l) {
|
|
104
|
+
if (l instanceof m) throw l;
|
|
105
|
+
if (x(l) !== void 0 || !q(l))
|
|
106
|
+
throw h(l, "token/exchange-failed", "the token endpoint did not renew the session");
|
|
107
|
+
const d = await s.get(t);
|
|
108
|
+
return (d == null ? void 0 : d.refreshToken) !== void 0 && d.refreshToken !== n ? { record: d, renewed: { ended: !1, session: d.session, cookies: [] } } : p(t, "token/refused");
|
|
109
|
+
}
|
|
110
|
+
const r = b(i, e), c = await s.update(t, r);
|
|
111
|
+
return c === null ? p(t) : {
|
|
112
|
+
record: r,
|
|
113
|
+
renewed: {
|
|
114
|
+
ended: !1,
|
|
115
|
+
session: r.session,
|
|
116
|
+
cookies: c === t ? [] : [await w.seal({ ticket: c })]
|
|
112
117
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
};
|
|
118
|
+
};
|
|
119
|
+
}), S = (t) => t instanceof m ? t : h(t, "token/refused", "the logout token did not verify");
|
|
116
120
|
return {
|
|
117
|
-
async begin(t
|
|
118
|
-
const e = await
|
|
119
|
-
t.organization !== void 0 && !
|
|
121
|
+
async begin(t) {
|
|
122
|
+
const e = await T(), n = C(), i = U(), r = D();
|
|
123
|
+
t.organization !== void 0 && !te.test(t.organization) && f(
|
|
120
124
|
"organization/invalid",
|
|
121
125
|
`\`${t.organization}\` is not an organization alias, and a scope is a space-delimited list: see ORGANIZATION`
|
|
122
126
|
);
|
|
123
127
|
const c = {
|
|
124
|
-
redirect_uri:
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
scope: t.organization === void 0 ? n.scope ?? I : `${n.scope ?? I} organization:${t.organization}`,
|
|
128
|
-
code_challenge: await v(o),
|
|
128
|
+
redirect_uri: t.redirectUri,
|
|
129
|
+
scope: ee(o.scope, t.organization),
|
|
130
|
+
code_challenge: await L(n),
|
|
129
131
|
code_challenge_method: "S256",
|
|
130
|
-
state:
|
|
131
|
-
nonce:
|
|
132
|
+
state: i,
|
|
133
|
+
nonce: r
|
|
132
134
|
};
|
|
133
135
|
return {
|
|
134
|
-
url:
|
|
136
|
+
url: j(e, c).href,
|
|
135
137
|
cookies: [
|
|
136
|
-
await
|
|
138
|
+
await E(i).seal({ state: i, nonce: r, verifier: n, returnTo: t.returnTo ?? "/" })
|
|
137
139
|
]
|
|
138
140
|
};
|
|
139
141
|
},
|
|
140
142
|
async complete(t) {
|
|
141
|
-
const e =
|
|
142
|
-
|
|
143
|
+
const e = new URL(t.url), n = e.searchParams.get("state");
|
|
144
|
+
(n === null || !X.test(n)) && f("callback/state-mismatch", "the callback carries no `state` this client could have sent");
|
|
145
|
+
const i = E(n), r = await i.read(t.cookie);
|
|
146
|
+
(r === null || r.state !== n) && f(
|
|
143
147
|
"callback/state-mismatch",
|
|
144
|
-
"the callback
|
|
148
|
+
"the callback's `state` names no transaction this browser started"
|
|
145
149
|
);
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
expectedNonce: e.nonce
|
|
155
|
-
}, i = (d) => D(d, o, r);
|
|
156
|
-
let c;
|
|
150
|
+
const c = {
|
|
151
|
+
pkceCodeVerifier: r.verifier,
|
|
152
|
+
expectedState: r.state,
|
|
153
|
+
expectedNonce: r.nonce
|
|
154
|
+
}, l = new URL(t.redirectUri);
|
|
155
|
+
l.search = e.search;
|
|
156
|
+
const d = (k) => P(k, l, c);
|
|
157
|
+
let y;
|
|
157
158
|
try {
|
|
158
|
-
|
|
159
|
-
} catch (
|
|
160
|
-
if (
|
|
161
|
-
if (
|
|
159
|
+
y = await d(await T());
|
|
160
|
+
} catch (k) {
|
|
161
|
+
if (k instanceof m) throw k;
|
|
162
|
+
if (O(k) && f(
|
|
162
163
|
"callback/nonce-mismatch",
|
|
163
164
|
"the ID token's `nonce` is not the one this transaction sent",
|
|
164
|
-
|
|
165
|
-
), !
|
|
166
|
-
throw k
|
|
165
|
+
k
|
|
166
|
+
), !Q(k))
|
|
167
|
+
throw h(k, "token/exchange-failed", "the authorization code could not be exchanged");
|
|
167
168
|
try {
|
|
168
|
-
|
|
169
|
-
} catch (
|
|
170
|
-
throw
|
|
169
|
+
y = await d(await a.rediscover());
|
|
170
|
+
} catch (A) {
|
|
171
|
+
throw O(A) && f(
|
|
171
172
|
"callback/nonce-mismatch",
|
|
172
173
|
"the ID token's `nonce` is not the one this transaction sent",
|
|
173
|
-
|
|
174
|
-
),
|
|
175
|
-
|
|
174
|
+
A
|
|
175
|
+
), h(
|
|
176
|
+
A,
|
|
176
177
|
"token/exchange-failed",
|
|
177
178
|
"the ID token did not verify, and did not verify against freshly discovered keys either"
|
|
178
179
|
);
|
|
179
180
|
}
|
|
180
181
|
}
|
|
181
|
-
const
|
|
182
|
+
const v = b(y, null), z = await s.put(v);
|
|
182
183
|
return {
|
|
183
|
-
|
|
184
|
-
cookies: [
|
|
185
|
-
returnTo:
|
|
184
|
+
session: v.session,
|
|
185
|
+
cookies: [await w.seal({ ticket: z }), i.clear()],
|
|
186
|
+
returnTo: r.returnTo
|
|
186
187
|
};
|
|
187
188
|
},
|
|
188
189
|
async read(t) {
|
|
189
|
-
var e;
|
|
190
|
-
return ((e = await
|
|
190
|
+
var e, n;
|
|
191
|
+
return ((n = (e = await g(t)) == null ? void 0 : e.record) == null ? void 0 : n.session) ?? null;
|
|
191
192
|
},
|
|
192
193
|
async token(t, e = {}) {
|
|
193
|
-
const
|
|
194
|
-
if (
|
|
195
|
-
const r = (e.renewWithin ??
|
|
196
|
-
if (
|
|
197
|
-
return { accessToken:
|
|
198
|
-
const
|
|
199
|
-
return d.accessToken === void 0 &&
|
|
194
|
+
const n = await g(t);
|
|
195
|
+
if ((n == null ? void 0 : n.record) == null) return p(n == null ? void 0 : n.ticket);
|
|
196
|
+
const { ticket: i, record: r } = n, c = (e.renewWithin ?? Y) * 1e3, l = r.accessTokenExpiresAt !== void 0 && r.accessTokenExpiresAt - Date.now() <= c;
|
|
197
|
+
if (r.accessToken !== void 0 && !l)
|
|
198
|
+
return { ended: !1, accessToken: r.accessToken, session: r.session, cookies: [] };
|
|
199
|
+
const d = await I(i, r);
|
|
200
|
+
return "ended" in d ? d : (d.record.accessToken === void 0 && f("token/exchange-failed", "the token response carried no access token"), { ...d.renewed, accessToken: d.record.accessToken });
|
|
200
201
|
},
|
|
201
202
|
async refresh(t) {
|
|
202
|
-
|
|
203
|
+
const e = await g(t);
|
|
204
|
+
if ((e == null ? void 0 : e.record) == null) return p(e == null ? void 0 : e.ticket);
|
|
205
|
+
const n = await I(e.ticket, e.record);
|
|
206
|
+
return "ended" in n ? n : n.renewed;
|
|
203
207
|
},
|
|
204
208
|
async end(t, e = {}) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
var c;
|
|
210
|
+
const n = await g(t);
|
|
211
|
+
n !== null && await s.drop(n.ticket);
|
|
212
|
+
const i = {}, r = e.returnTo ?? o.postLogoutRedirectUri;
|
|
213
|
+
return r !== void 0 && (i.post_logout_redirect_uri = r), ((c = n == null ? void 0 : n.record) == null ? void 0 : c.idToken) !== void 0 && (i.id_token_hint = n.record.idToken), {
|
|
214
|
+
url: R(await T(), i).href,
|
|
215
|
+
cookies: [w.clear()]
|
|
211
216
|
};
|
|
217
|
+
},
|
|
218
|
+
async logout(t) {
|
|
219
|
+
let e;
|
|
220
|
+
try {
|
|
221
|
+
({ payload: e } = await F(t, await a.keys(), {
|
|
222
|
+
issuer: o.issuer,
|
|
223
|
+
audience: o.clientId,
|
|
224
|
+
requiredClaims: ["iat"]
|
|
225
|
+
}));
|
|
226
|
+
} catch (l) {
|
|
227
|
+
throw S(l);
|
|
228
|
+
}
|
|
229
|
+
const n = e.events, i = typeof n == "object" && n !== null ? n[B] : void 0;
|
|
230
|
+
(typeof i != "object" || i === null) && f("token/refused", "the logout token does not carry the back-channel logout event"), "nonce" in e && f("token/refused", "a logout token must not carry a `nonce`");
|
|
231
|
+
const r = typeof e.sub == "string" ? e.sub : void 0, c = typeof e.sid == "string" ? e.sid : void 0;
|
|
232
|
+
r === void 0 && f(
|
|
233
|
+
"token/refused",
|
|
234
|
+
c === void 0 ? "the logout token names neither a subject nor a session" : "the logout token names a session but no subject, and sessions are found by subject"
|
|
235
|
+
), await s.dropAll({ sub: r, sid: c });
|
|
212
236
|
}
|
|
213
237
|
};
|
|
214
238
|
}
|
|
215
239
|
export {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
240
|
+
we as cookieValue,
|
|
241
|
+
K as issuer,
|
|
242
|
+
he as relyingParty,
|
|
243
|
+
Te as rewriteOrigin,
|
|
244
|
+
N as sealedCookie,
|
|
245
|
+
W as statelessStore,
|
|
246
|
+
ye as ticketStore
|
|
223
247
|
};
|
|
224
248
|
//# sourceMappingURL=server.js.map
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","sources":["../src/server.ts"],"sourcesContent":["import {\n buildAuthorizationUrl,\n buildEndSessionUrl,\n calculatePKCECodeChallenge,\n randomNonce,\n randomPKCECodeVerifier,\n randomState,\n refreshTokenGrant,\n authorizationCodeGrant,\n type Configuration,\n} from \"openid-client\";\nimport { claims } from \"./claims\";\nimport { sealedCookie, type SealedCookie } from \"./cookie-session\";\nimport { DEADLINE } from \"./deadline\";\nimport { issuer, type IssuerConfig } from \"./issuer\";\nimport { keyedSingleFlight } from \"./single-flight\";\nimport { statelessStore, type SessionRecord, type SessionStore } from \"./store\";\nimport { AuthError, type AuthErrorCode, type Session, type SignInOptions } from \"./types\";\n\n/**\n * `@kanzo-tech/auth/server` — the confidential OAuth client.\n *\n * This is the server half of the Backend For Frontend, which RFC 10017 calls *\"strongly\n * recommended for business applications, sensitive applications, and applications that handle\n * personal data\"*. The tokens live here and the browser gets a cookie it cannot read.\n *\n * **Nothing in this file implements OAuth.** `openid-client` does the flow, the ID token\n * verification and the end-session URL; `jose` does the sealing. What is written here is the\n * three-line sequence a route handler needs, the cookie discipline around it, and the reading of\n * Keycloak's claims into our one `Session` — which is the only part no library could have.\n *\n * ## The one thing that must never change\n *\n * **This module must not reach React.** It imports its siblings directly — `./claims`, never\n * `./index` — because importing the root barrel would drag React into a Node process. That is not\n * a hypothetical: it is the exact defect that forced `@kanzo-tech/mosaic` out of\n * `@kanzo-tech/ui`, and `server.test.ts` asserts it over source.\n *\n * ## Framework-agnostic on purpose\n *\n * Strings in, strings out: a URL and a `Cookie` header go in, a URL and `Set-Cookie` values come\n * out. `./next` is a thin wrapper over this, and so is anything else — there is no `Request` in\n * the signatures because a `Request` would make Next's flavour of it the one that fits.\n */\n\nconst DEFAULT_SCOPE = \"openid profile email\";\n/** Eight hours: a working day, after which the refresh token is the thing keeping you signed in. */\nconst DEFAULT_MAX_AGE = 8 * 60 * 60;\n/** Ten minutes is long enough to type a password and short enough that an abandoned leg expires. */\nconst TRANSACTION_MAX_AGE = 10 * 60;\n/**\n * Renew an access token with a minute left on it rather than after it dies.\n *\n * The window pays for two things at once: the flight time of the request we are about to send, and\n * the clock skew between this server and the one that will validate the token. A minute covers\n * both on every deployment anyone has run; going to zero means shipping tokens that expire in the\n * air, and going large means renewing constantly on a realm with a five-minute token.\n */\nconst DEFAULT_RENEW_WITHIN = 60;\n\nfunction refuse(code: AuthErrorCode, message: string, cause?: unknown): never {\n throw new AuthError(code, message, {}, cause === undefined ? undefined : { cause });\n}\n\nfunction codeOf(error: unknown): string | undefined {\n if (typeof error !== \"object\" || error === null || !(\"code\" in error)) return undefined;\n const code = (error as { code: unknown }).code;\n return typeof code === \"string\" ? code : undefined;\n}\n\n/**\n * A nonce mismatch, told apart from every other reason a grant can fail.\n *\n * `oauth4webapi` reports every failed claim comparison under one code and names the offending\n * claim on a `cause`, and `openid-client` re-wraps that in a `ClientError` — so the claim's name\n * is two `cause` hops down. Reading it is the only way to answer \"which check failed\", which is\n * the whole point of having codes rather than a 401. The walk is bounded because a cause chain is\n * data from a library, not something to trust to terminate.\n */\nfunction isNonceMismatch(error: unknown): boolean {\n const code = codeOf(error);\n\n // A *wrong* nonce is a claim comparison, and the claim's name is carried structurally.\n if (code === \"OAUTH_JWT_CLAIM_COMPARISON_FAILED\") {\n let node: unknown = error;\n for (let depth = 0; depth < 4 && typeof node === \"object\" && node !== null; depth++) {\n if ((node as { claim?: unknown }).claim === \"nonce\") return true;\n node = (node as { cause?: unknown }).cause;\n }\n return false;\n }\n\n // A *missing* nonce is reported as a malformed response instead, and the claim's name appears\n // only in the message. Matching on a library's prose is brittle, and the answer to that is the\n // test that pins it rather than a quieter code: if `oauth4webapi` rewords this, a test fails\n // here instead of production silently reclassifying a replay as a transport problem.\n return (\n code === \"OAUTH_INVALID_RESPONSE\" &&\n error instanceof Error &&\n error.cause instanceof Error &&\n error.cause.message.includes('\"nonce\"')\n );\n}\n\n/**\n * A failure that looks like the signing keys we hold are no longer the ones Keycloak signs with.\n *\n * Keycloak rotates its realm keys, and a client holding a cached JWKS sees a key id it has never\n * heard of. A resource server answers it the same way: re-fetch the metadata *once, on\n * a failure*, and retry. Refreshing on a timer instead would be a request every few minutes that\n * is wrong exactly when it matters.\n *\n * **This path is only reachable with `verifySignatures`.** Without it no key material is consulted\n * during a code grant at all — the channel vouches for the ID token — so there is nothing to go\n * stale. The Rust needed the retry unconditionally because `openidconnect` verifies the signature\n * either way; that is a difference between the two libraries, not between the two designs.\n */\nfunction isStaleKeyMaterial(error: unknown): boolean {\n return codeOf(error) === \"OAUTH_KEY_SELECTION_FAILED\";\n}\n\n/**\n * Who did not answer, when it was the IdP: `openid-client` reports its own deadline as\n * `OAUTH_TIMEOUT`, a status that is not OAuth's (a 502 from a proxy) as `OAUTH_RESPONSE_IS_NOT_CONFORM`,\n * and a connection that never opened as the platform's uncoded `TypeError`.\n */\nfunction unanswered(error: unknown): \"idp/silent\" | \"idp/unreachable\" | undefined {\n const code = codeOf(error);\n if (code === \"OAUTH_TIMEOUT\") return \"idp/silent\";\n if (code === \"OAUTH_RESPONSE_IS_NOT_CONFORM\") return \"idp/unreachable\";\n if (error instanceof TypeError && code === undefined) return \"idp/unreachable\";\n return undefined;\n}\n\n/**\n * A Keycloak organization alias, or `*`. Anything else is not put into a scope string.\n *\n * `scope` is a **space-delimited list**, so a value with a space in it does not become one scope\n * with a space in it — it becomes two scopes, and the second one is whatever the caller wrote.\n * `?organization=x%20offline_access` reaching `begin` unchecked is an authorization request for\n * `offline_access`, which is a refresh token that outlives the browser session, asked for by\n * whoever composed the link. That is scope injection, and the place to stop it is here rather than\n * at whichever door happened to be the one taking query parameters today.\n *\n * The alphabet is Keycloak's own for an alias — it is a hostname-ish name, and the realm will not\n * mint one outside this set — plus the `*` that asks for every organization at once.\n */\nconst ORGANIZATION = /^(\\*|[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?)$/;\n\n/**\n * One renewal per ticket, for the whole process rather than per `relyingParty`.\n *\n * `singleFlight`'s own header says why a second concurrent renewal is a revoked token chain and\n * not a wasted round trip. What that header does not say is that a server builds more than one\n * relying party: `authRoutes` rebuilds its own when the derived callback URL changes, `authToken`\n * and `authProxy` each hold theirs, and an instance-level slot would let a refresh from the route\n * and a refresh from the proxy replay the same token at the same moment. The ticket names the\n * session, so the ticket is the right key, and it is the same ticket whichever instance holds it.\n *\n * **It is per process.** Two Node instances behind a load balancer can still collide, and the\n * answer to that is a `SessionStore` whose `put` is the point of coordination — not a lock here,\n * which would be a distributed one pretending to be a `Map`.\n */\nconst renewals = keyedSingleFlight<Adopted>();\n\n/** The deployment's store, failing as `session/unavailable` rather than as whatever its driver throws. */\nfunction reachable(store: SessionStore): SessionStore {\n const fail = (error: unknown): never =>\n refuse(\"session/unavailable\", \"the session store did not answer\", error);\n return {\n async put(record) {\n try {\n return await store.put(record);\n } catch (error) {\n return fail(error);\n }\n },\n async get(ticket) {\n try {\n return await store.get(ticket);\n } catch (error) {\n return fail(error);\n }\n },\n async drop(ticket) {\n try {\n await store.drop(ticket);\n } catch (error) {\n fail(error);\n }\n },\n };\n}\n\n/** What `begin` and `end` answer: where to send the browser, and what to set on the way. */\nexport interface Redirect {\n readonly url: string;\n readonly cookies: readonly string[];\n}\n\n/** What `refresh` answers. */\nexport interface Renewed {\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/** What `complete` answers: a renewal, plus where the person was going before they were asked who they are. */\nexport interface SignedIn extends Renewed {\n readonly returnTo: string;\n}\n\n/**\n * What `token` answers: the credential a resource server takes, and what to set on the way out.\n *\n * **`cookies` is not optional to attach.** It is empty when nothing was renewed and carries a\n * rotated session when something was, and under the rotation RFC 10017 requires, dropping it\n * throws away the only refresh token still valid — the session does not go stale, it ends. A\n * caller with nowhere to put a `Set-Cookie` is a caller that must not be asking for this.\n */\nexport interface Token {\n readonly accessToken: string;\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/**\n * Everything a successful grant produced: what the caller is told, and the record behind it.\n *\n * The two are separate and only the first is ever returned from a public method, because a\n * `SessionRecord` holds the refresh token and a `Renewed` is the sort of thing a route handler\n * writes straight into a response body. Structural typing would have let one extra field ride\n * along unnoticed all the way to the browser.\n */\ninterface Adopted {\n readonly renewed: Renewed;\n readonly record: SessionRecord;\n}\n\nexport interface RelyingPartyConfig extends IssuerConfig {\n /** Registered at Keycloak, and where `complete` expects to be called. */\n readonly redirectUri: string;\n /** Seals the cookies. Any length; generate it. See `sealedCookie`. */\n readonly secret: string | Uint8Array;\n /** Default `openid profile email`. A multi-tenant product adds `organization:*`. */\n readonly scope?: string;\n /** Default {@link statelessStore}. Supply one to invalidate a session before it expires. */\n readonly store?: SessionStore;\n /** Session cookie lifetime in seconds. Default eight hours. */\n readonly maxAge?: number;\n /** Where Keycloak sends the browser after sign-out. Must be registered as a post-logout URI. */\n readonly postLogoutRedirectUri?: string;\n}\n\nexport interface RelyingParty {\n /** Leg one: the authorization URL, and the cookie that remembers this attempt. */\n begin(options?: SignInOptions): Promise<Redirect>;\n /** Leg two: the callback URL Keycloak returned to, and the `Cookie` header it arrived with. */\n complete(request: { readonly url: string | URL; readonly cookie: string | null }): Promise<SignedIn>;\n /** The session a request carries, or `null`. The read a route handler does on every request. */\n read(cookie: string | null | undefined): Promise<Session | null>;\n /**\n * The access token a request carries, renewed when it is about to expire — or `null` when there\n * is no session at all.\n *\n * This is the *token-mediating backend*: the browser holds a cookie, the resource server is\n * given a bearer token, and the two never meet. {@link read} is its sibling for identity, and\n * the difference in the signature is the whole of the difference in what they may be called\n * from — this one can answer with a `Set-Cookie` and therefore must be called somewhere that can\n * send one.\n *\n * Renewal is single-flight per ticket, so a page that fires eight requests at an expiring token\n * spends it once.\n */\n token(\n cookie: string | null | undefined,\n options?: { readonly renewWithin?: number },\n ): Promise<Token | null>;\n /**\n * Spend the refresh token, take the new one, and reissue the cookie.\n *\n * Single-flight per ticket across the whole process: a second concurrent call joins the first\n * rather than replaying a token it already spent. See `renewals`.\n */\n refresh(cookie: string | null | undefined): Promise<Renewed>;\n /** RP-initiated logout: forget the record here, clear the cookie, and end it at the IdP too. */\n end(cookie: string | null | undefined, options?: { readonly returnTo?: string }): Promise<Redirect>;\n}\n\n/** Everything either grant returns: one type, because both are answers from the token endpoint. */\ntype Tokens = Awaited<ReturnType<typeof refreshTokenGrant>>;\n\n/** What the session cookie carries: a ticket into the store, and nothing a browser could use. */\ninterface SessionTicket {\n readonly ticket: string;\n}\n\n/** What the transaction cookie carries between the two legs. */\ninterface Transaction {\n readonly state: string;\n readonly nonce: string;\n readonly verifier: string;\n readonly returnTo: string;\n}\n\nexport function relyingParty(config: RelyingPartyConfig): RelyingParty {\n const provider = issuer(config);\n const store = reachable(config.store ?? statelessStore());\n const after = DEADLINE;\n\n /** A failure to reach the IdP, coded by who did not answer; anything else as `fallback`. */\n const fromIdp = (error: unknown, fallback: AuthErrorCode, message: string): AuthError => {\n const code = unanswered(error) ?? fallback;\n return new AuthError(code, message, code === \"idp/silent\" ? { after } : {}, { cause: error });\n };\n\n /** Discovery, failing as the IdP's outage rather than as an uncoded error. */\n const configuration = async (): Promise<Configuration> => {\n try {\n return await provider.configuration();\n } catch (error) {\n throw fromIdp(error, \"idp/unreachable\", \"the IdP's discovery document could not be read\");\n }\n };\n\n const session: SealedCookie<SessionTicket> = sealedCookie({\n name: \"kanzo-session\",\n secret: config.secret,\n maxAge: config.maxAge ?? DEFAULT_MAX_AGE,\n });\n\n // A second cookie rather than a field on the first, because its lifetime is different by two\n // orders of magnitude and it must be gone the moment the callback has used it.\n const transaction: SealedCookie<Transaction> = sealedCookie({\n name: \"kanzo-auth\",\n secret: config.secret,\n maxAge: TRANSACTION_MAX_AGE,\n });\n\n const recordFrom = async (cookie: string | null | undefined): Promise<SessionRecord | null> => {\n const sealed = await session.read(cookie);\n if (sealed === null) return null;\n return store.get(sealed.ticket);\n };\n\n /** Everything a successful grant produces, in the one place both grants can use it. */\n const adopt = async (tokens: Tokens, previous: SessionRecord | null): Promise<Adopted> => {\n // A refresh that returns no new ID token leaves the identity as it was; only the tokens moved.\n const idClaims = tokens.claims();\n const next = idClaims === undefined ? previous?.session : claims(idClaims, config);\n if (next === undefined) {\n refuse(\"token/exchange-failed\", \"the token response carried no ID token, so it names nobody\");\n }\n\n // `expiresIn()` counts down from the moment the response was parsed, which is the only honest\n // reading: the token endpoint says `expires_in`, never an absolute time, because it has no\n // opinion about our clock. Absent, the expiry is unknown rather than zero — a record that\n // claimed to have expired at the epoch would be renewed on every single request.\n const lifetime = tokens.expiresIn();\n\n const record: SessionRecord = {\n session: next,\n accessToken: tokens.access_token,\n accessTokenExpiresAt: lifetime === undefined ? undefined : Date.now() + lifetime * 1000,\n // RFC 10017 requires rotation, so the newly issued token is the only one still valid. An\n // authorization server that did not rotate returns none, and the one we hold stays good.\n refreshToken: tokens.refresh_token ?? previous?.refreshToken,\n idToken: tokens.id_token ?? previous?.idToken,\n };\n\n const ticket = await store.put(record);\n return { renewed: { session: next, cookies: [await session.seal({ ticket })] }, record };\n };\n\n /** The renewal both `refresh` and `token` run, with the record they each need a different half of. */\n const renew = async (cookie: string | null | undefined): Promise<Adopted> => {\n const sealed = await session.read(cookie);\n const record = sealed === null ? null : await store.get(sealed.ticket);\n if (sealed === null || record === null) {\n refuse(\"session/absent\", \"there is no session cookie to refresh\");\n }\n const spent = record.refreshToken;\n if (spent === undefined) {\n refuse(\"session/absent\", \"the session holds no refresh token, so it cannot be renewed\");\n }\n\n // Everything above is a read and may run concurrently; everything below spends a token that can\n // only be spent once, so it is the half behind the slot. A caller that joins gets the cookie\n // the first one was issued, which is the cookie it would have been issued anyway.\n return renewals(sealed.ticket, async () => {\n let tokens: Tokens;\n try {\n tokens = await refreshTokenGrant(await configuration(), spent);\n } catch (error) {\n if (error instanceof AuthError) throw error;\n // Under rotation a refused refresh is often a *replayed* token rather than an expired one,\n // and the authorization server may have revoked the whole chain. Either way the session is\n // over; the slot above exists to keep us from causing it. An IdP that did not answer has\n // refused nothing, and says so.\n throw fromIdp(error, \"token/exchange-failed\", \"the refresh token was refused\");\n }\n\n // The superseded ticket goes first: a store that enforces one live session per person must\n // not briefly hold two, and for the stateless default this is a no-op.\n await store.drop(sealed.ticket);\n return adopt(tokens, record);\n });\n };\n\n return {\n async begin(options = {}) {\n const discovered = await configuration();\n\n const verifier = randomPKCECodeVerifier();\n const state = randomState();\n const nonce = randomNonce();\n\n if (options.organization !== undefined && !ORGANIZATION.test(options.organization)) {\n refuse(\n \"organization/invalid\",\n `\\`${options.organization}\\` is not an organization alias, and a scope is a space-delimited list: see ORGANIZATION`,\n );\n }\n\n const parameters: Record<string, string> = {\n redirect_uri: config.redirectUri,\n // `organization:<alias>` asks Keycloak for one; a product with many asks for\n // `organization:*` through `scope`, because plain `organization` prompts for a choice.\n scope:\n options.organization === undefined\n ? (config.scope ?? DEFAULT_SCOPE)\n : `${config.scope ?? DEFAULT_SCOPE} organization:${options.organization}`,\n code_challenge: await calculatePKCECodeChallenge(verifier),\n code_challenge_method: \"S256\",\n state,\n nonce,\n };\n\n return {\n url: buildAuthorizationUrl(discovered, parameters).href,\n cookies: [\n await transaction.seal({ state, nonce, verifier, returnTo: options.returnTo ?? \"/\" }),\n ],\n };\n },\n\n async complete(request) {\n const pending = await transaction.read(request.cookie);\n if (pending === null) {\n refuse(\n \"callback/state-mismatch\",\n \"the callback arrived with no transaction cookie, so there is nothing to match its `state` against\",\n );\n }\n\n const current = new URL(request.url);\n if (current.searchParams.get(\"state\") !== pending.state) {\n refuse(\n \"callback/state-mismatch\",\n \"the callback's `state` is not the one this browser was sent with\",\n );\n }\n\n const checks = {\n pkceCodeVerifier: pending.verifier,\n expectedState: pending.state,\n expectedNonce: pending.nonce,\n };\n\n const grant = (configuration: Configuration) =>\n authorizationCodeGrant(configuration, current, checks);\n\n let tokens: Tokens;\n try {\n tokens = await grant(await configuration());\n } catch (error) {\n if (error instanceof AuthError) throw error;\n if (isNonceMismatch(error)) {\n refuse(\n \"callback/nonce-mismatch\",\n \"the ID token's `nonce` is not the one this transaction sent\",\n error,\n );\n }\n if (!isStaleKeyMaterial(error)) {\n throw fromIdp(error, \"token/exchange-failed\", \"the authorization code could not be exchanged\");\n }\n // Keycloak rotated its signing key. One re-discovery, one retry, then give up — a loop\n // here is a self-inflicted denial of service against the identity provider.\n try {\n tokens = await grant(await provider.rediscover());\n } catch (retried) {\n if (isNonceMismatch(retried)) {\n refuse(\n \"callback/nonce-mismatch\",\n \"the ID token's `nonce` is not the one this transaction sent\",\n retried,\n );\n }\n throw fromIdp(\n retried,\n \"token/exchange-failed\",\n \"the ID token did not verify, and did not verify against freshly discovered keys either\",\n );\n }\n }\n\n // Session fixation: the record is new, the ticket is new and the cookie is new, and any\n // session cookie this callback happened to arrive with is not read. A server-side session\n // store calls `cycle_id()` here for the same reason — an attacker who planted a session before sign-in\n // must not find themselves holding the one that sign-in produced.\n const { renewed } = await adopt(tokens, null);\n\n return {\n ...renewed,\n cookies: [...renewed.cookies, transaction.clear()],\n returnTo: pending.returnTo,\n };\n },\n\n async read(cookie) {\n return (await recordFrom(cookie))?.session ?? null;\n },\n\n async token(cookie, options = {}) {\n const record = await recordFrom(cookie);\n if (record === null) return null;\n\n const within = (options.renewWithin ?? DEFAULT_RENEW_WITHIN) * 1000;\n const held = record.accessToken;\n // An unknown expiry is not treated as expired: a realm that omits `expires_in` would\n // otherwise be renewed on every request, which is the replay this package exists to avoid.\n const stale =\n record.accessTokenExpiresAt !== undefined &&\n record.accessTokenExpiresAt - Date.now() <= within;\n\n if (held !== undefined && !stale) {\n return { accessToken: held, session: record.session, cookies: [] };\n }\n\n const { renewed, record: fresh } = await renew(cookie);\n if (fresh.accessToken === undefined) {\n refuse(\"token/exchange-failed\", \"the token response carried no access token\");\n }\n return { accessToken: fresh.accessToken, session: renewed.session, cookies: renewed.cookies };\n },\n\n async refresh(cookie) {\n return (await renew(cookie)).renewed;\n },\n\n async end(cookie, options = {}) {\n const sealed = await session.read(cookie);\n const record = sealed === null ? null : await store.get(sealed.ticket);\n if (sealed !== null) await store.drop(sealed.ticket);\n\n const parameters: Record<string, string> = {};\n const returnTo = options.returnTo ?? config.postLogoutRedirectUri;\n if (returnTo !== undefined) parameters[\"post_logout_redirect_uri\"] = returnTo;\n // Without the hint Keycloak cannot tell which session is ending and asks the person to\n // confirm — which reads as a bug to everyone who sees it.\n if (record?.idToken !== undefined) parameters[\"id_token_hint\"] = record.idToken;\n\n // `buildEndSessionUrl` rather than a hand-built URL: the endpoint comes from discovery, and\n // the parameter names are the specification's rather than ours to remember.\n return {\n url: buildEndSessionUrl(await configuration(), parameters).href,\n cookies: [session.clear()],\n };\n },\n };\n}\n\nexport { issuer, rewriteOrigin, type Issuer, type IssuerConfig } from \"./issuer\";\nexport { sealedCookie, cookieValue, type SealedCookie, type SealedCookieConfig } from \"./cookie-session\";\nexport {\n statelessStore,\n ticketStore,\n type SessionRecord,\n type SessionStore,\n type TicketAdapter,\n type TicketStoreConfig,\n} from \"./store\";\n"],"names":["DEFAULT_SCOPE","DEFAULT_MAX_AGE","TRANSACTION_MAX_AGE","DEFAULT_RENEW_WITHIN","refuse","code","message","cause","AuthError","codeOf","error","isNonceMismatch","node","depth","isStaleKeyMaterial","unanswered","ORGANIZATION","renewals","keyedSingleFlight","reachable","store","fail","record","ticket","relyingParty","config","provider","issuer","statelessStore","after","DEADLINE","fromIdp","fallback","configuration","session","sealedCookie","transaction","recordFrom","cookie","sealed","adopt","tokens","previous","idClaims","next","claims","lifetime","renew","spent","refreshTokenGrant","options","discovered","verifier","randomPKCECodeVerifier","state","randomState","nonce","randomNonce","parameters","calculatePKCECodeChallenge","buildAuthorizationUrl","request","pending","current","checks","grant","authorizationCodeGrant","retried","renewed","_a","within","held","stale","fresh","returnTo","buildEndSessionUrl"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"server.js","sources":["../src/server.ts"],"sourcesContent":["import {\n buildAuthorizationUrl,\n buildEndSessionUrl,\n calculatePKCECodeChallenge,\n randomNonce,\n randomPKCECodeVerifier,\n randomState,\n refreshTokenGrant,\n authorizationCodeGrant,\n type Configuration,\n} from \"openid-client\";\nimport { jwtVerify, type JWTPayload } from \"jose\";\nimport { claims } from \"./claims\";\nimport { sealedCookie, type SealedCookie } from \"./cookie-session\";\nimport { DEADLINE } from \"./deadline\";\nimport { issuer, type IssuerConfig } from \"./issuer\";\nimport { keyedSingleFlight } from \"./single-flight\";\nimport { statelessStore, type SessionRecord, type SessionStore } from \"./store\";\nimport { AuthError, type AuthErrorCode, type Session, type SignInOptions } from \"./types\";\n\n/**\n * `@kanzo-tech/auth/server` — the confidential OAuth client.\n *\n * This is the server half of the Backend For Frontend, which RFC 10017 calls *\"strongly\n * recommended for business applications, sensitive applications, and applications that handle\n * personal data\"*. The tokens live here and the browser gets a cookie it cannot read.\n *\n * **Nothing in this file implements OAuth.** `openid-client` does the flow, the ID token\n * verification and the end-session URL; `jose` does the sealing. What is written here is the\n * three-line sequence a route handler needs, the cookie discipline around it, and the reading of\n * Keycloak's claims into our one `Session` — which is the only part no library could have.\n *\n * ## The one thing that must never change\n *\n * **This module must not reach React.** It imports its siblings directly — `./claims`, never\n * `./index` — because importing the root barrel would drag React into a Node process. That is not\n * a hypothetical: it is the exact defect that forced `@kanzo-tech/mosaic` out of\n * `@kanzo-tech/ui`, and `server.test.ts` asserts it over source.\n *\n * ## Framework-agnostic on purpose\n *\n * Strings in, strings out: a URL and a `Cookie` header go in, a URL and `Set-Cookie` values come\n * out. `./next` is a thin wrapper over this, and so is anything else — there is no `Request` in\n * the signatures because a `Request` would make Next's flavour of it the one that fits.\n */\n\n/**\n * `organization:*` is always asked for: membership of every organization arrives in one token, and\n * which one a request is *in* is the product's resolver's answer, per request. Plain\n * `organization` would make Keycloak prompt for a choice at sign-in instead.\n */\nconst DEFAULT_SCOPE = \"openid profile email organization:*\";\n/** Eight hours: a working day, after which the refresh token is the thing keeping you signed in. */\nconst DEFAULT_MAX_AGE = 8 * 60 * 60;\n/** Ten minutes is long enough to type a password and short enough that an abandoned leg expires. */\nconst TRANSACTION_MAX_AGE = 10 * 60;\n/**\n * What `randomState()` mints — 43 characters of base64url — with room for another client's. The\n * callback's `state` names a cookie, so it is checked before it is spelled into one.\n */\nconst STATE = /^[A-Za-z0-9_-]{16,128}$/;\n/** The event a logout token carries, OpenID Connect Back-Channel Logout 1.0 §2.4. */\nconst BACKCHANNEL_EVENT = \"http://schemas.openid.net/event/backchannel-logout\";\n/**\n * Renew an access token with a minute left on it rather than after it dies.\n *\n * The window pays for two things at once: the flight time of the request we are about to send, and\n * the clock skew between this server and the one that will validate the token. A minute covers\n * both on every deployment anyone has run; going to zero means shipping tokens that expire in the\n * air, and going large means renewing constantly on a realm with a five-minute token.\n */\nconst DEFAULT_RENEW_WITHIN = 60;\n\nfunction refuse(code: AuthErrorCode, message: string, cause?: unknown): never {\n throw new AuthError(code, message, {}, cause === undefined ? undefined : { cause });\n}\n\nfunction codeOf(error: unknown): string | undefined {\n if (typeof error !== \"object\" || error === null || !(\"code\" in error)) return undefined;\n const code = (error as { code: unknown }).code;\n return typeof code === \"string\" ? code : undefined;\n}\n\n/**\n * A nonce mismatch, told apart from every other reason a grant can fail.\n *\n * `oauth4webapi` reports every failed claim comparison under one code and names the offending\n * claim on a `cause`, and `openid-client` re-wraps that in a `ClientError` — so the claim's name\n * is two `cause` hops down. Reading it is the only way to answer \"which check failed\", which is\n * the whole point of having codes rather than a 401. The walk is bounded because a cause chain is\n * data from a library, not something to trust to terminate.\n */\nfunction isNonceMismatch(error: unknown): boolean {\n const code = codeOf(error);\n\n // A *wrong* nonce is a claim comparison, and the claim's name is carried structurally.\n if (code === \"OAUTH_JWT_CLAIM_COMPARISON_FAILED\") {\n let node: unknown = error;\n for (let depth = 0; depth < 4 && typeof node === \"object\" && node !== null; depth++) {\n if ((node as { claim?: unknown }).claim === \"nonce\") return true;\n node = (node as { cause?: unknown }).cause;\n }\n return false;\n }\n\n // A *missing* nonce is reported as a malformed response instead, and the claim's name appears\n // only in the message. Matching on a library's prose is brittle, and the answer to that is the\n // test that pins it rather than a quieter code: if `oauth4webapi` rewords this, a test fails\n // here instead of production silently reclassifying a replay as a transport problem.\n return (\n code === \"OAUTH_INVALID_RESPONSE\" &&\n error instanceof Error &&\n error.cause instanceof Error &&\n error.cause.message.includes('\"nonce\"')\n );\n}\n\n/**\n * A failure that looks like the signing keys we hold are no longer the ones Keycloak signs with.\n *\n * Keycloak rotates its realm keys, and a client holding a cached JWKS sees a key id it has never\n * heard of. A resource server answers it the same way: re-fetch the metadata *once, on\n * a failure*, and retry. Refreshing on a timer instead would be a request every few minutes that\n * is wrong exactly when it matters.\n *\n * **This path is only reachable with `verifySignatures`.** Without it no key material is consulted\n * during a code grant at all — the channel vouches for the ID token — so there is nothing to go\n * stale. The Rust needed the retry unconditionally because `openidconnect` verifies the signature\n * either way; that is a difference between the two libraries, not between the two designs.\n */\nfunction isStaleKeyMaterial(error: unknown): boolean {\n return codeOf(error) === \"OAUTH_KEY_SELECTION_FAILED\";\n}\n\n/**\n * Who did not answer, when it was the IdP: `openid-client` reports its own deadline as\n * `OAUTH_TIMEOUT`, a status that is not OAuth's (a 502 from a proxy) as `OAUTH_RESPONSE_IS_NOT_CONFORM`,\n * and a connection that never opened as the platform's uncoded `TypeError`. `jose`, fetching the\n * keys a logout token is checked against, reports its deadline as `ERR_JWKS_TIMEOUT` and a key set\n * that is not one as `ERR_JWKS_INVALID`, or as its generic error for a status that is not 200.\n */\nfunction unanswered(error: unknown): \"idp/silent\" | \"idp/unreachable\" | undefined {\n const code = codeOf(error);\n if (code === \"OAUTH_TIMEOUT\" || code === \"ERR_JWKS_TIMEOUT\") return \"idp/silent\";\n if (\n code === \"OAUTH_RESPONSE_IS_NOT_CONFORM\" ||\n code === \"ERR_JWKS_INVALID\" ||\n code === \"ERR_JOSE_GENERIC\"\n ) {\n return \"idp/unreachable\";\n }\n if (error instanceof TypeError && code === undefined) return \"idp/unreachable\";\n return undefined;\n}\n\n/**\n * The token endpoint's `invalid_grant` for a refresh token: Keycloak's answer once the SSO session\n * behind it has gone idle, been ended, or the token was already rotated by someone else.\n * `openid-client` carries the OAuth error on the `error` field of its `ResponseBodyError`.\n */\nfunction isInvalidGrant(error: unknown): boolean {\n return (\n typeof error === \"object\" &&\n error !== null &&\n (error as { error?: unknown }).error === \"invalid_grant\"\n );\n}\n\n/**\n * The scope a sign-in asks for: the configured one with its organization scope replaced by\n * `organization:<alias>` when one is named, and by `organization:*` otherwise. Exactly one\n * organization scope, because Keycloak gives no promise about which of two would win.\n */\nfunction scopeFor(configured: string | undefined, organization: string | undefined): string {\n const others = (configured ?? DEFAULT_SCOPE)\n .split(/\\s+/)\n .filter((scope) => scope !== \"\" && scope !== \"organization\" && !scope.startsWith(\"organization:\"));\n return [...others, `organization:${organization ?? \"*\"}`].join(\" \");\n}\n\n/**\n * A Keycloak organization alias, or `*`. Anything else is not put into a scope string.\n *\n * `scope` is a **space-delimited list**, so a value with a space in it does not become one scope\n * with a space in it — it becomes two scopes, and the second one is whatever the caller wrote.\n * `?organization=x%20offline_access` reaching `begin` unchecked is an authorization request for\n * `offline_access`, which is a refresh token that outlives the browser session, asked for by\n * whoever composed the link. That is scope injection, and the place to stop it is here rather than\n * at whichever door happened to be the one taking query parameters today.\n *\n * The alphabet is Keycloak's own for an alias — it is a hostname-ish name, and the realm will not\n * mint one outside this set — plus the `*` that asks for every organization at once.\n */\nconst ORGANIZATION = /^(\\*|[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?)$/;\n\n/**\n * One renewal per ticket, for the whole process rather than per `relyingParty`.\n *\n * `singleFlight`'s own header says why a second concurrent renewal is a revoked token chain and\n * not a wasted round trip. The ticket names the session and no longer changes when the session is\n * renewed, so it is the right key: the proxy, the API forwarder and the refresh route all renew the\n * same session under the same name, and whichever asks second joins the first.\n *\n * **It is per process.** Two Node instances behind a load balancer can still both spend the same\n * refresh token, and the loser is told `invalid_grant`. That is answered in `renew` by reading the\n * record again — the winner has already written the rotated token under the same ticket — rather\n * than by a lock here, which would be a distributed one pretending to be a `Map`.\n */\nconst renewals = keyedSingleFlight<Adopted | Ended>();\n\n/** The deployment's store, failing as `session/unavailable` rather than as whatever its driver throws. */\nfunction reachable(store: SessionStore): SessionStore {\n const guard =\n <A extends unknown[], R>(call: (...args: A) => Promise<R>) =>\n async (...args: A): Promise<R> => {\n try {\n return await call(...args);\n } catch (error) {\n // A store that cannot revoke answered, and its answer is the point; it is not an outage.\n if (error instanceof AuthError && error.code === \"session/irrevocable\") throw error;\n return refuse(\"session/unavailable\", \"the session store did not answer\", error);\n }\n };\n return {\n put: guard((record) => store.put(record)),\n update: guard((ticket, record) => store.update(ticket, record)),\n get: guard((ticket) => store.get(ticket)),\n drop: guard((ticket) => store.drop(ticket)),\n dropAll: guard((subject) => store.dropAll(subject)),\n };\n}\n\n/** What `begin` and `end` answer: where to send the browser, and what to set on the way. */\nexport interface Redirect {\n readonly url: string;\n readonly cookies: readonly string[];\n}\n\n/**\n * A live session after `refresh`: renewed, or still good. `cookies` is empty unless the ticket\n * itself changed, which only a stateless store's re-seal does.\n */\nexport interface Renewed {\n readonly ended: false;\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/**\n * No live session: none was presented, the store no longer knows it, or the IdP refused to renew\n * it. The ticket has been dropped, and `cookies` clears the one the browser holds — empty when it\n * held none.\n *\n * A result rather than an exception, because the cookies are the point: an ended session that\n * forgets to clear its cookie is the zombie this replaced, a page drawn for a session the IdP had\n * already closed.\n */\nexport interface Ended {\n readonly ended: true;\n /** `token/refused` when the IdP refused to renew it; `session/absent` when there was none to renew. */\n readonly code: \"session/absent\" | \"token/refused\";\n readonly cookies: readonly string[];\n}\n\n/** What `complete` answers: the new session, its cookies, and where the person was going. */\nexport interface SignedIn {\n readonly session: Session;\n readonly cookies: readonly string[];\n readonly returnTo: string;\n}\n\n/** What `token` answers for a live session: the credential a resource server takes, and what to set. */\nexport interface Token extends Renewed {\n readonly accessToken: string;\n}\n\n/**\n * Everything a successful renewal produced: what the caller is told, and the record behind it.\n *\n * The two are separate and only the first is ever returned from a public method, because a\n * `SessionRecord` holds the refresh token and a `Renewed` is the sort of thing a route handler\n * writes straight into a response body. Structural typing would have let one extra field ride\n * along unnoticed all the way to the browser.\n */\ninterface Adopted {\n readonly renewed: Renewed;\n readonly record: SessionRecord;\n}\n\nexport interface RelyingPartyConfig extends IssuerConfig {\n /** Seals the cookies. Any length; generate it. See `sealedCookie`. */\n readonly secret: string | Uint8Array;\n /**\n * Default `openid profile email organization:*`. Whatever is given, its organization scope is\n * `organization:*` — or the one alias a sign-in names.\n */\n readonly scope?: string;\n /** Default {@link statelessStore}. Supply a `ticketStore` to end a session before it expires. */\n readonly store?: SessionStore;\n /** Session cookie lifetime in seconds. Default eight hours. */\n readonly maxAge?: number;\n /** Where Keycloak sends the browser after sign-out. Must be registered as a post-logout URI. */\n readonly postLogoutRedirectUri?: string;\n}\n\nexport interface RelyingParty {\n /**\n * Leg one: the authorization URL, and the cookie that remembers this attempt.\n *\n * `redirectUri` is per call, because it is derived from the request that asked, and one relying\n * party serves every origin a deployment answers on.\n */\n begin(options: SignInOptions & { readonly redirectUri: string }): Promise<Redirect>;\n /**\n * Leg two: the callback URL Keycloak returned to, the `Cookie` header it arrived with, and the\n * `redirectUri` `begin` was given — which the token request must repeat exactly.\n */\n complete(request: {\n readonly url: string | URL;\n readonly cookie: string | null;\n readonly redirectUri: string;\n }): Promise<SignedIn>;\n /** The session a request carries, or `null`. One store read; nothing is renewed. */\n read(cookie: string | null | undefined): Promise<Session | null>;\n /**\n * The access token a request carries, renewed in place when it is within `renewWithin` seconds\n * of expiry (default 60) — or {@link Ended} when there is no live session.\n *\n * This is the *token-mediating backend*: the browser holds a cookie, the resource server is\n * given a bearer token, and the two never meet. Renewal is single-flight per ticket, so a page\n * that fires eight requests at an expiring token spends it once.\n */\n token(\n cookie: string | null | undefined,\n options?: { readonly renewWithin?: number },\n ): Promise<Token | Ended>;\n /** Spend the refresh token now, whatever the access token's expiry. See {@link token}. */\n refresh(cookie: string | null | undefined): Promise<Renewed | Ended>;\n /** RP-initiated logout: forget the record here, clear the cookie, and end it at the IdP too. */\n end(cookie: string | null | undefined, options?: { readonly returnTo?: string }): Promise<Redirect>;\n /**\n * Back-channel logout, OpenID Connect Back-Channel Logout 1.0 §2.6: verify the logout token\n * Keycloak posted, and drop every session it names.\n *\n * Rejects with `token/refused` for a token that does not verify, and `session/irrevocable` for a\n * store that cannot end a session from here.\n */\n logout(logoutToken: string): Promise<void>;\n}\n\n/** Everything either grant returns: one type, because both are answers from the token endpoint. */\ntype Tokens = Awaited<ReturnType<typeof refreshTokenGrant>>;\n\n/** What the session cookie carries: a ticket into the store, and nothing a browser could use. */\ninterface SessionTicket {\n readonly ticket: string;\n}\n\n/** What a transaction cookie carries between the two legs. */\ninterface Transaction {\n readonly state: string;\n readonly nonce: string;\n readonly verifier: string;\n readonly returnTo: string;\n}\n\nexport function relyingParty(config: RelyingPartyConfig): RelyingParty {\n const provider = issuer(config);\n const store = reachable(config.store ?? statelessStore());\n const after = DEADLINE;\n\n /** A failure to reach the IdP, coded by who did not answer; anything else as `fallback`. */\n const fromIdp = (error: unknown, fallback: AuthErrorCode, message: string): AuthError => {\n const code = unanswered(error) ?? fallback;\n return new AuthError(code, message, code === \"idp/silent\" ? { after } : {}, { cause: error });\n };\n\n /** Discovery, failing as the IdP's outage rather than as an uncoded error. */\n const configuration = async (): Promise<Configuration> => {\n try {\n return await provider.configuration();\n } catch (error) {\n throw fromIdp(error, \"idp/unreachable\", \"the IdP's discovery document could not be read\");\n }\n };\n\n const session: SealedCookie<SessionTicket> = sealedCookie({\n name: \"kanzo-session\",\n secret: config.secret,\n maxAge: config.maxAge ?? DEFAULT_MAX_AGE,\n });\n\n /**\n * One cookie per attempt, named by its `state` — Auth0's `__txn_{state}`. A single transaction\n * cookie is overwritten by the second tab that starts signing in, and the first tab's callback\n * then finds someone else's attempt and fails; per-state cookies let both finish. Each lives ten\n * minutes and is cleared by the callback that spends it.\n */\n const transaction = (state: string): SealedCookie<Transaction> =>\n sealedCookie({ name: `kanzo-auth.${state}`, secret: config.secret, maxAge: TRANSACTION_MAX_AGE });\n\n /** The ticket a request's cookie carries and the record behind it, or `null` for no cookie. */\n const opened = async (\n cookie: string | null | undefined,\n ): Promise<{ readonly ticket: string; readonly record: SessionRecord | null } | null> => {\n const sealed = await session.read(cookie);\n if (sealed === null) return null;\n return { ticket: sealed.ticket, record: await store.get(sealed.ticket) };\n };\n\n const ended = async (\n ticket: string | undefined,\n code: Ended[\"code\"] = \"session/absent\",\n ): Promise<Ended> => {\n if (ticket !== undefined) await store.drop(ticket);\n return { ended: true, code, cookies: ticket === undefined ? [] : [session.clear()] };\n };\n\n /** What a grant produced, as the record to keep: the identity, the tokens, and the IdP session. */\n const adopt = (tokens: Tokens, previous: SessionRecord | null): SessionRecord => {\n // A refresh that returns no new ID token leaves the identity as it was; only the tokens moved.\n const idClaims = tokens.claims();\n const identity = idClaims === undefined ? previous?.session : claims(idClaims, config);\n if (identity === undefined) {\n refuse(\"token/exchange-failed\", \"the token response carried no ID token, so it names nobody\");\n }\n\n // `expiresIn()` counts down from the moment the response was parsed, which is the only honest\n // reading: the token endpoint says `expires_in`, never an absolute time, because it has no\n // opinion about our clock. Absent, the expiry is unknown rather than zero — a record that\n // claimed to have expired at the epoch would be renewed on every single request.\n const lifetime = tokens.expiresIn();\n const accessTokenExpiresAt = lifetime === undefined ? undefined : Date.now() + lifetime * 1000;\n const sid = typeof idClaims?.[\"sid\"] === \"string\" ? idClaims[\"sid\"] : previous?.sid;\n\n return {\n session: { ...identity, expiresAt: accessTokenExpiresAt ?? identity.expiresAt },\n sid,\n accessToken: tokens.access_token,\n accessTokenExpiresAt,\n // RFC 10017 requires rotation, so the newly issued token is the only one still valid. An\n // authorization server that did not rotate returns none, and the one we hold stays good.\n refreshToken: tokens.refresh_token ?? previous?.refreshToken,\n idToken: tokens.id_token ?? previous?.idToken,\n };\n };\n\n /**\n * Spend the refresh token and write what comes back under the same ticket.\n *\n * Duende BFF's shape: the server-side session is updated in place, so the cookie naming it does\n * not change and a renewal in the proxy has nothing to hand the browser. A refusal is the end of\n * the session — unless the refusal is because another process got there first, which the\n * record, re-read once, says.\n */\n const renew = (ticket: string, record: SessionRecord): Promise<Adopted | Ended> =>\n renewals(ticket, async () => {\n const spent = record.refreshToken;\n if (spent === undefined) return ended(ticket);\n\n let tokens: Tokens;\n try {\n tokens = await refreshTokenGrant(await configuration(), spent);\n } catch (error) {\n if (error instanceof AuthError) throw error;\n // An IdP that did not answer has refused nothing, and a token endpoint that refused for any\n // reason but the grant is a deployment fault: neither ends the session.\n if (unanswered(error) !== undefined || !isInvalidGrant(error)) {\n throw fromIdp(error, \"token/exchange-failed\", \"the token endpoint did not renew the session\");\n }\n const current = await store.get(ticket);\n if (current?.refreshToken !== undefined && current.refreshToken !== spent) {\n return { record: current, renewed: { ended: false, session: current.session, cookies: [] } };\n }\n return ended(ticket, \"token/refused\");\n }\n\n const fresh = adopt(tokens, record);\n const next = await store.update(ticket, fresh);\n // The row went while the grant was in flight: a back-channel logout ended this session, and\n // the tokens just issued are for nobody.\n if (next === null) return ended(ticket);\n return {\n record: fresh,\n renewed: {\n ended: false,\n session: fresh.session,\n cookies: next === ticket ? [] : [await session.seal({ ticket: next })],\n },\n };\n });\n\n /** A logout token's verification failure, as the IdP's outage or as a refused token. */\n const fromLogout = (error: unknown): AuthError =>\n error instanceof AuthError\n ? error\n : fromIdp(error, \"token/refused\", \"the logout token did not verify\");\n\n return {\n async begin(options) {\n const discovered = await configuration();\n\n const verifier = randomPKCECodeVerifier();\n const state = randomState();\n const nonce = randomNonce();\n\n if (options.organization !== undefined && !ORGANIZATION.test(options.organization)) {\n refuse(\n \"organization/invalid\",\n `\\`${options.organization}\\` is not an organization alias, and a scope is a space-delimited list: see ORGANIZATION`,\n );\n }\n\n const parameters: Record<string, string> = {\n redirect_uri: options.redirectUri,\n scope: scopeFor(config.scope, options.organization),\n code_challenge: await calculatePKCECodeChallenge(verifier),\n code_challenge_method: \"S256\",\n state,\n nonce,\n };\n\n return {\n url: buildAuthorizationUrl(discovered, parameters).href,\n cookies: [\n await transaction(state).seal({ state, nonce, verifier, returnTo: options.returnTo ?? \"/\" }),\n ],\n };\n },\n\n async complete(request) {\n const current = new URL(request.url);\n const state = current.searchParams.get(\"state\");\n if (state === null || !STATE.test(state)) {\n refuse(\"callback/state-mismatch\", \"the callback carries no `state` this client could have sent\");\n }\n\n const spent = transaction(state);\n const pending = await spent.read(request.cookie);\n if (pending === null || pending.state !== state) {\n refuse(\n \"callback/state-mismatch\",\n \"the callback's `state` names no transaction this browser started\",\n );\n }\n\n const checks = {\n pkceCodeVerifier: pending.verifier,\n expectedState: pending.state,\n expectedNonce: pending.nonce,\n };\n\n // The token request repeats the `redirect_uri` the authorization request sent (RFC 6749\n // §4.1.3), and `openid-client` reads it off the URL it is handed — so the callback's own\n // parameters go onto that URI, whatever host the request happened to arrive under.\n const callback = new URL(request.redirectUri);\n callback.search = current.search;\n\n const grant = (configuration: Configuration) =>\n authorizationCodeGrant(configuration, callback, checks);\n\n let tokens: Tokens;\n try {\n tokens = await grant(await configuration());\n } catch (error) {\n if (error instanceof AuthError) throw error;\n if (isNonceMismatch(error)) {\n refuse(\n \"callback/nonce-mismatch\",\n \"the ID token's `nonce` is not the one this transaction sent\",\n error,\n );\n }\n if (!isStaleKeyMaterial(error)) {\n throw fromIdp(error, \"token/exchange-failed\", \"the authorization code could not be exchanged\");\n }\n // Keycloak rotated its signing key. One re-discovery, one retry, then give up — a loop\n // here is a self-inflicted denial of service against the identity provider.\n try {\n tokens = await grant(await provider.rediscover());\n } catch (retried) {\n if (isNonceMismatch(retried)) {\n refuse(\n \"callback/nonce-mismatch\",\n \"the ID token's `nonce` is not the one this transaction sent\",\n retried,\n );\n }\n throw fromIdp(\n retried,\n \"token/exchange-failed\",\n \"the ID token did not verify, and did not verify against freshly discovered keys either\",\n );\n }\n }\n\n // Session fixation: the record is new, the ticket is new and the cookie is new, and any\n // session cookie this callback happened to arrive with is not read. This is the only place a\n // ticket is issued; every renewal after it keeps this one.\n const record = adopt(tokens, null);\n const ticket = await store.put(record);\n\n return {\n session: record.session,\n cookies: [await session.seal({ ticket }), spent.clear()],\n returnTo: pending.returnTo,\n };\n },\n\n async read(cookie) {\n return (await opened(cookie))?.record?.session ?? null;\n },\n\n async token(cookie, options = {}) {\n const found = await opened(cookie);\n if (found?.record == null) return ended(found?.ticket);\n const { ticket, record } = found;\n\n const within = (options.renewWithin ?? DEFAULT_RENEW_WITHIN) * 1000;\n // An unknown expiry is not treated as expired: a realm that omits `expires_in` would\n // otherwise be renewed on every request, which is the replay this package exists to avoid.\n const stale =\n record.accessTokenExpiresAt !== undefined &&\n record.accessTokenExpiresAt - Date.now() <= within;\n\n if (record.accessToken !== undefined && !stale) {\n return { ended: false, accessToken: record.accessToken, session: record.session, cookies: [] };\n }\n\n const outcome = await renew(ticket, record);\n if (\"ended\" in outcome) return outcome;\n if (outcome.record.accessToken === undefined) {\n refuse(\"token/exchange-failed\", \"the token response carried no access token\");\n }\n return { ...outcome.renewed, accessToken: outcome.record.accessToken };\n },\n\n async refresh(cookie) {\n const found = await opened(cookie);\n if (found?.record == null) return ended(found?.ticket);\n const outcome = await renew(found.ticket, found.record);\n return \"ended\" in outcome ? outcome : outcome.renewed;\n },\n\n async end(cookie, options = {}) {\n const found = await opened(cookie);\n if (found !== null) await store.drop(found.ticket);\n\n const parameters: Record<string, string> = {};\n const returnTo = options.returnTo ?? config.postLogoutRedirectUri;\n if (returnTo !== undefined) parameters[\"post_logout_redirect_uri\"] = returnTo;\n // Without the hint Keycloak cannot tell which session is ending and asks the person to\n // confirm — which reads as a bug to everyone who sees it.\n if (found?.record?.idToken !== undefined) parameters[\"id_token_hint\"] = found.record.idToken;\n\n // `buildEndSessionUrl` rather than a hand-built URL: the endpoint comes from discovery, and\n // the parameter names are the specification's rather than ours to remember.\n return {\n url: buildEndSessionUrl(await configuration(), parameters).href,\n cookies: [session.clear()],\n };\n },\n\n async logout(logoutToken) {\n let payload: JWTPayload;\n try {\n // `jose` checks the signature against the realm's published keys, `iss`, `aud` and the\n // presence of `iat`, and `exp` when the token carries one. An unknown `kid` re-fetches the\n // key set once per cooldown, which is how a rotation is survived here.\n ({ payload } = await jwtVerify(logoutToken, await provider.keys(), {\n issuer: config.issuer,\n audience: config.clientId,\n requiredClaims: [\"iat\"],\n }));\n } catch (error) {\n throw fromLogout(error);\n }\n\n // §2.6 steps 4–6: the event is present, there is no `nonce` — which is what keeps an ID token\n // from being replayed as a logout token — and the token names a subject, a session, or both.\n const events = payload[\"events\"];\n const event =\n typeof events === \"object\" && events !== null\n ? (events as Record<string, unknown>)[BACKCHANNEL_EVENT]\n : undefined;\n if (typeof event !== \"object\" || event === null) {\n refuse(\"token/refused\", \"the logout token does not carry the back-channel logout event\");\n }\n if (\"nonce\" in payload) {\n refuse(\"token/refused\", \"a logout token must not carry a `nonce`\");\n }\n const sub = typeof payload.sub === \"string\" ? payload.sub : undefined;\n const sid = typeof payload[\"sid\"] === \"string\" ? payload[\"sid\"] : undefined;\n if (sub === undefined) {\n // §2.4 allows a token with only `sid`. Tickets are keyed by subject first, so a session\n // could only be found by reading every ticket there is; Keycloak always sends `sub`.\n refuse(\n \"token/refused\",\n sid === undefined\n ? \"the logout token names neither a subject nor a session\"\n : \"the logout token names a session but no subject, and sessions are found by subject\",\n );\n }\n\n await store.dropAll({ sub, sid });\n },\n };\n}\n\nexport { issuer, rewriteOrigin, type Issuer, type IssuerConfig } from \"./issuer\";\nexport { sealedCookie, cookieValue, type SealedCookie, type SealedCookieConfig } from \"./cookie-session\";\nexport {\n statelessStore,\n ticketStore,\n type SessionRecord,\n type SessionStore,\n type SessionSubject,\n type TicketAdapter,\n type TicketStoreConfig,\n} from \"./store\";\n"],"names":["DEFAULT_SCOPE","DEFAULT_MAX_AGE","TRANSACTION_MAX_AGE","STATE","BACKCHANNEL_EVENT","DEFAULT_RENEW_WITHIN","refuse","code","message","cause","AuthError","codeOf","error","isNonceMismatch","node","depth","isStaleKeyMaterial","unanswered","isInvalidGrant","scopeFor","configured","organization","scope","ORGANIZATION","renewals","keyedSingleFlight","reachable","store","guard","call","args","record","ticket","subject","relyingParty","config","provider","issuer","statelessStore","after","DEADLINE","fromIdp","fallback","configuration","session","sealedCookie","transaction","state","opened","cookie","sealed","ended","adopt","tokens","previous","idClaims","identity","claims","lifetime","accessTokenExpiresAt","sid","renew","spent","refreshTokenGrant","current","fresh","next","fromLogout","options","discovered","verifier","randomPKCECodeVerifier","randomState","nonce","randomNonce","parameters","calculatePKCECodeChallenge","buildAuthorizationUrl","request","pending","checks","callback","grant","authorizationCodeGrant","retried","_b","_a","found","within","stale","outcome","returnTo","buildEndSessionUrl","logoutToken","payload","jwtVerify","events","event","sub"],"mappings":"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"}
|