@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.
- 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 +15 -10
- 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/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,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";
|
package/src/oauth/jwt.ts
ADDED
|
@@ -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
|
+
}
|