@multiplatform.one/keycloak 6.7.0 → 7.1.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,226 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { keycloakEndpoints } from "./endpoints";
4
+ import { type LoopbackListener, type LoopbackTransport, runLoopbackLogin } from "./loopbackFlow";
5
+ import type { FetchLike } from "./tokenExchange";
6
+
7
+ const crypto = {
8
+ randomBytes: (length: number) => new Uint8Array(randomBytes(length)),
9
+ sha256: (input: Uint8Array) => new Uint8Array(createHash("sha256").update(input).digest()),
10
+ };
11
+
12
+ const config = { url: "https://kc.example.com", realm: "myrealm", clientId: "app-public" };
13
+ const issuer = keycloakEndpoints(config).issuer;
14
+
15
+ /** A structurally real ID token, minted per login so the nonce matches. */
16
+ function mintIdToken(nonce: string, claims: Record<string, unknown> = {}): string {
17
+ const segment = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
18
+ const now = Math.floor(Date.now() / 1000);
19
+ return [
20
+ segment({ alg: "RS256", typ: "JWT", kid: "kid-1" }),
21
+ segment({
22
+ iss: issuer,
23
+ aud: "app-public",
24
+ azp: "app-public",
25
+ sub: "3f6c1e2a-user",
26
+ exp: now + 300,
27
+ iat: now,
28
+ nonce,
29
+ ...claims,
30
+ }),
31
+ "signature-we-do-not-verify",
32
+ ].join(".");
33
+ }
34
+
35
+ /**
36
+ * A stand-in Keycloak: it reads the authorization request the flow opened in
37
+ * the "browser", redirects back with a code, and verifies the PKCE verifier at
38
+ * the token endpoint the way a real server would. It mints its ID token from
39
+ * the nonce it was sent, exactly as Keycloak does.
40
+ */
41
+ function fakeIdp({
42
+ echoState,
43
+ tokenPayload,
44
+ idTokenClaims,
45
+ omitIdToken,
46
+ }: {
47
+ echoState?: string;
48
+ tokenPayload?: unknown;
49
+ idTokenClaims?: Record<string, unknown>;
50
+ omitIdToken?: boolean;
51
+ } = {}) {
52
+ const seen: {
53
+ authorizationUrl?: URL;
54
+ challenge?: string;
55
+ verifier?: string;
56
+ nonce?: string;
57
+ listenedFor?: string;
58
+ } = {};
59
+ let resolveCallback: (query: Record<string, string>) => void;
60
+ const callback = new Promise<Record<string, string>>((resolve) => {
61
+ resolveCallback = resolve;
62
+ });
63
+ const listener: LoopbackListener = {
64
+ redirectUri: "http://127.0.0.1:41234/callback",
65
+ waitForCallback: () => callback,
66
+ close: vi.fn(),
67
+ };
68
+ const transport: LoopbackTransport = {
69
+ crypto,
70
+ listen: async (expectedState: string) => {
71
+ seen.listenedFor = expectedState;
72
+ return listener;
73
+ },
74
+ openBrowser: (url: string) => {
75
+ const parsed = new URL(url);
76
+ seen.authorizationUrl = parsed;
77
+ seen.challenge = parsed.searchParams.get("code_challenge") ?? undefined;
78
+ seen.nonce = parsed.searchParams.get("nonce") ?? undefined;
79
+ resolveCallback({
80
+ code: "authz-code",
81
+ state: echoState ?? (parsed.searchParams.get("state") as string),
82
+ });
83
+ },
84
+ fetch: (async (_url, init) => {
85
+ const form = new URLSearchParams(init.body);
86
+ seen.verifier = form.get("code_verifier") ?? undefined;
87
+ return {
88
+ ok: true,
89
+ status: 200,
90
+ json: async () =>
91
+ tokenPayload ?? {
92
+ access_token: "at",
93
+ refresh_token: "rt",
94
+ ...(omitIdToken ? {} : { id_token: mintIdToken(seen.nonce as string, idTokenClaims) }),
95
+ },
96
+ text: async () => "",
97
+ };
98
+ }) as FetchLike,
99
+ };
100
+ return { transport, listener, seen };
101
+ }
102
+
103
+ describe("runLoopbackLogin", () => {
104
+ it("completes the code-for-token round trip", async () => {
105
+ const { transport } = fakeIdp();
106
+ const tokens = await runLoopbackLogin(transport, config);
107
+ expect(tokens).toMatchObject({ accessToken: "at", refreshToken: "rt" });
108
+ expect(tokens.idTokenClaims?.sub).toBe("3f6c1e2a-user");
109
+ });
110
+
111
+ it("sends a challenge that is the SHA-256 of the verifier it later redeems", async () => {
112
+ const { transport, seen } = fakeIdp();
113
+ await runLoopbackLogin(transport, config);
114
+ const expected = Buffer.from(
115
+ createHash("sha256")
116
+ .update(seen.verifier as string)
117
+ .digest(),
118
+ )
119
+ .toString("base64")
120
+ .replace(/\+/g, "-")
121
+ .replace(/\//g, "_")
122
+ .replace(/=+$/, "");
123
+ expect(seen.challenge).toBe(expected);
124
+ });
125
+
126
+ it("uses the listener's bound redirect uri in both legs of the flow", async () => {
127
+ const { transport, seen } = fakeIdp();
128
+ await runLoopbackLogin(transport, config);
129
+ expect(seen.authorizationUrl?.searchParams.get("redirect_uri")).toBe(
130
+ "http://127.0.0.1:41234/callback",
131
+ );
132
+ });
133
+
134
+ it("aborts when the callback echoes a different state", async () => {
135
+ const { transport } = fakeIdp({ echoState: "attacker-supplied" });
136
+ await expect(runLoopbackLogin(transport, config)).rejects.toMatchObject({
137
+ code: "invalid_state",
138
+ });
139
+ });
140
+
141
+ it("closes the listener even when the exchange fails", async () => {
142
+ const { transport, listener } = fakeIdp({ echoState: "mismatch" });
143
+ await expect(runLoopbackLogin(transport, config)).rejects.toThrow();
144
+ expect(listener.close).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ it("closes the listener on success", async () => {
148
+ const { transport, listener } = fakeIdp();
149
+ await runLoopbackLogin(transport, config);
150
+ expect(listener.close).toHaveBeenCalledTimes(1);
151
+ });
152
+
153
+ it("generates a distinct verifier and state per login", async () => {
154
+ const first = fakeIdp();
155
+ const second = fakeIdp();
156
+ await runLoopbackLogin(first.transport, config);
157
+ await runLoopbackLogin(second.transport, config);
158
+ expect(first.seen.verifier).not.toBe(second.seen.verifier);
159
+ expect(first.seen.authorizationUrl?.searchParams.get("state")).not.toBe(
160
+ second.seen.authorizationUrl?.searchParams.get("state"),
161
+ );
162
+ });
163
+
164
+ it("hands the listener the state to filter on, before the browser opens", async () => {
165
+ const { transport, seen } = fakeIdp();
166
+ await runLoopbackLogin(transport, config);
167
+ expect(seen.listenedFor).toBe(seen.authorizationUrl?.searchParams.get("state"));
168
+ });
169
+
170
+ it("sends a nonce distinct from the state, and a fresh one per login", async () => {
171
+ const first = fakeIdp();
172
+ const second = fakeIdp();
173
+ await runLoopbackLogin(first.transport, config);
174
+ await runLoopbackLogin(second.transport, config);
175
+ expect(first.seen.nonce).toBeTruthy();
176
+ expect(first.seen.nonce).not.toBe(first.seen.authorizationUrl?.searchParams.get("state"));
177
+ expect(first.seen.nonce).not.toBe(second.seen.nonce);
178
+ });
179
+
180
+ it("rejects an id token bound to a different login's nonce", async () => {
181
+ const { transport } = fakeIdp({ idTokenClaims: { nonce: "someone-elses-nonce" } });
182
+ await expect(runLoopbackLogin(transport, config)).rejects.toMatchObject({
183
+ code: "invalid_id_token",
184
+ });
185
+ });
186
+
187
+ it("rejects an id token minted for another client in the realm", async () => {
188
+ const { transport } = fakeIdp({ idTokenClaims: { aud: "other", azp: "other" } });
189
+ await expect(runLoopbackLogin(transport, config)).rejects.toMatchObject({
190
+ code: "invalid_id_token",
191
+ });
192
+ });
193
+
194
+ it("rejects an openid login that came back with no id token", async () => {
195
+ const { transport } = fakeIdp({ omitIdToken: true });
196
+ await expect(runLoopbackLogin(transport, config)).rejects.toThrow(/no id_token came back/);
197
+ });
198
+
199
+ it("allows a non-openid login to come back without an id token", async () => {
200
+ const { transport } = fakeIdp({ omitIdToken: true });
201
+ await expect(
202
+ runLoopbackLogin(transport, { ...config, scopes: ["profile"] }),
203
+ ).resolves.toMatchObject({ accessToken: "at" });
204
+ });
205
+
206
+ it("refuses to start against a plaintext issuer", async () => {
207
+ const { transport } = fakeIdp();
208
+ await expect(
209
+ runLoopbackLogin(transport, { ...config, url: "http://kc.example.com" }),
210
+ ).rejects.toMatchObject({ code: "insecure_issuer" });
211
+ });
212
+
213
+ it("runs against a loopback dev realm when the caller opts in", async () => {
214
+ const localConfig = {
215
+ url: "http://127.0.0.1:8080",
216
+ realm: "myrealm",
217
+ clientId: "app-public",
218
+ allowInsecureLoopbackHttp: true,
219
+ };
220
+ const localIssuer = keycloakEndpoints(localConfig).issuer;
221
+ const { transport } = fakeIdp({ idTokenClaims: { iss: localIssuer } });
222
+ await expect(runLoopbackLogin(transport, localConfig)).resolves.toMatchObject({
223
+ accessToken: "at",
224
+ });
225
+ });
226
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * File: /src/oauth/loopbackFlow.ts
3
+ * Project: @multiplatform.one/keycloak
4
+ *
5
+ * Authorization Code + PKCE with a loopback redirect — the flow RFC 8252
6
+ * ("OAuth 2.0 for Native Apps", §7.3) prescribes for desktop applications.
7
+ *
8
+ * The app binds an ephemeral port on 127.0.0.1, hands Keycloak that port as
9
+ * the redirect URI, and opens the system browser. The user authenticates in a
10
+ * real browser — with their password manager, their existing SSO cookie and a
11
+ * verifiable address bar — and Keycloak redirects back to the listener, which
12
+ * closes as soon as the code arrives.
13
+ *
14
+ * Why not the alternatives:
15
+ *
16
+ * - A custom URI scheme (`myapp://callback`) needs an installed .desktop
17
+ * file registering `x-scheme-handler/myapp` plus a desktop-database
18
+ * refresh. That cannot work from a dev tree run with `gjs -m dist/...`,
19
+ * and on a multi-app desktop any other app can register the same scheme.
20
+ * - The device authorization grant avoids the redirect entirely, but it has
21
+ * to be enabled per-client on the realm, and it downgrades the UX to
22
+ * retyping a code for a GUI app that can perfectly well open a browser.
23
+ * - An embedded webview would put the app between the user and their
24
+ * credentials, which RFC 8252 §8.12 explicitly rules out.
25
+ *
26
+ * Everything the flow touches is injected, so the whole orchestration is
27
+ * exercised in unit tests with no browser, no sockets and no GJS.
28
+ */
29
+
30
+ import { buildAuthorizationUrl, defaultScopes } from "./authorizationUrl";
31
+ import { type CallbackQuery, parseAuthorizationCallback } from "./callback";
32
+ import { keycloakEndpoints } from "./endpoints";
33
+ import { OAuthError } from "./errors";
34
+ import { createNonce, createPkcePair, createStateToken, type PkceCrypto } from "./pkce";
35
+ import { exchangeAuthorizationCode, type FetchLike, type TokenSet } from "./tokenExchange";
36
+
37
+ /** A bound loopback socket waiting for exactly one redirect. */
38
+ export interface LoopbackListener {
39
+ /** The `http://127.0.0.1:<port>/…` URI the socket is bound to. */
40
+ redirectUri: string;
41
+ waitForCallback(): Promise<CallbackQuery>;
42
+ close(): void;
43
+ }
44
+
45
+ export interface LoopbackTransport {
46
+ crypto: PkceCrypto;
47
+ fetch: FetchLike;
48
+ openBrowser(url: string): void | Promise<void>;
49
+ /**
50
+ * Bind a loopback socket for one login.
51
+ *
52
+ * `expectedState` is handed over at bind time rather than at wait time so
53
+ * the listener can tell the real redirect from a stray local request from
54
+ * the very first packet — there is no window in which it does not yet know
55
+ * what it is waiting for.
56
+ */
57
+ listen(expectedState: string): Promise<LoopbackListener>;
58
+ }
59
+
60
+ export interface LoopbackLoginConfig {
61
+ url: string;
62
+ realm: string;
63
+ clientId: string;
64
+ /** See `RealmLocation.allowInsecureLoopbackHttp` — development only. */
65
+ allowInsecureLoopbackHttp?: boolean;
66
+ scopes?: string[];
67
+ prompt?: "none" | "login" | "consent" | "select_account";
68
+ loginHint?: string;
69
+ }
70
+
71
+ export async function runLoopbackLogin(
72
+ transport: LoopbackTransport,
73
+ config: LoopbackLoginConfig,
74
+ ): Promise<TokenSet> {
75
+ const endpoints = keycloakEndpoints(config);
76
+ const pkce = createPkcePair(transport.crypto);
77
+ const state = createStateToken(transport.crypto);
78
+ const nonce = createNonce(transport.crypto);
79
+ const scopes = config.scopes ?? defaultScopes;
80
+ const listener = await transport.listen(state);
81
+ try {
82
+ await transport.openBrowser(
83
+ buildAuthorizationUrl({
84
+ endpoints,
85
+ clientId: config.clientId,
86
+ redirectUri: listener.redirectUri,
87
+ state,
88
+ codeChallenge: pkce.challenge,
89
+ nonce,
90
+ scopes,
91
+ prompt: config.prompt,
92
+ loginHint: config.loginHint,
93
+ }),
94
+ );
95
+ const code = parseAuthorizationCallback(await listener.waitForCallback(), state);
96
+ const tokens = await exchangeAuthorizationCode(transport.fetch, {
97
+ endpoints,
98
+ clientId: config.clientId,
99
+ code,
100
+ codeVerifier: pkce.verifier,
101
+ redirectUri: listener.redirectUri,
102
+ nonce,
103
+ });
104
+ // An `openid` request that comes back without an ID token means the
105
+ // server did not do what was asked; accepting it would quietly turn an
106
+ // authenticated login into a bare access token nobody validated.
107
+ if (scopes.includes("openid") && !tokens.idToken) {
108
+ throw new OAuthError("invalid_response", "openid was requested but no id_token came back");
109
+ }
110
+ return tokens;
111
+ } finally {
112
+ listener.close();
113
+ }
114
+ }
@@ -0,0 +1,60 @@
1
+ import { createHash } from "node:crypto";
2
+ import { describe, expect, it } from "vitest";
3
+ import { base64UrlEncode, createPkcePair, createStateToken, type PkceCrypto } from "./pkce";
4
+
5
+ const sha256 = (input: Uint8Array) => new Uint8Array(createHash("sha256").update(input).digest());
6
+
7
+ function base64UrlDecode(value: string): Uint8Array {
8
+ return new Uint8Array(Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64"));
9
+ }
10
+
11
+ function fixedCrypto(bytes: Uint8Array): PkceCrypto {
12
+ return { randomBytes: () => bytes, sha256 };
13
+ }
14
+
15
+ describe("base64UrlEncode", () => {
16
+ it("uses the URL-safe alphabet and strips padding", () => {
17
+ // 0xfb 0xff 0xbe encodes to "+/++" in standard base64.
18
+ expect(base64UrlEncode(new Uint8Array([0xfb, 0xff, 0xbe]))).toBe("-_--");
19
+ expect(base64UrlEncode(new Uint8Array([0x01]))).toBe("AQ");
20
+ expect(base64UrlEncode(new Uint8Array([]))).toBe("");
21
+ });
22
+ });
23
+
24
+ describe("createPkcePair", () => {
25
+ // RFC 7636 Appendix B publishes this verifier/challenge pair.
26
+ it("reproduces the RFC 7636 Appendix B test vector", () => {
27
+ const rfcVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
28
+ const rfcChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
29
+ const pair = createPkcePair(fixedCrypto(base64UrlDecode(rfcVerifier)));
30
+ expect(pair.verifier).toBe(rfcVerifier);
31
+ expect(pair.challenge).toBe(rfcChallenge);
32
+ expect(pair.method).toBe("S256");
33
+ });
34
+
35
+ it("produces a verifier within the length RFC 7636 §4.1 allows", () => {
36
+ const pair = createPkcePair(fixedCrypto(new Uint8Array(32).fill(7)));
37
+ expect(pair.verifier.length).toBeGreaterThanOrEqual(43);
38
+ expect(pair.verifier.length).toBeLessThanOrEqual(128);
39
+ expect(pair.verifier).toMatch(/^[A-Za-z0-9\-._~]+$/);
40
+ });
41
+
42
+ it("requests 32 bytes of entropy for the verifier", () => {
43
+ let requested = -1;
44
+ createPkcePair({
45
+ randomBytes: (length) => {
46
+ requested = length;
47
+ return new Uint8Array(length);
48
+ },
49
+ sha256,
50
+ });
51
+ expect(requested).toBe(32);
52
+ });
53
+ });
54
+
55
+ describe("createStateToken", () => {
56
+ it("encodes 16 random bytes", () => {
57
+ const state = createStateToken({ randomBytes: (length) => new Uint8Array(length).fill(1) });
58
+ expect(state).toBe(base64UrlEncode(new Uint8Array(16).fill(1)));
59
+ });
60
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * File: /src/oauth/pkce.ts
3
+ * Project: @multiplatform.one/keycloak
4
+ *
5
+ * PKCE (RFC 7636) for public clients. A desktop app cannot keep a client
6
+ * secret, so the code verifier is what proves the app redeeming the
7
+ * authorization code is the same app that requested it.
8
+ *
9
+ * Crypto is injected rather than imported: the browser has WebCrypto, GJS has
10
+ * GLib.compute_checksum_for_bytes, and neither is reachable from the other.
11
+ * Keeping the primitive behind an interface is also what makes this file
12
+ * testable with fixed vectors.
13
+ */
14
+
15
+ export interface PkceCrypto {
16
+ /** Cryptographically secure random bytes. */
17
+ randomBytes(length: number): Uint8Array;
18
+ /** Raw (not hex) SHA-256 digest. */
19
+ sha256(input: Uint8Array): Uint8Array;
20
+ }
21
+
22
+ export interface PkcePair {
23
+ verifier: string;
24
+ challenge: string;
25
+ method: "S256";
26
+ }
27
+
28
+ const base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
29
+
30
+ /**
31
+ * base64url per RFC 4648 §5 — no padding, URL-safe alphabet.
32
+ *
33
+ * Hand-rolled rather than btoa/Buffer because this runs on all three of
34
+ * browsers, node (tests) and GJS, and GJS has neither of those globals.
35
+ */
36
+ export function base64UrlEncode(bytes: Uint8Array): string {
37
+ let out = "";
38
+ for (let i = 0; i < bytes.length; i += 3) {
39
+ const remaining = bytes.length - i;
40
+ const chunk =
41
+ (bytes[i] << 16) |
42
+ ((remaining > 1 ? bytes[i + 1] : 0) << 8) |
43
+ (remaining > 2 ? bytes[i + 2] : 0);
44
+ out += base64UrlAlphabet[(chunk >> 18) & 63];
45
+ out += base64UrlAlphabet[(chunk >> 12) & 63];
46
+ // A 1-byte tail encodes to 2 chars, a 2-byte tail to 3 — the dropped
47
+ // characters are exactly what the "=" padding would have covered.
48
+ if (remaining > 1) out += base64UrlAlphabet[(chunk >> 6) & 63];
49
+ if (remaining > 2) out += base64UrlAlphabet[chunk & 63];
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * RFC 7636 §4.1 allows a 43–128 char verifier; 32 random bytes base64url-encode
56
+ * to 43 chars, the shortest length the spec considers secure.
57
+ */
58
+ export function createPkcePair(crypto: PkceCrypto): PkcePair {
59
+ const verifier = base64UrlEncode(crypto.randomBytes(32));
60
+ const challenge = base64UrlEncode(crypto.sha256(new TextEncoder().encode(verifier)));
61
+ return { verifier, challenge, method: "S256" };
62
+ }
63
+
64
+ /** Opaque CSRF token echoed back on the redirect (RFC 6749 §10.12). */
65
+ export function createStateToken(crypto: Pick<PkceCrypto, "randomBytes">): string {
66
+ return base64UrlEncode(crypto.randomBytes(16));
67
+ }
68
+
69
+ /**
70
+ * Replay defence for the ID token (OIDC Core §3.1.2.1). Sent on the
71
+ * authorization request and echoed in the id_token's `nonce` claim, so a
72
+ * token captured from one login cannot be presented as the result of another.
73
+ *
74
+ * Separate from the state token on purpose: state binds the *redirect* to
75
+ * this process, nonce binds the *token* to this authorization request, and
76
+ * reusing one value for both would leak the state into the token.
77
+ */
78
+ export function createNonce(crypto: Pick<PkceCrypto, "randomBytes">): string {
79
+ return base64UrlEncode(crypto.randomBytes(16));
80
+ }