@multiplatform.one/keycloak 6.7.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.
Files changed (79) hide show
  1. package/dist/cjs/index.cjs +129 -55
  2. package/dist/cjs/index.native.cjs +239 -130
  3. package/dist/esm/index.js +131 -57
  4. package/dist/esm/index.native.js +239 -130
  5. package/package.json +15 -10
  6. package/src/betterAuth/expoAuthFlow.native.spec.ts +307 -0
  7. package/src/betterAuth/expoAuthFlow.native.ts +189 -55
  8. package/src/frappeToken.native.spec.ts +104 -0
  9. package/src/frappeToken.native.ts +46 -2
  10. package/src/frappeToken.spec.ts +141 -0
  11. package/src/frappeToken.ts +106 -14
  12. package/src/index.ts +1 -1
  13. package/src/keycloak/index.ts +24 -7
  14. package/src/keycloak/index.webext.ts +16 -0
  15. package/src/oauth/authorizationUrl.spec.ts +83 -0
  16. package/src/oauth/authorizationUrl.ts +74 -0
  17. package/src/oauth/callback.spec.ts +72 -0
  18. package/src/oauth/callback.ts +46 -0
  19. package/src/oauth/callbackPage.spec.ts +61 -0
  20. package/src/oauth/callbackPage.ts +57 -0
  21. package/src/oauth/endpoints.spec.ts +193 -0
  22. package/src/oauth/endpoints.ts +135 -0
  23. package/src/oauth/errors.ts +18 -0
  24. package/src/oauth/form.spec.ts +35 -0
  25. package/src/oauth/form.ts +30 -0
  26. package/src/oauth/idToken.spec.ts +191 -0
  27. package/src/oauth/idToken.ts +153 -0
  28. package/src/oauth/index.ts +10 -0
  29. package/src/oauth/jwt.ts +90 -0
  30. package/src/oauth/loopbackFlow.spec.ts +226 -0
  31. package/src/oauth/loopbackFlow.ts +114 -0
  32. package/src/oauth/pkce.spec.ts +60 -0
  33. package/src/oauth/pkce.ts +80 -0
  34. package/src/oauth/tokenExchange.spec.ts +331 -0
  35. package/src/oauth/tokenExchange.ts +232 -0
  36. package/src/one.ts +30 -3
  37. package/src/provider/AfterAuth.tsx +7 -0
  38. package/src/provider/authProvider/index.native.tsx +37 -4
  39. package/src/session/index.ts +2 -2
  40. package/src/state.ts +5 -2
  41. package/types/betterAuth/expoAuthFlow.native.d.ts +50 -3
  42. package/types/betterAuth/expoAuthFlow.native.d.ts.map +1 -1
  43. package/types/frappeToken.d.ts +14 -1
  44. package/types/frappeToken.d.ts.map +1 -1
  45. package/types/frappeToken.native.d.ts +16 -2
  46. package/types/frappeToken.native.d.ts.map +1 -1
  47. package/types/index.d.ts +1 -1
  48. package/types/index.d.ts.map +1 -1
  49. package/types/keycloak/index.d.ts.map +1 -1
  50. package/types/keycloak/index.webext.d.ts.map +1 -1
  51. package/types/oauth/authorizationUrl.d.ts +32 -0
  52. package/types/oauth/authorizationUrl.d.ts.map +1 -0
  53. package/types/oauth/callback.d.ts +26 -0
  54. package/types/oauth/callback.d.ts.map +1 -0
  55. package/types/oauth/callbackPage.d.ts +34 -0
  56. package/types/oauth/callbackPage.d.ts.map +1 -0
  57. package/types/oauth/endpoints.d.ts +68 -0
  58. package/types/oauth/endpoints.d.ts.map +1 -0
  59. package/types/oauth/errors.d.ts +17 -0
  60. package/types/oauth/errors.d.ts.map +1 -0
  61. package/types/oauth/form.d.ts +16 -0
  62. package/types/oauth/form.d.ts.map +1 -0
  63. package/types/oauth/idToken.d.ts +83 -0
  64. package/types/oauth/idToken.d.ts.map +1 -0
  65. package/types/oauth/index.d.ts +11 -0
  66. package/types/oauth/index.d.ts.map +1 -0
  67. package/types/oauth/jwt.d.ts +31 -0
  68. package/types/oauth/jwt.d.ts.map +1 -0
  69. package/types/oauth/loopbackFlow.d.ts +64 -0
  70. package/types/oauth/loopbackFlow.d.ts.map +1 -0
  71. package/types/oauth/pkce.d.ts +49 -0
  72. package/types/oauth/pkce.d.ts.map +1 -0
  73. package/types/oauth/tokenExchange.d.ts +89 -0
  74. package/types/oauth/tokenExchange.d.ts.map +1 -0
  75. package/types/one.d.ts +7 -0
  76. package/types/one.d.ts.map +1 -1
  77. package/types/provider/AfterAuth.d.ts.map +1 -1
  78. package/types/provider/authProvider/index.native.d.ts.map +1 -1
  79. 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
- baseUrl,
33
- keycloakBaseUrl,
34
- ...(config.expoScheme ? [`${config.expoScheme}://`] : []),
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
  }