@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,35 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { encodeForm } from "./form";
3
+
4
+ describe("encodeForm", () => {
5
+ it("joins pairs with & and =", () => {
6
+ expect(encodeForm({ a: "1", b: "2" })).toBe("a=1&b=2");
7
+ });
8
+
9
+ it("encodes byte for byte the way URLSearchParams does", () => {
10
+ // The point of this file is to be a drop-in for URLSearchParams on
11
+ // runtimes that lack it, so pin that equivalence rather than the escapes.
12
+ for (const value of [
13
+ "hello world",
14
+ "a+b",
15
+ "a&b=c",
16
+ "tok-_.~!*()'",
17
+ "http://127.0.0.1:8080/cb?x=1",
18
+ "realm/with space",
19
+ "öäü",
20
+ "openid profile email",
21
+ "",
22
+ ]) {
23
+ expect(encodeForm({ k: value })).toBe(new URLSearchParams({ k: value }).toString());
24
+ }
25
+ });
26
+
27
+ it("encodes keys as well as values", () => {
28
+ expect(encodeForm({ "a key": "v" })).toBe(new URLSearchParams({ "a key": "v" }).toString());
29
+ });
30
+
31
+ it("round-trips through a URLSearchParams parse", () => {
32
+ const params = { grant_type: "authorization_code", code_verifier: "aB-_09~x", scope: "a b" };
33
+ expect(Object.fromEntries(new URLSearchParams(encodeForm(params)))).toEqual(params);
34
+ });
35
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * File: /src/oauth/form.ts
3
+ * Project: @multiplatform.one/keycloak
4
+ *
5
+ * application/x-www-form-urlencoded serialization.
6
+ *
7
+ * URLSearchParams would do this in one line, but GJS has no such global —
8
+ * the GNOME app only gets one from the react-gnome polyfills, and the auth
9
+ * entries deliberately run without them. Twelve lines here keeps the OAuth
10
+ * core dependent on nothing but the language.
11
+ *
12
+ * Matches URLSearchParams byte for byte, including the space-as-"+" rule that
13
+ * the form-urlencoded media type requires.
14
+ */
15
+
16
+ export function encodeForm(params: Record<string, string>): string {
17
+ return Object.entries(params)
18
+ .map(([key, value]) => `${encodeFormComponent(key)}=${encodeFormComponent(value)}`)
19
+ .join("&");
20
+ }
21
+
22
+ function encodeFormComponent(value: string): string {
23
+ return (
24
+ encodeURIComponent(value)
25
+ .replace(/%20/g, "+")
26
+ // encodeURIComponent leaves these unescaped, but they are reserved in a
27
+ // query component, so URLSearchParams escapes them.
28
+ .replace(/[!'()~]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
29
+ );
30
+ }
@@ -0,0 +1,191 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { describe, expect, it } from "vitest";
3
+ import { base64UrlDecode, decodeJwt } from "./jwt";
4
+ import { type IdTokenClaims, validateIdToken } from "./idToken";
5
+ import { base64UrlEncode } from "./pkce";
6
+
7
+ const issuer = "https://kc.example.com/realms/myrealm";
8
+ const clientId = "app-public";
9
+ const now = 1_700_000_000;
10
+
11
+ function segment(value: unknown): string {
12
+ return base64UrlEncode(new TextEncoder().encode(JSON.stringify(value)));
13
+ }
14
+
15
+ function mintIdToken(claims: Partial<IdTokenClaims>, header: Record<string, unknown> = {}): string {
16
+ return [
17
+ segment({ alg: "RS256", typ: "JWT", kid: "abc", ...header }),
18
+ segment({
19
+ iss: issuer,
20
+ aud: clientId,
21
+ azp: clientId,
22
+ sub: "3f6c1e2a-user",
23
+ exp: now + 300,
24
+ iat: now - 5,
25
+ ...claims,
26
+ }),
27
+ "not-a-real-signature",
28
+ ].join(".");
29
+ }
30
+
31
+ const validation = { issuer, clientId, now };
32
+
33
+ describe("base64UrlDecode", () => {
34
+ it("round-trips base64UrlEncode over random inputs of every tail length", () => {
35
+ for (let length = 0; length <= 64; length++) {
36
+ const bytes = new Uint8Array(randomBytes(length));
37
+ expect(Array.from(base64UrlDecode(base64UrlEncode(bytes)))).toEqual(Array.from(bytes));
38
+ }
39
+ });
40
+
41
+ it("matches node's base64url decoder over 2000 random inputs", () => {
42
+ for (let i = 0; i < 2000; i++) {
43
+ const bytes = randomBytes(Math.floor(Math.random() * 96));
44
+ const encoded = bytes.toString("base64url");
45
+ expect(Array.from(base64UrlDecode(encoded))).toEqual(Array.from(bytes));
46
+ }
47
+ });
48
+
49
+ it("accepts the padded form node also accepts", () => {
50
+ expect(new TextDecoder().decode(base64UrlDecode("aGk="))).toBe("hi");
51
+ });
52
+
53
+ it("rejects a character outside the url-safe alphabet", () => {
54
+ expect(() => base64UrlDecode("ab+c")).toThrow(/outside the alphabet/);
55
+ });
56
+
57
+ it("rejects an impossible length", () => {
58
+ expect(() => base64UrlDecode("abcde")).toThrow(/impossible length/);
59
+ });
60
+ });
61
+
62
+ describe("decodeJwt", () => {
63
+ it("decodes the header and payload without touching the signature", () => {
64
+ const decoded = decodeJwt(mintIdToken({}));
65
+ expect(decoded.header.alg).toBe("RS256");
66
+ expect(decoded.payload.sub).toBe("3f6c1e2a-user");
67
+ expect(decoded.signature).toBe("not-a-real-signature");
68
+ });
69
+
70
+ it("rejects a token that is not three segments", () => {
71
+ expect(() => decodeJwt("only.two")).toThrow(/three base64url segments/);
72
+ });
73
+
74
+ it("rejects a payload that is not a json object", () => {
75
+ expect(() => decodeJwt(`${segment({ alg: "RS256" })}.${segment([1, 2])}.sig`)).toThrow(
76
+ /not a json object/,
77
+ );
78
+ });
79
+ });
80
+
81
+ describe("validateIdToken", () => {
82
+ it("accepts a token from the expected issuer for this client", () => {
83
+ const claims = validateIdToken(mintIdToken({}), validation);
84
+ expect(claims.sub).toBe("3f6c1e2a-user");
85
+ });
86
+
87
+ it("rejects a token from a different issuer", () => {
88
+ const token = mintIdToken({ iss: "https://evil.example.com/realms/myrealm" });
89
+ expect(() => validateIdToken(token, validation)).toThrow(/iss .* is not/);
90
+ });
91
+
92
+ it("rejects a token minted for a sibling client in the same realm", () => {
93
+ const token = mintIdToken({ aud: "other-client", azp: "other-client" });
94
+ expect(() => validateIdToken(token, validation)).toThrow(/does not include app-public/);
95
+ });
96
+
97
+ it("rejects a token whose azp is a different client even when aud fits", () => {
98
+ const token = mintIdToken({ aud: clientId, azp: "other-client" });
99
+ expect(() => validateIdToken(token, validation)).toThrow(/azp .* is not app-public/);
100
+ });
101
+
102
+ it("accepts a multi-audience token that names this client in azp", () => {
103
+ const token = mintIdToken({ aud: [clientId, "account"], azp: clientId });
104
+ expect(validateIdToken(token, validation).azp).toBe(clientId);
105
+ });
106
+
107
+ it("rejects a multi-audience token with no azp", () => {
108
+ const token = mintIdToken({ aud: [clientId, "account"], azp: undefined });
109
+ expect(() => validateIdToken(token, validation)).toThrow(/multiple values but azp is missing/);
110
+ });
111
+
112
+ it("rejects an expired token", () => {
113
+ expect(() => validateIdToken(mintIdToken({ exp: now - 3600 }), validation)).toThrow(
114
+ /exp is in the past/,
115
+ );
116
+ });
117
+
118
+ it("allows a token that expired within the clock tolerance", () => {
119
+ const token = mintIdToken({ exp: now - 30 });
120
+ expect(validateIdToken(token, validation).exp).toBe(now - 30);
121
+ });
122
+
123
+ it("rejects a token with no exp", () => {
124
+ expect(() => validateIdToken(mintIdToken({ exp: undefined }), validation)).toThrow(
125
+ /exp is missing/,
126
+ );
127
+ });
128
+
129
+ it("rejects a token issued in the future", () => {
130
+ expect(() => validateIdToken(mintIdToken({ iat: now + 3600 }), validation)).toThrow(
131
+ /iat is in the future/,
132
+ );
133
+ });
134
+
135
+ it("rejects a token that is not yet valid", () => {
136
+ expect(() => validateIdToken(mintIdToken({ nbf: now + 3600 }), validation)).toThrow(
137
+ /nbf is in the future/,
138
+ );
139
+ });
140
+
141
+ it("rejects an unsecured jwt", () => {
142
+ expect(() => validateIdToken(mintIdToken({}, { alg: "none" }), validation)).toThrow(/alg=none/);
143
+ });
144
+
145
+ it("rejects a header with no alg", () => {
146
+ expect(() => validateIdToken(mintIdToken({}, { alg: undefined }), validation)).toThrow(
147
+ /header has no alg/,
148
+ );
149
+ });
150
+
151
+ it("rejects a token with no sub", () => {
152
+ expect(() => validateIdToken(mintIdToken({ sub: undefined }), validation)).toThrow(
153
+ /sub is missing/,
154
+ );
155
+ });
156
+
157
+ it("rejects a replayed token whose nonce belongs to another login", () => {
158
+ const token = mintIdToken({ nonce: "nonce-from-an-earlier-login" });
159
+ expect(() => validateIdToken(token, { ...validation, nonce: "this-logins-nonce" })).toThrow(
160
+ /nonce does not match/,
161
+ );
162
+ });
163
+
164
+ it("rejects a token with no nonce when one was requested", () => {
165
+ expect(() => validateIdToken(mintIdToken({}), { ...validation, nonce: "n" })).toThrow(
166
+ /nonce does not match/,
167
+ );
168
+ });
169
+
170
+ it("accepts the matching nonce", () => {
171
+ const token = mintIdToken({ nonce: "this-logins-nonce" });
172
+ expect(validateIdToken(token, { ...validation, nonce: "this-logins-nonce" }).nonce).toBe(
173
+ "this-logins-nonce",
174
+ );
175
+ });
176
+
177
+ it("skips the nonce check on the refresh grant, where none is supplied", () => {
178
+ const token = mintIdToken({ nonce: "nonce-from-the-original-login" });
179
+ expect(validateIdToken(token, validation).sub).toBe("3f6c1e2a-user");
180
+ });
181
+
182
+ it("raises an OAuthError with a stable code so callers can branch on it", () => {
183
+ expect(() => validateIdToken(mintIdToken({ exp: now - 3600 }), validation)).toThrow(
184
+ expect.objectContaining({ code: "invalid_id_token" }),
185
+ );
186
+ });
187
+
188
+ it("rejects a token that is not a jwt at all", () => {
189
+ expect(() => validateIdToken("fake-id-token", validation)).toThrow(/three base64url segments/);
190
+ });
191
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * File: /src/oauth/idToken.ts
3
+ * Project: @multiplatform.one/keycloak
4
+ *
5
+ * ID token validation for the loopback flow (OIDC Core §3.1.3.7).
6
+ *
7
+ * ## What is checked, and what is deliberately not
8
+ *
9
+ * Checked: `iss`, `aud`, `azp`, `exp`, `iat`, `nbf`, `nonce`, `sub`, and that
10
+ * the header does not claim `alg: none`.
11
+ *
12
+ * **Not checked: the signature.** This is a deliberate decision, not an
13
+ * oversight, and it rests on a precondition that is enforced rather than
14
+ * assumed. OIDC Core §3.1.3.7 rule 6 says a client MAY skip signature
15
+ * validation when the ID token is received "directly from the Token Endpoint"
16
+ * over a TLS-protected channel, because TLS server authentication already
17
+ * proves who minted it. Both halves of that are true here and neither is left
18
+ * to chance:
19
+ *
20
+ * 1. *Directly from the token endpoint.* `validateIdToken` is only ever
21
+ * called from `tokenExchange.ts`, on the parsed body of a POST this
22
+ * process just made to `endpoints.token`. No ID token from a redirect,
23
+ * a front channel or a caller reaches it — there is no implicit flow
24
+ * here and no `response_type` that returns a token on the redirect.
25
+ * 2. *Over TLS.* `keycloakEndpoints` refuses to derive a token endpoint for
26
+ * a plaintext issuer unless the host is loopback **and** the caller
27
+ * passed `allowInsecureLoopbackHttp`. See endpoints.ts.
28
+ *
29
+ * The alternative is a JWKS fetch plus RS256 verification, and it is not
30
+ * available: GJS has no `node:crypto` and no WebCrypto (`globalThis.crypto`
31
+ * is undefined as of gjs 1.88), so RSA verification would mean hand-rolling
32
+ * modular exponentiation over bignums in JavaScript. Hand-rolled RSA in an
33
+ * auth path is a worse risk than the carve-out the spec explicitly grants.
34
+ *
35
+ * If this code ever accepts an ID token from anywhere other than a direct
36
+ * token-endpoint response — a front-channel redirect, a push from a server,
37
+ * a cached token from another process — the carve-out stops applying and the
38
+ * signature has to be verified for real.
39
+ *
40
+ * ## What the carve-out does not excuse
41
+ *
42
+ * Nothing above says anything about the claims. A validly-signed token minted
43
+ * for a different client in the same realm, or one that expired last week, is
44
+ * still the wrong token; `azp`/`aud` and `exp` are what catch those, and they
45
+ * are checked unconditionally.
46
+ */
47
+
48
+ import { OAuthError } from "./errors";
49
+ import { decodeJwt } from "./jwt";
50
+
51
+ export interface IdTokenClaims {
52
+ iss?: string;
53
+ aud?: string | string[];
54
+ azp?: string;
55
+ exp?: number;
56
+ iat?: number;
57
+ nbf?: number;
58
+ nonce?: string;
59
+ sub?: string;
60
+ [claim: string]: unknown;
61
+ }
62
+
63
+ export interface IdTokenValidation {
64
+ /** `endpoints.issuer` — the exact string the `iss` claim must equal. */
65
+ issuer: string;
66
+ clientId: string;
67
+ /**
68
+ * The nonce sent on the authorization request. When supplied the token's
69
+ * `nonce` must match it exactly, which is what stops a token captured from
70
+ * one login being replayed into another.
71
+ *
72
+ * Omitted only on the refresh grant, where there is no fresh authorization
73
+ * request to bind to and the token store does not carry the original nonce
74
+ * across process restarts. The refreshed token is still bound to this
75
+ * client by `azp`/`aud` and to this realm by `iss`.
76
+ */
77
+ nonce?: string;
78
+ /** Epoch seconds. Injected by tests; defaults to the wall clock. */
79
+ now?: number;
80
+ /** Allowance for clock skew between this machine and Keycloak. */
81
+ clockToleranceSeconds?: number;
82
+ }
83
+
84
+ const defaultClockToleranceSeconds = 60;
85
+
86
+ function reject(detail: string): never {
87
+ throw new OAuthError("invalid_id_token", `id token rejected: ${detail}`);
88
+ }
89
+
90
+ function numericClaim(claims: IdTokenClaims, name: "exp" | "iat" | "nbf"): number | undefined {
91
+ const value = claims[name];
92
+ if (value === undefined) return undefined;
93
+ if (typeof value !== "number" || !Number.isFinite(value)) {
94
+ reject(`${name} is not a number`);
95
+ }
96
+ return value;
97
+ }
98
+
99
+ /**
100
+ * Validate an ID token that came straight off a token-endpoint response, and
101
+ * return its claims. Throws `OAuthError("invalid_id_token")` on any failure.
102
+ */
103
+ export function validateIdToken(idToken: string, validation: IdTokenValidation): IdTokenClaims {
104
+ const { header, payload } = decodeJwt(idToken);
105
+ const claims = payload as IdTokenClaims;
106
+ const now = validation.now ?? Math.floor(Date.now() / 1000);
107
+ const tolerance = validation.clockToleranceSeconds ?? defaultClockToleranceSeconds;
108
+
109
+ // `alg: none` is an unsecured JWT (RFC 7519 §6). We do not verify the
110
+ // signature, but a token that announces it does not have one was not
111
+ // produced by Keycloak's token endpoint and should not be humoured.
112
+ const alg = header.alg;
113
+ if (typeof alg !== "string" || !alg) reject("header has no alg");
114
+ if (alg.toLowerCase() === "none") reject("header declares alg=none");
115
+
116
+ if (claims.iss !== validation.issuer) {
117
+ reject(`iss ${JSON.stringify(claims.iss)} is not ${JSON.stringify(validation.issuer)}`);
118
+ }
119
+
120
+ const audiences =
121
+ typeof claims.aud === "string" ? [claims.aud] : Array.isArray(claims.aud) ? claims.aud : [];
122
+ if (!audiences.length) reject("aud is missing");
123
+ if (!audiences.includes(validation.clientId)) {
124
+ reject(`aud ${JSON.stringify(claims.aud)} does not include ${validation.clientId}`);
125
+ }
126
+ // OIDC Core §2: azp is REQUIRED when the token has more than one audience,
127
+ // and when present it must be this client. It is the claim that stops a
128
+ // token minted for a sibling client in the same realm being replayed here.
129
+ if (claims.azp === undefined) {
130
+ if (audiences.length > 1) reject("aud has multiple values but azp is missing");
131
+ } else if (claims.azp !== validation.clientId) {
132
+ reject(`azp ${JSON.stringify(claims.azp)} is not ${validation.clientId}`);
133
+ }
134
+
135
+ const exp = numericClaim(claims, "exp");
136
+ if (exp === undefined) reject("exp is missing");
137
+ if (now >= exp + tolerance) reject("exp is in the past");
138
+
139
+ const iat = numericClaim(claims, "iat");
140
+ if (iat === undefined) reject("iat is missing");
141
+ if (iat > now + tolerance) reject("iat is in the future");
142
+
143
+ const nbf = numericClaim(claims, "nbf");
144
+ if (nbf !== undefined && now < nbf - tolerance) reject("nbf is in the future");
145
+
146
+ if (validation.nonce !== undefined && claims.nonce !== validation.nonce) {
147
+ reject("nonce does not match the authorization request");
148
+ }
149
+
150
+ if (typeof claims.sub !== "string" || !claims.sub) reject("sub is missing");
151
+
152
+ return claims;
153
+ }
@@ -0,0 +1,10 @@
1
+ export * from "./authorizationUrl";
2
+ export * from "./callback";
3
+ export * from "./callbackPage";
4
+ export * from "./endpoints";
5
+ export * from "./errors";
6
+ export * from "./idToken";
7
+ export * from "./jwt";
8
+ export * from "./loopbackFlow";
9
+ export * from "./pkce";
10
+ export * from "./tokenExchange";
@@ -0,0 +1,90 @@
1
+ /**
2
+ * File: /src/oauth/jwt.ts
3
+ * Project: @multiplatform.one/keycloak
4
+ *
5
+ * Unverified JWT decoding — the inverse of pkce.ts's base64url encoder, and
6
+ * hand-rolled for the same reason: GJS has no `atob` and no `Buffer`, and the
7
+ * OAuth core has to run unchanged on GJS, node and the browser.
8
+ *
9
+ * "Unverified" is load-bearing. Nothing in this file looks at the signature.
10
+ * Decoding a JWT tells you what the bytes claim, not whether the claim is
11
+ * true; `validateIdToken` in idToken.ts is what turns the former into the
12
+ * latter, and it is the only thing that should be feeding these results into
13
+ * a decision.
14
+ */
15
+
16
+ import { OAuthError } from "./errors";
17
+
18
+ const base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
19
+
20
+ const base64UrlValues: Record<string, number> = {};
21
+ for (let i = 0; i < base64UrlAlphabet.length; i++) base64UrlValues[base64UrlAlphabet[i]] = i;
22
+
23
+ /**
24
+ * base64url per RFC 4648 §5 — URL-safe alphabet, padding optional.
25
+ *
26
+ * Strict on input: an unknown character or a 1-character tail (which no
27
+ * base64 encoding can produce) is a malformed token, not something to
28
+ * silently round down.
29
+ */
30
+ export function base64UrlDecode(value: string): Uint8Array {
31
+ const input = value.replace(/=+$/, "");
32
+ if (input.length % 4 === 1) {
33
+ throw new OAuthError("invalid_token", "base64url input has an impossible length");
34
+ }
35
+ const bytes = new Uint8Array(Math.floor((input.length * 3) / 4));
36
+ let out = 0;
37
+ for (let i = 0; i < input.length; i += 4) {
38
+ const remaining = input.length - i;
39
+ let chunk = 0;
40
+ for (let j = 0; j < 4; j++) {
41
+ const sextet = j < remaining ? base64UrlValues[input[i + j]] : 0;
42
+ if (sextet === undefined) {
43
+ throw new OAuthError(
44
+ "invalid_token",
45
+ "base64url input has a character outside the alphabet",
46
+ );
47
+ }
48
+ chunk = (chunk << 6) | sextet;
49
+ }
50
+ bytes[out++] = (chunk >> 16) & 255;
51
+ // A 2-character tail carries 1 byte, a 3-character tail 2 — the rest is
52
+ // the padding bits, which must be dropped rather than emitted as zeros.
53
+ if (remaining > 2) bytes[out++] = (chunk >> 8) & 255;
54
+ if (remaining > 3) bytes[out++] = chunk & 255;
55
+ }
56
+ return bytes;
57
+ }
58
+
59
+ function decodeJsonSegment(segment: string, what: string): Record<string, unknown> {
60
+ let parsed: unknown;
61
+ try {
62
+ parsed = JSON.parse(new TextDecoder().decode(base64UrlDecode(segment)));
63
+ } catch {
64
+ throw new OAuthError("invalid_token", `jwt ${what} is not base64url-encoded json`);
65
+ }
66
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
67
+ throw new OAuthError("invalid_token", `jwt ${what} is not a json object`);
68
+ }
69
+ return parsed as Record<string, unknown>;
70
+ }
71
+
72
+ export interface DecodedJwt {
73
+ header: Record<string, unknown>;
74
+ payload: Record<string, unknown>;
75
+ /** Present so a caller can see there is one; nothing here verifies it. */
76
+ signature: string;
77
+ }
78
+
79
+ /** Decode a compact-serialization JWS without verifying anything about it. */
80
+ export function decodeJwt(token: string): DecodedJwt {
81
+ const parts = token.split(".");
82
+ if (parts.length !== 3 || !parts[0] || !parts[1]) {
83
+ throw new OAuthError("invalid_token", "jwt is not three base64url segments");
84
+ }
85
+ return {
86
+ header: decodeJsonSegment(parts[0], "header"),
87
+ payload: decodeJsonSegment(parts[1], "payload"),
88
+ signature: parts[2],
89
+ };
90
+ }