@multiplatform.one/keycloak 6.6.0 → 7.0.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/cjs/index.cjs +129 -55
- package/dist/cjs/index.native.cjs +239 -130
- package/dist/esm/index.js +131 -57
- package/dist/esm/index.native.js +239 -130
- package/package.json +16 -11
- package/src/betterAuth/client.ts +3 -1
- package/src/betterAuth/expoAuthFlow.native.spec.ts +307 -0
- package/src/betterAuth/expoAuthFlow.native.ts +189 -55
- package/src/frappeToken.native.spec.ts +104 -0
- package/src/frappeToken.native.ts +46 -2
- package/src/frappeToken.spec.ts +141 -0
- package/src/frappeToken.ts +106 -14
- package/src/index.ts +1 -1
- package/src/keycloak/index.ts +24 -7
- package/src/keycloak/index.webext.ts +16 -0
- package/src/oauth/authorizationUrl.spec.ts +83 -0
- package/src/oauth/authorizationUrl.ts +74 -0
- package/src/oauth/callback.spec.ts +72 -0
- package/src/oauth/callback.ts +46 -0
- package/src/oauth/callbackPage.spec.ts +61 -0
- package/src/oauth/callbackPage.ts +57 -0
- package/src/oauth/endpoints.spec.ts +193 -0
- package/src/oauth/endpoints.ts +135 -0
- package/src/oauth/errors.ts +18 -0
- package/src/oauth/form.spec.ts +35 -0
- package/src/oauth/form.ts +30 -0
- package/src/oauth/idToken.spec.ts +191 -0
- package/src/oauth/idToken.ts +153 -0
- package/src/oauth/index.ts +10 -0
- package/src/oauth/jwt.ts +90 -0
- package/src/oauth/loopbackFlow.spec.ts +226 -0
- package/src/oauth/loopbackFlow.ts +114 -0
- package/src/oauth/pkce.spec.ts +60 -0
- package/src/oauth/pkce.ts +80 -0
- package/src/oauth/tokenExchange.spec.ts +331 -0
- package/src/oauth/tokenExchange.ts +232 -0
- package/src/one.ts +30 -3
- package/src/provider/AfterAuth.tsx +7 -0
- package/src/provider/authProvider/index.native.tsx +37 -4
- package/src/session/index.ts +2 -2
- package/src/state.ts +5 -2
- package/types/betterAuth/client.d.ts +2 -791
- package/types/betterAuth/client.d.ts.map +1 -1
- package/types/betterAuth/expoAuthFlow.native.d.ts +50 -3
- package/types/betterAuth/expoAuthFlow.native.d.ts.map +1 -1
- package/types/frappeToken.d.ts +14 -1
- package/types/frappeToken.d.ts.map +1 -1
- package/types/frappeToken.native.d.ts +16 -2
- package/types/frappeToken.native.d.ts.map +1 -1
- package/types/index.d.ts +1 -1
- package/types/index.d.ts.map +1 -1
- package/types/keycloak/index.d.ts.map +1 -1
- package/types/keycloak/index.webext.d.ts.map +1 -1
- package/types/oauth/authorizationUrl.d.ts +32 -0
- package/types/oauth/authorizationUrl.d.ts.map +1 -0
- package/types/oauth/callback.d.ts +26 -0
- package/types/oauth/callback.d.ts.map +1 -0
- package/types/oauth/callbackPage.d.ts +34 -0
- package/types/oauth/callbackPage.d.ts.map +1 -0
- package/types/oauth/endpoints.d.ts +68 -0
- package/types/oauth/endpoints.d.ts.map +1 -0
- package/types/oauth/errors.d.ts +17 -0
- package/types/oauth/errors.d.ts.map +1 -0
- package/types/oauth/form.d.ts +16 -0
- package/types/oauth/form.d.ts.map +1 -0
- package/types/oauth/idToken.d.ts +83 -0
- package/types/oauth/idToken.d.ts.map +1 -0
- package/types/oauth/index.d.ts +11 -0
- package/types/oauth/index.d.ts.map +1 -0
- package/types/oauth/jwt.d.ts +31 -0
- package/types/oauth/jwt.d.ts.map +1 -0
- package/types/oauth/loopbackFlow.d.ts +64 -0
- package/types/oauth/loopbackFlow.d.ts.map +1 -0
- package/types/oauth/pkce.d.ts +49 -0
- package/types/oauth/pkce.d.ts.map +1 -0
- package/types/oauth/tokenExchange.d.ts +89 -0
- package/types/oauth/tokenExchange.d.ts.map +1 -0
- package/types/one.d.ts +7 -0
- package/types/one.d.ts.map +1 -1
- package/types/provider/AfterAuth.d.ts.map +1 -1
- package/types/provider/authProvider/index.native.d.ts.map +1 -1
- package/types/state.d.ts.map +1 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { keycloakEndpoints } from "./endpoints";
|
|
3
|
+
import { OAuthError } from "./errors";
|
|
4
|
+
import {
|
|
5
|
+
endSession,
|
|
6
|
+
type ExchangeCodeParams,
|
|
7
|
+
exchangeAuthorizationCode,
|
|
8
|
+
type FetchLike,
|
|
9
|
+
refreshAccessToken,
|
|
10
|
+
type TokenSet,
|
|
11
|
+
} from "./tokenExchange";
|
|
12
|
+
|
|
13
|
+
const endpoints = keycloakEndpoints({ url: "https://kc.example.com", realm: "myrealm" });
|
|
14
|
+
|
|
15
|
+
const now = 1_700_000_000;
|
|
16
|
+
const nonce = "the-nonce";
|
|
17
|
+
|
|
18
|
+
function jsonFetch(payload: unknown, { ok = true, status = 200 } = {}) {
|
|
19
|
+
return vi.fn(async () => ({
|
|
20
|
+
ok,
|
|
21
|
+
status,
|
|
22
|
+
json: async () => payload,
|
|
23
|
+
text: async () => JSON.stringify(payload),
|
|
24
|
+
})) as unknown as FetchLike & ReturnType<typeof vi.fn>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A structurally real ID token — the shape Keycloak actually returns. */
|
|
28
|
+
function mintIdToken(claims: Record<string, unknown> = {}): string {
|
|
29
|
+
const segment = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
30
|
+
return [
|
|
31
|
+
segment({ alg: "RS256", typ: "JWT", kid: "kid-1" }),
|
|
32
|
+
segment({
|
|
33
|
+
iss: endpoints.issuer,
|
|
34
|
+
aud: "app-public",
|
|
35
|
+
azp: "app-public",
|
|
36
|
+
sub: "3f6c1e2a-user",
|
|
37
|
+
exp: now + 300,
|
|
38
|
+
iat: now - 5,
|
|
39
|
+
nonce,
|
|
40
|
+
...claims,
|
|
41
|
+
}),
|
|
42
|
+
"signature-we-do-not-verify",
|
|
43
|
+
].join(".");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const idToken = mintIdToken();
|
|
47
|
+
|
|
48
|
+
const successPayload = {
|
|
49
|
+
access_token: "at",
|
|
50
|
+
id_token: idToken,
|
|
51
|
+
refresh_token: "rt",
|
|
52
|
+
expires_in: 300,
|
|
53
|
+
refresh_expires_in: 1800,
|
|
54
|
+
token_type: "Bearer",
|
|
55
|
+
scope: "openid profile email",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const exchangeParams = {
|
|
59
|
+
endpoints,
|
|
60
|
+
clientId: "app-public",
|
|
61
|
+
code: "the-code",
|
|
62
|
+
codeVerifier: "the-verifier",
|
|
63
|
+
redirectUri: "http://127.0.0.1:41234/callback",
|
|
64
|
+
nonce,
|
|
65
|
+
now,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe("exchangeAuthorizationCode", () => {
|
|
69
|
+
it("posts the PKCE verifier as a form-encoded body", async () => {
|
|
70
|
+
const fetchImpl = jsonFetch(successPayload);
|
|
71
|
+
await exchangeAuthorizationCode(fetchImpl, exchangeParams);
|
|
72
|
+
const [url, init] = fetchImpl.mock.calls[0];
|
|
73
|
+
expect(url).toBe(endpoints.token);
|
|
74
|
+
expect(init.method).toBe("POST");
|
|
75
|
+
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
|
|
76
|
+
expect(Object.fromEntries(new URLSearchParams(init.body))).toEqual({
|
|
77
|
+
grant_type: "authorization_code",
|
|
78
|
+
client_id: "app-public",
|
|
79
|
+
code: "the-code",
|
|
80
|
+
code_verifier: "the-verifier",
|
|
81
|
+
redirect_uri: "http://127.0.0.1:41234/callback",
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("maps the snake_case wire format onto a TokenSet", async () => {
|
|
86
|
+
const tokens = await exchangeAuthorizationCode(jsonFetch(successPayload), exchangeParams);
|
|
87
|
+
expect(tokens).toMatchObject({
|
|
88
|
+
accessToken: "at",
|
|
89
|
+
idToken,
|
|
90
|
+
refreshToken: "rt",
|
|
91
|
+
expiresIn: 300,
|
|
92
|
+
refreshExpiresIn: 1800,
|
|
93
|
+
tokenType: "Bearer",
|
|
94
|
+
scope: "openid profile email",
|
|
95
|
+
});
|
|
96
|
+
expect(tokens.idTokenClaims?.sub).toBe("3f6c1e2a-user");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("raises the server's OAuth error even though it arrives with a 400", async () => {
|
|
100
|
+
const fetchImpl = jsonFetch(
|
|
101
|
+
{ error: "invalid_grant", error_description: "Code not valid" },
|
|
102
|
+
{ ok: false, status: 400 },
|
|
103
|
+
);
|
|
104
|
+
await expect(exchangeAuthorizationCode(fetchImpl, exchangeParams)).rejects.toMatchObject({
|
|
105
|
+
code: "invalid_grant",
|
|
106
|
+
description: "Code not valid",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("rejects a 200 that carries no access_token", async () => {
|
|
111
|
+
await expect(
|
|
112
|
+
exchangeAuthorizationCode(jsonFetch({ id_token: idToken }), exchangeParams),
|
|
113
|
+
).rejects.toThrow(/no access_token/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("rejects a non-JSON body", async () => {
|
|
117
|
+
const fetchImpl = vi.fn(async () => ({
|
|
118
|
+
ok: false,
|
|
119
|
+
status: 502,
|
|
120
|
+
json: async () => {
|
|
121
|
+
throw new Error("not json");
|
|
122
|
+
},
|
|
123
|
+
text: async () => "<html>bad gateway</html>",
|
|
124
|
+
})) as unknown as FetchLike;
|
|
125
|
+
await expect(exchangeAuthorizationCode(fetchImpl, exchangeParams)).rejects.toBeInstanceOf(
|
|
126
|
+
OAuthError,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns an access token unaccompanied by an id token untouched", async () => {
|
|
131
|
+
const tokens = await exchangeAuthorizationCode(
|
|
132
|
+
jsonFetch({ access_token: "at", expires_in: 300 }),
|
|
133
|
+
exchangeParams,
|
|
134
|
+
);
|
|
135
|
+
expect(tokens.idToken).toBeUndefined();
|
|
136
|
+
expect(tokens.idTokenClaims).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("exchangeAuthorizationCode id token validation", () => {
|
|
141
|
+
async function exchange(payloadIdToken: string) {
|
|
142
|
+
return exchangeAuthorizationCode(
|
|
143
|
+
jsonFetch({ ...successPayload, id_token: payloadIdToken }),
|
|
144
|
+
exchangeParams,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
it("rejects an id token from a different issuer", async () => {
|
|
149
|
+
await expect(
|
|
150
|
+
exchange(mintIdToken({ iss: "https://evil.example.com/realms/x" })),
|
|
151
|
+
).rejects.toMatchObject({ code: "invalid_id_token" });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("rejects an id token minted for a different client in the same realm", async () => {
|
|
155
|
+
await expect(exchange(mintIdToken({ aud: "other", azp: "other" }))).rejects.toMatchObject({
|
|
156
|
+
code: "invalid_id_token",
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rejects an expired id token", async () => {
|
|
161
|
+
await expect(exchange(mintIdToken({ exp: now - 3600 }))).rejects.toThrow(/exp is in the past/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("rejects an id token replayed from another login", async () => {
|
|
165
|
+
await expect(exchange(mintIdToken({ nonce: "someone-elses-nonce" }))).rejects.toThrow(
|
|
166
|
+
/nonce does not match/,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("rejects an unsecured id token", async () => {
|
|
171
|
+
const unsecured = [
|
|
172
|
+
Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"),
|
|
173
|
+
Buffer.from(
|
|
174
|
+
JSON.stringify({
|
|
175
|
+
iss: endpoints.issuer,
|
|
176
|
+
aud: "app-public",
|
|
177
|
+
azp: "app-public",
|
|
178
|
+
sub: "u",
|
|
179
|
+
exp: now + 300,
|
|
180
|
+
iat: now,
|
|
181
|
+
nonce,
|
|
182
|
+
}),
|
|
183
|
+
).toString("base64url"),
|
|
184
|
+
"",
|
|
185
|
+
].join(".");
|
|
186
|
+
await expect(exchange(unsecured)).rejects.toThrow(/alg=none/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("rejects an id token that is not a jwt", async () => {
|
|
190
|
+
await expect(exchange("fake-id-token")).rejects.toMatchObject({ code: "invalid_token" });
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* `nonce` is non-optional on `ExchangeCodeParams`, which stops an in-repo
|
|
196
|
+
* caller forgetting it and stops nothing at all for the JavaScript consumers
|
|
197
|
+
* of a published package. The failure mode is quiet rather than loud:
|
|
198
|
+
* `validateIdToken` skips the nonce comparison when it has no nonce to
|
|
199
|
+
* compare against, so a missing one does not fail, it just drops the only
|
|
200
|
+
* check binding the returned ID token to THIS login. Every call below is
|
|
201
|
+
* cast, because a cast is exactly how the value gets in.
|
|
202
|
+
*/
|
|
203
|
+
describe("exchangeAuthorizationCode nonce requirement", () => {
|
|
204
|
+
function exchangeWith(params: Record<string, unknown>, payload: unknown = successPayload) {
|
|
205
|
+
return exchangeAuthorizationCode(jsonFetch(payload), params as unknown as ExchangeCodeParams);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
it("refuses an explicitly undefined nonce", async () => {
|
|
209
|
+
await expect(exchangeWith({ ...exchangeParams, nonce: undefined })).rejects.toMatchObject({
|
|
210
|
+
code: "invalid_request",
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("refuses an omitted nonce", async () => {
|
|
215
|
+
const { nonce: _omitted, ...withoutNonce } = exchangeParams;
|
|
216
|
+
await expect(exchangeWith(withoutNonce)).rejects.toThrow(/nonce is required/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("refuses an empty nonce", async () => {
|
|
220
|
+
await expect(exchangeWith({ ...exchangeParams, nonce: "" })).rejects.toThrow(
|
|
221
|
+
/nonce is required/,
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("refuses before the code ever reaches the token endpoint", async () => {
|
|
226
|
+
const fetchImpl = jsonFetch(successPayload);
|
|
227
|
+
await expect(
|
|
228
|
+
exchangeAuthorizationCode(fetchImpl, {
|
|
229
|
+
...exchangeParams,
|
|
230
|
+
nonce: undefined,
|
|
231
|
+
} as unknown as ExchangeCodeParams),
|
|
232
|
+
).rejects.toBeInstanceOf(OAuthError);
|
|
233
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// What the guard is actually for. This id token is valid in every respect
|
|
237
|
+
// except that it was minted for a different authorization request; without
|
|
238
|
+
// the guard it comes back as a populated TokenSet.
|
|
239
|
+
it("refuses an id token bound to another login rather than accepting it", async () => {
|
|
240
|
+
await expect(
|
|
241
|
+
exchangeWith(
|
|
242
|
+
{ ...exchangeParams, nonce: undefined },
|
|
243
|
+
{
|
|
244
|
+
...successPayload,
|
|
245
|
+
id_token: mintIdToken({ nonce: "someone-elses-nonce" }),
|
|
246
|
+
},
|
|
247
|
+
),
|
|
248
|
+
).rejects.toThrow(/nonce is required/);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The brand is type-only, so this is checked by `tsc --noEmit` — which
|
|
254
|
+
* includes this file and runs in CI — rather than by the assertion below.
|
|
255
|
+
* Drop the brand from `TokenSet` and the literal becomes assignable, at
|
|
256
|
+
* which point tsc fails on an unused `@ts-expect-error`. That is the whole
|
|
257
|
+
* point: the docstring in tokenExchange.ts claims validation is unskippable,
|
|
258
|
+
* and this is what makes the claim the compiler's rather than the author's.
|
|
259
|
+
*/
|
|
260
|
+
describe("TokenSet is not constructible outside this module", () => {
|
|
261
|
+
it("refuses a hand-written object literal", () => {
|
|
262
|
+
// @ts-expect-error TokenSet is branded; toTokenSet is the only source
|
|
263
|
+
const forged: TokenSet = { accessToken: "at", idToken: "an.unvalidated.token" };
|
|
264
|
+
expect(forged.accessToken).toBe("at");
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("refreshAccessToken", () => {
|
|
269
|
+
const refreshParams = { endpoints, clientId: "app-public", refreshToken: "old-rt", now };
|
|
270
|
+
|
|
271
|
+
it("posts the refresh_token grant", async () => {
|
|
272
|
+
const fetchImpl = jsonFetch(successPayload);
|
|
273
|
+
const tokens = await refreshAccessToken(fetchImpl, refreshParams);
|
|
274
|
+
expect(Object.fromEntries(new URLSearchParams(fetchImpl.mock.calls[0][1].body))).toEqual({
|
|
275
|
+
grant_type: "refresh_token",
|
|
276
|
+
client_id: "app-public",
|
|
277
|
+
refresh_token: "old-rt",
|
|
278
|
+
});
|
|
279
|
+
expect(tokens.accessToken).toBe("at");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("surfaces an expired refresh token as invalid_grant", async () => {
|
|
283
|
+
const fetchImpl = jsonFetch({ error: "invalid_grant" }, { ok: false, status: 400 });
|
|
284
|
+
await expect(
|
|
285
|
+
refreshAccessToken(fetchImpl, { endpoints, clientId: "c", refreshToken: "dead" }),
|
|
286
|
+
).rejects.toMatchObject({ code: "invalid_grant" });
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("validates the refreshed id token against the issuer and this client", async () => {
|
|
290
|
+
const fetchImpl = jsonFetch({
|
|
291
|
+
...successPayload,
|
|
292
|
+
id_token: mintIdToken({ aud: "other", azp: "other" }),
|
|
293
|
+
});
|
|
294
|
+
await expect(refreshAccessToken(fetchImpl, refreshParams)).rejects.toMatchObject({
|
|
295
|
+
code: "invalid_id_token",
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("does not require a nonce, since a refresh has no authorization request", async () => {
|
|
300
|
+
const fetchImpl = jsonFetch({
|
|
301
|
+
...successPayload,
|
|
302
|
+
id_token: mintIdToken({ nonce: "the-original-logins-nonce" }),
|
|
303
|
+
});
|
|
304
|
+
await expect(refreshAccessToken(fetchImpl, refreshParams)).resolves.toMatchObject({
|
|
305
|
+
accessToken: "at",
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("endSession", () => {
|
|
311
|
+
it("posts the refresh token to the logout endpoint", async () => {
|
|
312
|
+
const fetchImpl = jsonFetch({});
|
|
313
|
+
await endSession(fetchImpl, { endpoints, clientId: "app-public", refreshToken: "rt" });
|
|
314
|
+
const [url, init] = fetchImpl.mock.calls[0];
|
|
315
|
+
expect(url).toBe(endpoints.endSession);
|
|
316
|
+
expect(Object.fromEntries(new URLSearchParams(init.body))).toEqual({
|
|
317
|
+
client_id: "app-public",
|
|
318
|
+
refresh_token: "rt",
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("throws when the server refuses the logout", async () => {
|
|
323
|
+
await expect(
|
|
324
|
+
endSession(jsonFetch({}, { ok: false, status: 400 }), {
|
|
325
|
+
endpoints,
|
|
326
|
+
clientId: "c",
|
|
327
|
+
refreshToken: "rt",
|
|
328
|
+
}),
|
|
329
|
+
).rejects.toThrow(/end-session endpoint returned 400/);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: /src/oauth/tokenExchange.ts
|
|
3
|
+
* Project: @multiplatform.one/keycloak
|
|
4
|
+
*
|
|
5
|
+
* The token endpoint calls, written against a minimal fetch shape so the GJS
|
|
6
|
+
* libsoup fetch shim satisfies it as readily as the platform fetch.
|
|
7
|
+
*
|
|
8
|
+
* This module is also where every ID token this package produces gets
|
|
9
|
+
* validated. Doing it here rather than in the flow is what makes the
|
|
10
|
+
* invariant checkable: a `TokenSet` whose `idToken` is set has had that token
|
|
11
|
+
* validated against the issuer and this client, because there is no other way
|
|
12
|
+
* to construct one — `toTokenSet` is module-private and `TokenSet` is branded,
|
|
13
|
+
* so a consumer cannot object-literal its way past the validation either. It
|
|
14
|
+
* is also the only place OIDC Core §3.1.3.7's "directly from the Token
|
|
15
|
+
* Endpoint" precondition is provably true — see idToken.ts for what that
|
|
16
|
+
* precondition buys and what it does not.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { OidcEndpoints } from "./endpoints";
|
|
20
|
+
import { OAuthError } from "./errors";
|
|
21
|
+
import { encodeForm } from "./form";
|
|
22
|
+
import { type IdTokenClaims, validateIdToken } from "./idToken";
|
|
23
|
+
|
|
24
|
+
export interface FetchLikeResponse {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
status: number;
|
|
27
|
+
json(): Promise<unknown>;
|
|
28
|
+
text(): Promise<string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type FetchLike = (
|
|
32
|
+
url: string,
|
|
33
|
+
init: { method: string; headers: Record<string, string>; body: string },
|
|
34
|
+
) => Promise<FetchLikeResponse>;
|
|
35
|
+
|
|
36
|
+
declare const tokenSetBrand: unique symbol;
|
|
37
|
+
|
|
38
|
+
export interface TokenSet {
|
|
39
|
+
/**
|
|
40
|
+
* Phantom brand — in the type, never on the object. It is what makes the
|
|
41
|
+
* invariant above checkable rather than merely stated: an object literal
|
|
42
|
+
* cannot satisfy this interface, so the only `TokenSet` that exists is one
|
|
43
|
+
* `toTokenSet` returned, and `toTokenSet` cannot return without having put
|
|
44
|
+
* any `idToken` through `validateIdToken` first.
|
|
45
|
+
*/
|
|
46
|
+
readonly [tokenSetBrand]: true;
|
|
47
|
+
accessToken: string;
|
|
48
|
+
/**
|
|
49
|
+
* Present only when it passed `validateIdToken` — `iss`, `aud`, `azp`,
|
|
50
|
+
* `exp` and (on the authorization-code grant) `nonce` have all been
|
|
51
|
+
* checked. Its signature has not been verified; see idToken.ts for why
|
|
52
|
+
* that is sound here and what would break it.
|
|
53
|
+
*/
|
|
54
|
+
idToken?: string;
|
|
55
|
+
/** Claims of the validated `idToken`, so callers need not re-decode it. */
|
|
56
|
+
idTokenClaims?: IdTokenClaims;
|
|
57
|
+
refreshToken?: string;
|
|
58
|
+
/** Access token lifetime in seconds, as reported by the server. */
|
|
59
|
+
expiresIn?: number;
|
|
60
|
+
refreshExpiresIn?: number;
|
|
61
|
+
tokenType?: string;
|
|
62
|
+
scope?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface RawTokenResponse {
|
|
66
|
+
access_token?: string;
|
|
67
|
+
id_token?: string;
|
|
68
|
+
refresh_token?: string;
|
|
69
|
+
expires_in?: number;
|
|
70
|
+
refresh_expires_in?: number;
|
|
71
|
+
token_type?: string;
|
|
72
|
+
scope?: string;
|
|
73
|
+
error?: string;
|
|
74
|
+
error_description?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const formHeaders = {
|
|
78
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
79
|
+
Accept: "application/json",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
async function postForm(
|
|
83
|
+
fetchImpl: FetchLike,
|
|
84
|
+
endpoint: string,
|
|
85
|
+
form: Record<string, string>,
|
|
86
|
+
): Promise<RawTokenResponse> {
|
|
87
|
+
const response = await fetchImpl(endpoint, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: formHeaders,
|
|
90
|
+
body: encodeForm(form),
|
|
91
|
+
});
|
|
92
|
+
// Keycloak returns the OAuth error object with a 4xx, so parse the body
|
|
93
|
+
// before trusting `ok` — the error code is more useful than the status.
|
|
94
|
+
let payload: RawTokenResponse;
|
|
95
|
+
try {
|
|
96
|
+
payload = (await response.json()) as RawTokenResponse;
|
|
97
|
+
} catch {
|
|
98
|
+
throw new OAuthError(
|
|
99
|
+
"invalid_response",
|
|
100
|
+
`token endpoint returned ${response.status} with a non-JSON body`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (payload.error) {
|
|
104
|
+
throw new OAuthError(payload.error, undefined, payload.error_description);
|
|
105
|
+
}
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new OAuthError("invalid_response", `token endpoint returned ${response.status}`);
|
|
108
|
+
}
|
|
109
|
+
return payload;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toTokenSet(payload: RawTokenResponse, validation: IdTokenValidationContext): TokenSet {
|
|
113
|
+
if (!payload.access_token) {
|
|
114
|
+
throw new OAuthError("invalid_response", "token response had no access_token");
|
|
115
|
+
}
|
|
116
|
+
const claims = payload.id_token
|
|
117
|
+
? validateIdToken(payload.id_token, {
|
|
118
|
+
issuer: validation.endpoints.issuer,
|
|
119
|
+
clientId: validation.clientId,
|
|
120
|
+
nonce: validation.nonce,
|
|
121
|
+
now: validation.now,
|
|
122
|
+
})
|
|
123
|
+
: undefined;
|
|
124
|
+
// The one place the brand is applied, and it is unreachable without the
|
|
125
|
+
// validation above. Type-only, so nothing is added at runtime.
|
|
126
|
+
return {
|
|
127
|
+
accessToken: payload.access_token,
|
|
128
|
+
idToken: payload.id_token,
|
|
129
|
+
idTokenClaims: claims,
|
|
130
|
+
refreshToken: payload.refresh_token,
|
|
131
|
+
expiresIn: payload.expires_in,
|
|
132
|
+
refreshExpiresIn: payload.refresh_expires_in,
|
|
133
|
+
tokenType: payload.token_type,
|
|
134
|
+
scope: payload.scope,
|
|
135
|
+
} as TokenSet;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface IdTokenValidationContext {
|
|
139
|
+
endpoints: OidcEndpoints;
|
|
140
|
+
clientId: string;
|
|
141
|
+
nonce?: string;
|
|
142
|
+
now?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ExchangeCodeParams {
|
|
146
|
+
endpoints: OidcEndpoints;
|
|
147
|
+
clientId: string;
|
|
148
|
+
code: string;
|
|
149
|
+
codeVerifier: string;
|
|
150
|
+
redirectUri: string;
|
|
151
|
+
/**
|
|
152
|
+
* The nonce from the authorization request this code came back on.
|
|
153
|
+
* Required, not optional: it is the only thing binding the returned ID
|
|
154
|
+
* token to this login, and an optional parameter is one a caller forgets.
|
|
155
|
+
*/
|
|
156
|
+
nonce: string;
|
|
157
|
+
/** Epoch seconds for the `exp`/`iat` checks. Injected by tests. */
|
|
158
|
+
now?: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function exchangeAuthorizationCode(
|
|
162
|
+
fetchImpl: FetchLike,
|
|
163
|
+
{ endpoints, clientId, code, codeVerifier, redirectUri, nonce, now }: ExchangeCodeParams,
|
|
164
|
+
): Promise<TokenSet> {
|
|
165
|
+
// `nonce` being non-optional on the type is not a control: this package is
|
|
166
|
+
// published, so a JavaScript consumer — or anything that got here through a
|
|
167
|
+
// cast — can pass `undefined`. `validateIdToken` skips the nonce comparison
|
|
168
|
+
// when it has nothing to compare against, so a missing nonce is not a
|
|
169
|
+
// failure but a silently weaker check: the ID token would only have to be
|
|
170
|
+
// for this client, not for THIS login. Fail closed instead.
|
|
171
|
+
if (!nonce) {
|
|
172
|
+
throw new OAuthError(
|
|
173
|
+
"invalid_request",
|
|
174
|
+
"nonce is required on the authorization-code grant — it is the only thing binding the id token to this login",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return toTokenSet(
|
|
178
|
+
await postForm(fetchImpl, endpoints.token, {
|
|
179
|
+
grant_type: "authorization_code",
|
|
180
|
+
client_id: clientId,
|
|
181
|
+
code,
|
|
182
|
+
code_verifier: codeVerifier,
|
|
183
|
+
redirect_uri: redirectUri,
|
|
184
|
+
}),
|
|
185
|
+
{ endpoints, clientId, nonce, now },
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface RefreshParams {
|
|
190
|
+
endpoints: OidcEndpoints;
|
|
191
|
+
clientId: string;
|
|
192
|
+
refreshToken: string;
|
|
193
|
+
/** Epoch seconds for the `exp`/`iat` checks. Injected by tests. */
|
|
194
|
+
now?: number;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function refreshAccessToken(
|
|
198
|
+
fetchImpl: FetchLike,
|
|
199
|
+
{ endpoints, clientId, refreshToken, now }: RefreshParams,
|
|
200
|
+
): Promise<TokenSet> {
|
|
201
|
+
return toTokenSet(
|
|
202
|
+
await postForm(fetchImpl, endpoints.token, {
|
|
203
|
+
grant_type: "refresh_token",
|
|
204
|
+
client_id: clientId,
|
|
205
|
+
refresh_token: refreshToken,
|
|
206
|
+
}),
|
|
207
|
+
// No nonce on the refresh grant: there is no fresh authorization request
|
|
208
|
+
// to bind to. Every other claim is still checked.
|
|
209
|
+
{ endpoints, clientId, now },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* End the Keycloak SSO session via back-channel logout. Preferred over opening
|
|
215
|
+
* the end-session URL in a browser on GNOME: there is no app window to return
|
|
216
|
+
* to, so a browser tab would just be left sitting on a logout page.
|
|
217
|
+
*/
|
|
218
|
+
export async function endSession(
|
|
219
|
+
fetchImpl: FetchLike,
|
|
220
|
+
{ endpoints, clientId, refreshToken }: RefreshParams,
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
const response = await fetchImpl(endpoints.endSession, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: formHeaders,
|
|
225
|
+
body: encodeForm({ client_id: clientId, refresh_token: refreshToken }),
|
|
226
|
+
});
|
|
227
|
+
// A rejected logout leaves a server session we cannot reach again; the local
|
|
228
|
+
// tokens are still discarded by the caller, so surface it and move on.
|
|
229
|
+
if (!response.ok) {
|
|
230
|
+
throw new OAuthError("logout_failed", `end-session endpoint returned ${response.status}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/one.ts
CHANGED
|
@@ -23,16 +23,36 @@ export function createAuth(config: AuthHandlerConfig) {
|
|
|
23
23
|
const keycloakBaseUrl = config.keycloak?.baseUrl || `${baseUrl}/auth`;
|
|
24
24
|
const realm = config.keycloak?.realm || "master";
|
|
25
25
|
const issuer = `${keycloakBaseUrl}/realms/${realm}`;
|
|
26
|
+
// Extra origins (e.g. LAN devices hitting the server by IP) come from the
|
|
27
|
+
// KEYCLOAK_TRUSTED_ORIGINS env var (comma-separated) and/or the
|
|
28
|
+
// trustedOrigins option; both extend the always-trusted defaults.
|
|
29
|
+
const envTrustedOrigins = (
|
|
30
|
+
(typeof process !== "undefined" && process.env?.KEYCLOAK_TRUSTED_ORIGINS) ||
|
|
31
|
+
""
|
|
32
|
+
)
|
|
33
|
+
.split(",")
|
|
34
|
+
.map((origin) => origin.trim())
|
|
35
|
+
.filter(Boolean);
|
|
26
36
|
|
|
27
37
|
_auth = betterAuth({
|
|
28
38
|
basePath: "/api/auth",
|
|
29
39
|
baseURL: baseUrl,
|
|
30
40
|
secret: config.secret || "",
|
|
31
41
|
trustedOrigins: [
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
...new Set([
|
|
43
|
+
baseUrl,
|
|
44
|
+
keycloakBaseUrl,
|
|
45
|
+
...(config.expoScheme ? [`${config.expoScheme}://`] : []),
|
|
46
|
+
...envTrustedOrigins,
|
|
47
|
+
...(config.trustedOrigins || []),
|
|
48
|
+
]),
|
|
35
49
|
],
|
|
50
|
+
advanced: {
|
|
51
|
+
// better-auth skips origin checks entirely when NODE_ENV=test; pin the
|
|
52
|
+
// check on so the trustedOrigins contract is enforced (and testable)
|
|
53
|
+
// regardless of environment.
|
|
54
|
+
disableOriginCheck: false,
|
|
55
|
+
},
|
|
36
56
|
session: {
|
|
37
57
|
cookieCache: {
|
|
38
58
|
enabled: true,
|
|
@@ -79,6 +99,13 @@ export interface AuthHandlerConfig {
|
|
|
79
99
|
scopes?: string[];
|
|
80
100
|
/** Expo deep-link scheme (e.g. "multiplatform-one") — added to trustedOrigins. */
|
|
81
101
|
expoScheme?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Additional origins allowed to hit the auth route (e.g. LAN device
|
|
104
|
+
* origins). Extends — never replaces — the defaults (baseUrl, keycloak
|
|
105
|
+
* URL, expo scheme). Also readable from the KEYCLOAK_TRUSTED_ORIGINS env
|
|
106
|
+
* var as a comma-separated list.
|
|
107
|
+
*/
|
|
108
|
+
trustedOrigins?: string[];
|
|
82
109
|
}
|
|
83
110
|
|
|
84
111
|
export type Auth = ReturnType<typeof createAuth>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { logger } from "@multiplatform.one/logger";
|
|
2
2
|
import { type ComponentType, type PropsWithChildren, useEffect } from "react";
|
|
3
|
+
import { watchKeycloakBearerTokenRevalidate } from "../frappeToken";
|
|
3
4
|
import { useAuthConfig } from "../hooks";
|
|
4
5
|
import { useKeycloak } from "../keycloak/index";
|
|
5
6
|
import { persist, useAuthStore } from "../state";
|
|
@@ -63,5 +64,11 @@ export function AfterAuth({ children, loadingComponent: _loadingComponent }: Aft
|
|
|
63
64
|
}
|
|
64
65
|
}, [authConfig.debug, keycloak]);
|
|
65
66
|
|
|
67
|
+
// Silent renew / SLO notice: when this tab is focused again, re-fetch the
|
|
68
|
+
// access token. A front-channel logout elsewhere (desk, another app on
|
|
69
|
+
// this host) revokes the refresh token; the server then clears the Better
|
|
70
|
+
// Auth session so the chrome flips to signed-out without a reload.
|
|
71
|
+
useEffect(() => watchKeycloakBearerTokenRevalidate(), []);
|
|
72
|
+
|
|
66
73
|
return <>{children}</>;
|
|
67
74
|
}
|