@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,74 @@
1
+ /**
2
+ * File: /src/oauth/authorizationUrl.ts
3
+ * Project: @multiplatform.one/keycloak
4
+ */
5
+
6
+ import type { OidcEndpoints } from "./endpoints";
7
+ import { encodeForm } from "./form";
8
+
9
+ export interface AuthorizationUrlParams {
10
+ endpoints: OidcEndpoints;
11
+ clientId: string;
12
+ redirectUri: string;
13
+ state: string;
14
+ codeChallenge: string;
15
+ /**
16
+ * Echoed in the id_token's `nonce` claim (OIDC Core §3.1.2.1). Required
17
+ * rather than optional: an authorization request built without one yields
18
+ * an ID token with nothing to bind it to this login.
19
+ */
20
+ nonce: string;
21
+ /** Defaults to the OIDC minimum plus the claims BaseKeycloak reads. */
22
+ scopes?: string[];
23
+ prompt?: "none" | "login" | "consent" | "select_account";
24
+ loginHint?: string;
25
+ }
26
+
27
+ export const defaultScopes = ["openid", "profile", "email"];
28
+
29
+ export function buildAuthorizationUrl({
30
+ endpoints,
31
+ clientId,
32
+ redirectUri,
33
+ state,
34
+ codeChallenge,
35
+ nonce,
36
+ scopes = defaultScopes,
37
+ prompt,
38
+ loginHint,
39
+ }: AuthorizationUrlParams): string {
40
+ return `${endpoints.authorization}?${encodeForm({
41
+ client_id: clientId,
42
+ redirect_uri: redirectUri,
43
+ response_type: "code",
44
+ scope: scopes.join(" "),
45
+ state,
46
+ nonce,
47
+ code_challenge: codeChallenge,
48
+ code_challenge_method: "S256",
49
+ ...(prompt ? { prompt } : {}),
50
+ ...(loginHint ? { login_hint: loginHint } : {}),
51
+ })}`;
52
+ }
53
+
54
+ export interface LogoutUrlParams {
55
+ endpoints: OidcEndpoints;
56
+ clientId: string;
57
+ idToken?: string;
58
+ postLogoutRedirectUri?: string;
59
+ }
60
+
61
+ export function buildLogoutUrl({
62
+ endpoints,
63
+ clientId,
64
+ idToken,
65
+ postLogoutRedirectUri,
66
+ }: LogoutUrlParams): string {
67
+ // Keycloak only honours post_logout_redirect_uri when it can tie the request
68
+ // to a session, which id_token_hint is what supplies.
69
+ return `${endpoints.endSession}?${encodeForm({
70
+ client_id: clientId,
71
+ ...(idToken ? { id_token_hint: idToken } : {}),
72
+ ...(postLogoutRedirectUri ? { post_logout_redirect_uri: postLogoutRedirectUri } : {}),
73
+ })}`;
74
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isCallbackForState, parseAuthorizationCallback } from "./callback";
3
+ import { OAuthError } from "./errors";
4
+
5
+ describe("isCallbackForState", () => {
6
+ it("accepts the redirect that echoes the state we generated", () => {
7
+ expect(isCallbackForState({ code: "abc", state: "s1" }, "s1")).toBe(true);
8
+ });
9
+
10
+ it("rejects a local request that carries someone else's state", () => {
11
+ expect(isCallbackForState({ code: "abc", state: "other" }, "s1")).toBe(false);
12
+ });
13
+
14
+ it("rejects a bare port scan with no query at all", () => {
15
+ expect(isCallbackForState({}, "s1")).toBe(false);
16
+ });
17
+
18
+ it("rejects an error redirect that does not name this login", () => {
19
+ expect(isCallbackForState({ error: "access_denied" }, "s1")).toBe(false);
20
+ });
21
+
22
+ it("accepts an error redirect that does name this login, so the flow fails fast", () => {
23
+ expect(isCallbackForState({ error: "access_denied", state: "s1" }, "s1")).toBe(true);
24
+ });
25
+
26
+ it("never matches on an empty expected state", () => {
27
+ expect(isCallbackForState({ state: "" }, "")).toBe(false);
28
+ });
29
+ });
30
+
31
+ describe("parseAuthorizationCallback", () => {
32
+ it("returns the code when the state matches", () => {
33
+ expect(parseAuthorizationCallback({ code: "abc", state: "s1" }, "s1")).toBe("abc");
34
+ });
35
+
36
+ it("rejects a callback whose state does not match the request", () => {
37
+ expect(() => parseAuthorizationCallback({ code: "abc", state: "other" }, "s1")).toThrow(
38
+ OAuthError,
39
+ );
40
+ expect(() => parseAuthorizationCallback({ code: "abc", state: "other" }, "s1")).toThrow(
41
+ /state did not match/,
42
+ );
43
+ });
44
+
45
+ it("rejects a callback with no state at all", () => {
46
+ expect(() => parseAuthorizationCallback({ code: "abc" }, "s1")).toThrow(/no state parameter/);
47
+ });
48
+
49
+ it("surfaces the server's error code and description", () => {
50
+ try {
51
+ parseAuthorizationCallback(
52
+ { error: "access_denied", error_description: "user said no", state: "s1" },
53
+ "s1",
54
+ );
55
+ expect.unreachable("should have thrown");
56
+ } catch (err) {
57
+ expect(err).toBeInstanceOf(OAuthError);
58
+ expect((err as OAuthError).code).toBe("access_denied");
59
+ expect((err as OAuthError).description).toBe("user said no");
60
+ }
61
+ });
62
+
63
+ it("reports an error before a state mismatch, since the flow already failed", () => {
64
+ expect(() => parseAuthorizationCallback({ error: "server_error" }, "s1")).toThrow(
65
+ /server_error/,
66
+ );
67
+ });
68
+
69
+ it("rejects a callback that carries state but no code", () => {
70
+ expect(() => parseAuthorizationCallback({ state: "s1" }, "s1")).toThrow(/no code parameter/);
71
+ });
72
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * File: /src/oauth/callback.ts
3
+ * Project: @multiplatform.one/keycloak
4
+ */
5
+
6
+ import { OAuthError } from "./errors";
7
+
8
+ export type CallbackQuery = Record<string, string | undefined>;
9
+
10
+ /**
11
+ * Is this request the redirect for the login that generated `expectedState`?
12
+ *
13
+ * Anything on the machine can hit 127.0.0.1, so the listener needs to tell a
14
+ * stray local request from the real callback *before* it decides it is done
15
+ * waiting. A request that fails this is noise: it gets a 404 and the listener
16
+ * keeps listening. Without that distinction, any unprivileged local process
17
+ * that races the browser to the port can deny sign-in — it cannot get a code
18
+ * exchanged, because `parseAuthorizationCallback` checks state again, but it
19
+ * can make the real callback arrive at a closed socket.
20
+ */
21
+ export function isCallbackForState(query: CallbackQuery, expectedState: string): boolean {
22
+ return !!expectedState && query.state === expectedState;
23
+ }
24
+
25
+ /**
26
+ * Validate the redirect's query and return the authorization code.
27
+ *
28
+ * The state check is the CSRF defence for the loopback listener: anything on
29
+ * the machine can hit 127.0.0.1, so a callback that does not echo the state we
30
+ * generated is rejected rather than exchanged.
31
+ */
32
+ export function parseAuthorizationCallback(query: CallbackQuery, expectedState: string): string {
33
+ if (query.error) {
34
+ throw new OAuthError(query.error, undefined, query.error_description);
35
+ }
36
+ if (!query.state) {
37
+ throw new OAuthError("invalid_state", "authorization response had no state parameter");
38
+ }
39
+ if (query.state !== expectedState) {
40
+ throw new OAuthError("invalid_state", "authorization response state did not match the request");
41
+ }
42
+ if (!query.code) {
43
+ throw new OAuthError("invalid_request", "authorization response had no code parameter");
44
+ }
45
+ return query.code;
46
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ callbackPageFailure,
4
+ callbackPageSuccess,
5
+ escapeHtml,
6
+ renderCallbackPage,
7
+ } from "./callbackPage";
8
+
9
+ /** The exact payload a hostile local client can put in `error_description`. */
10
+ const hostileErrorDescription = "</p><script>alert(1)</script><p>";
11
+
12
+ describe("escapeHtml", () => {
13
+ it("neutralises the characters that end a text node or an attribute", () => {
14
+ expect(escapeHtml(`<>&"'`)).toBe("&lt;&gt;&amp;&quot;&#39;");
15
+ });
16
+
17
+ it("escapes the ampersand first, so no entity is double-decoded", () => {
18
+ expect(escapeHtml("&lt;script&gt;")).toBe("&amp;lt;script&amp;gt;");
19
+ });
20
+
21
+ it("leaves ordinary text alone", () => {
22
+ expect(escapeHtml("Signed in")).toBe("Signed in");
23
+ });
24
+
25
+ it("defuses the script payload the loopback listener used to reflect", () => {
26
+ const escaped = escapeHtml(hostileErrorDescription);
27
+ expect(escaped).not.toContain("<script>");
28
+ expect(escaped).not.toContain("</p>");
29
+ expect(escaped).toContain("&lt;script&gt;");
30
+ });
31
+ });
32
+
33
+ describe("renderCallbackPage", () => {
34
+ it("renders the success copy", () => {
35
+ const html = renderCallbackPage(callbackPageSuccess);
36
+ expect(html).toContain("<h1>Signed in</h1>");
37
+ expect(html).toContain("You can close this tab");
38
+ });
39
+
40
+ it("renders the failure copy without any provider-supplied text", () => {
41
+ const html = renderCallbackPage(callbackPageFailure);
42
+ expect(html).toContain("<h1>Sign-in failed</h1>");
43
+ expect(html).toContain("Return to the app for the reason.");
44
+ });
45
+
46
+ it("escapes anything interpolated, so a future caller cannot inject markup", () => {
47
+ const html = renderCallbackPage({
48
+ heading: hostileErrorDescription,
49
+ detail: hostileErrorDescription,
50
+ });
51
+ expect(html).not.toContain("<script>");
52
+ expect(html).not.toContain("alert(1)</script>");
53
+ expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
54
+ });
55
+
56
+ it("emits exactly one h1 and one p even for markup-shaped input", () => {
57
+ const html = renderCallbackPage({ heading: "<h1>x</h1>", detail: "<p>y</p>" });
58
+ expect(html.match(/<h1>/g)).toHaveLength(1);
59
+ expect(html.match(/<p>/g)).toHaveLength(1);
60
+ });
61
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * File: /src/oauth/callbackPage.ts
3
+ * Project: @multiplatform.one/keycloak
4
+ *
5
+ * The HTML the loopback listener serves back into the browser tab the
6
+ * redirect lands in.
7
+ *
8
+ * It lives in the OAuth core rather than in the GJS listener for two reasons:
9
+ * markup assembly is pure, so it can be unit tested in CI where nothing runs
10
+ * gjs; and putting the only place this flow emits HTML behind one escaped
11
+ * renderer means there is a single thing to audit.
12
+ *
13
+ * Two rules, both load-bearing:
14
+ *
15
+ * 1. **Nothing the authorization server sent is rendered.** The failure
16
+ * page's copy is fixed. Keycloak's `error_description` arrives on a
17
+ * redirect that any local process can forge, so treating it as markup
18
+ * turned an error page into a script-injection sink; the app still
19
+ * receives the full text through the callback query and surfaces it as
20
+ * an `OAuthError` where it is a string, not a document.
21
+ * 2. **Everything interpolated is escaped anyway.** Belt and braces: the
22
+ * inputs are constants today, and this way they can stop being
23
+ * constants without that being a vulnerability.
24
+ */
25
+
26
+ /** Escape text for an HTML text node or a double-quoted attribute. */
27
+ export function escapeHtml(value: string): string {
28
+ return value
29
+ .replace(/&/g, "&amp;")
30
+ .replace(/</g, "&lt;")
31
+ .replace(/>/g, "&gt;")
32
+ .replace(/"/g, "&quot;")
33
+ .replace(/'/g, "&#39;");
34
+ }
35
+
36
+ export interface CallbackPageContent {
37
+ heading: string;
38
+ detail: string;
39
+ }
40
+
41
+ export const callbackPageSuccess: CallbackPageContent = {
42
+ heading: "Signed in",
43
+ detail: "You can close this tab and return to the app.",
44
+ };
45
+
46
+ export const callbackPageFailure: CallbackPageContent = {
47
+ heading: "Sign-in failed",
48
+ detail: "Return to the app for the reason. You can close this tab.",
49
+ };
50
+
51
+ export function renderCallbackPage({ heading, detail }: CallbackPageContent): string {
52
+ return (
53
+ `<!doctype html><meta charset="utf-8"><title>${escapeHtml(heading)}</title>` +
54
+ '<body style="font-family:system-ui,sans-serif;text-align:center;margin-top:4rem">' +
55
+ `<h1>${escapeHtml(heading)}</h1><p>${escapeHtml(detail)}</p>`
56
+ );
57
+ }
@@ -0,0 +1,193 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { assertSecureIssuer, keycloakEndpoints, type OidcEndpoints } from "./endpoints";
3
+
4
+ describe("keycloakEndpoints", () => {
5
+ it("derives the standard OIDC endpoints for a realm", () => {
6
+ expect(keycloakEndpoints({ url: "https://kc.example.com", realm: "myrealm" })).toEqual({
7
+ issuer: "https://kc.example.com/realms/myrealm",
8
+ authorization: "https://kc.example.com/realms/myrealm/protocol/openid-connect/auth",
9
+ token: "https://kc.example.com/realms/myrealm/protocol/openid-connect/token",
10
+ endSession: "https://kc.example.com/realms/myrealm/protocol/openid-connect/logout",
11
+ userinfo: "https://kc.example.com/realms/myrealm/protocol/openid-connect/userinfo",
12
+ revocation: "https://kc.example.com/realms/myrealm/protocol/openid-connect/revoke",
13
+ });
14
+ });
15
+
16
+ it("tolerates a trailing slash on the base url", () => {
17
+ expect(keycloakEndpoints({ url: "https://kc.example.com///", realm: "r" }).token).toBe(
18
+ "https://kc.example.com/realms/r/protocol/openid-connect/token",
19
+ );
20
+ });
21
+
22
+ it("escapes a realm name that needs it", () => {
23
+ expect(keycloakEndpoints({ url: "https://kc", realm: "my realm" }).authorization).toBe(
24
+ "https://kc/realms/my%20realm/protocol/openid-connect/auth",
25
+ );
26
+ });
27
+
28
+ it("derives the issuer the id token must claim", () => {
29
+ expect(keycloakEndpoints({ url: "https://kc.example.com/", realm: "myrealm" }).issuer).toBe(
30
+ "https://kc.example.com/realms/myrealm",
31
+ );
32
+ });
33
+
34
+ it("still derives the docker realm's endpoints under the loopback opt-in", () => {
35
+ expect(
36
+ keycloakEndpoints({
37
+ url: "http://localhost:8080",
38
+ realm: "myrealm",
39
+ allowInsecureLoopbackHttp: true,
40
+ }).token,
41
+ ).toBe("http://localhost:8080/realms/myrealm/protocol/openid-connect/token");
42
+ });
43
+ });
44
+
45
+ describe("assertSecureIssuer", () => {
46
+ it("accepts https anywhere", () => {
47
+ expect(() => assertSecureIssuer({ url: "https://kc.example.com", realm: "r" })).not.toThrow();
48
+ });
49
+
50
+ it("is case insensitive about the scheme", () => {
51
+ expect(() => assertSecureIssuer({ url: "HTTPS://kc.example.com", realm: "r" })).not.toThrow();
52
+ });
53
+
54
+ it("refuses plaintext http to a remote host even with the loopback opt-in set", () => {
55
+ expect(() =>
56
+ assertSecureIssuer({
57
+ url: "http://kc.example.com",
58
+ realm: "r",
59
+ allowInsecureLoopbackHttp: true,
60
+ }),
61
+ ).toThrow(/refusing plaintext http to kc.example.com/);
62
+ });
63
+
64
+ it("refuses plaintext http to a loopback host without the opt-in", () => {
65
+ expect(() => assertSecureIssuer({ url: "http://localhost:8080", realm: "r" })).toThrow(
66
+ /needs an explicit allowInsecureLoopbackHttp/,
67
+ );
68
+ });
69
+
70
+ it("refuses the shipped dev default until something opts in", () => {
71
+ expect(() => keycloakEndpoints({ url: "http://localhost:8080", realm: "myrealm" })).toThrow(
72
+ expect.objectContaining({ code: "insecure_issuer" }),
73
+ );
74
+ });
75
+
76
+ for (const url of [
77
+ "http://127.0.0.1:8080",
78
+ "http://127.1.2.3:8080",
79
+ "http://localhost",
80
+ "http://LOCALHOST:8080",
81
+ "http://[::1]:8080",
82
+ "http://user:pw@127.0.0.1:8080",
83
+ ]) {
84
+ it(`allows ${url} under the opt-in`, () => {
85
+ expect(() =>
86
+ assertSecureIssuer({ url, realm: "r", allowInsecureLoopbackHttp: true }),
87
+ ).not.toThrow();
88
+ });
89
+ }
90
+
91
+ for (const url of [
92
+ // Hosts that only look like loopback — the classic SSRF bypass set.
93
+ "http://127.0.0.1.evil.com",
94
+ "http://localhost.evil.com",
95
+ "http://evil.com#127.0.0.1",
96
+ "http://evil.com/127.0.0.1",
97
+ "http://evil.com?127.0.0.1",
98
+ "http://0.0.0.0",
99
+ "http://10.0.0.1",
100
+ ]) {
101
+ it(`still refuses ${url} under the opt-in`, () => {
102
+ expect(() =>
103
+ assertSecureIssuer({ url, realm: "r", allowInsecureLoopbackHttp: true }),
104
+ ).toThrow(/must use https/);
105
+ });
106
+ }
107
+
108
+ it("rejects a non-http scheme", () => {
109
+ expect(() => assertSecureIssuer({ url: "ftp://kc.example.com", realm: "r" })).toThrow(
110
+ /must use https, got ftp/,
111
+ );
112
+ });
113
+
114
+ it("rejects a url with no scheme at all", () => {
115
+ expect(() => assertSecureIssuer({ url: "kc.example.com", realm: "r" })).toThrow(
116
+ /must be an absolute http\(s\) url/,
117
+ );
118
+ });
119
+ });
120
+
121
+ /**
122
+ * The userinfo boundary inside `hostOf`, which decides what counts as
123
+ * loopback and therefore what the http opt-in is allowed to reach.
124
+ *
125
+ * An authority may contain more than one `@` and only the LAST one ends the
126
+ * userinfo (RFC 3986 §3.2) — everything before it is a credential, not a
127
+ * host. Split on the first `@` instead and `x@127.0.0.1:8080@evil.com` reads
128
+ * as host `127.0.0.1`: loopback, accepted under the opt-in, and every
129
+ * derived endpoint then points at `evil.com` over plaintext http. The
130
+ * authorization code and the PKCE verifier get posted there, and OIDC Core
131
+ * §3.1.3.7's "over TLS" precondition — the whole reason this package skips
132
+ * signature verification — is silently void.
133
+ *
134
+ * That inversion is one character, and nothing else in this file notices it.
135
+ */
136
+ describe("assertSecureIssuer userinfo boundary", () => {
137
+ for (const url of [
138
+ "http://x@127.0.0.1:8080@evil.com",
139
+ "http://user:pass@127.0.0.1:8080@evil.com",
140
+ "http://x@[::1]@evil.com",
141
+ ]) {
142
+ it(`takes the host after the last @, not the first, in ${url}`, () => {
143
+ expect(() =>
144
+ assertSecureIssuer({ url, realm: "r", allowInsecureLoopbackHttp: true }),
145
+ ).toThrow(/refusing plaintext http to evil\.com/);
146
+ });
147
+ }
148
+
149
+ it("still allows a genuine loopback host whose userinfo only looks hostile", () => {
150
+ expect(() =>
151
+ assertSecureIssuer({
152
+ url: "http://evil.com@127.0.0.1",
153
+ realm: "r",
154
+ allowInsecureLoopbackHttp: true,
155
+ }),
156
+ ).not.toThrow();
157
+ });
158
+
159
+ it("derives the endpoints from the real host, not the userinfo", () => {
160
+ expect(
161
+ keycloakEndpoints({
162
+ url: "http://evil.com@127.0.0.1:8080",
163
+ realm: "r",
164
+ allowInsecureLoopbackHttp: true,
165
+ }).token,
166
+ ).toBe("http://evil.com@127.0.0.1:8080/realms/r/protocol/openid-connect/token");
167
+ });
168
+ });
169
+
170
+ /**
171
+ * The brand is type-only, so this is checked by `tsc --noEmit` — which
172
+ * includes this file and runs in CI — rather than by the assertion below.
173
+ * Drop the brand from `OidcEndpoints` and the literal becomes assignable, at
174
+ * which point tsc fails on an unused `@ts-expect-error`. It is what makes
175
+ * "every URL derives from here" a property of the type rather than a habit:
176
+ * a forged endpoint set is how a plaintext token URL would reach the code
177
+ * exchange without `assertSecureIssuer` ever having run.
178
+ */
179
+ describe("OidcEndpoints is not constructible outside this module", () => {
180
+ it("refuses a hand-written object literal", () => {
181
+ const plaintext = "http://evil.com/realms/r/protocol/openid-connect";
182
+ // @ts-expect-error OidcEndpoints is branded; keycloakEndpoints is the only source
183
+ const forged: OidcEndpoints = {
184
+ issuer: "http://evil.com/realms/r",
185
+ authorization: `${plaintext}/auth`,
186
+ token: `${plaintext}/token`,
187
+ endSession: `${plaintext}/logout`,
188
+ userinfo: `${plaintext}/userinfo`,
189
+ revocation: `${plaintext}/revoke`,
190
+ };
191
+ expect(forged.token).toBe(`${plaintext}/token`);
192
+ });
193
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * File: /src/oauth/endpoints.ts
3
+ * Project: @multiplatform.one/keycloak
4
+ *
5
+ * Keycloak's OIDC endpoints are positional under /realms/<realm>, so they can
6
+ * be derived without a discovery round trip. Deriving beats fetching
7
+ * .well-known here: the GNOME target builds these before it has a working
8
+ * token, and one less network hop is one less failure mode at startup.
9
+ *
10
+ * This is also the transport-security choke point, and `OidcEndpoints` is
11
+ * branded so that it is one by construction rather than by convention: no
12
+ * hand-written object satisfies the type, so every code path that talks to
13
+ * Keycloak — the authorization URL, the token exchange, the refresh, the
14
+ * back-channel logout — necessarily derives its URLs from `keycloakEndpoints`,
15
+ * and rejecting a plaintext issuer here rejects it everywhere. That matters
16
+ * more than usual for this flow:
17
+ * `validateIdToken` skips signature verification on the strength of OIDC Core
18
+ * §3.1.3.7, whose precondition is that the token arrived directly from the
19
+ * token endpoint *over TLS*. Without an enforced https floor that precondition
20
+ * is an assumption, and an assumption is not a control.
21
+ */
22
+
23
+ import { OAuthError } from "./errors";
24
+
25
+ declare const oidcEndpointsBrand: unique symbol;
26
+
27
+ export interface OidcEndpoints {
28
+ /**
29
+ * Phantom brand — in the type, never on the object. It is what turns the
30
+ * choke point described above from a convention into something the
31
+ * compiler holds: an object literal cannot satisfy this interface, so the
32
+ * only way a caller gets an `OidcEndpoints` is out of `keycloakEndpoints`,
33
+ * which runs `assertSecureIssuer` first. Without it a consumer could hand
34
+ * `exchangeAuthorizationCode` a plaintext `token` URL of its own making
35
+ * and the https floor would never have been consulted.
36
+ */
37
+ readonly [oidcEndpointsBrand]: true;
38
+ /** The `iss` claim an ID token minted by this realm must carry. */
39
+ issuer: string;
40
+ authorization: string;
41
+ token: string;
42
+ endSession: string;
43
+ userinfo: string;
44
+ revocation: string;
45
+ }
46
+
47
+ export interface RealmLocation {
48
+ /** Keycloak base URL, e.g. `https://kc.example.com`. */
49
+ url: string;
50
+ realm: string;
51
+ /**
52
+ * Development-only escape hatch for a Keycloak running on the same machine
53
+ * — `http://localhost:8080`, the docker realm, a stand-in IdP in a test.
54
+ *
55
+ * Deliberately narrow: it permits `http` for **loopback hosts only**
56
+ * (`localhost`, `127.0.0.0/8`, `::1`). A plaintext URL pointing anywhere
57
+ * else stays rejected whatever this is set to, because the traffic would
58
+ * leave the machine. Deliberately explicit: there is no silent fallback and
59
+ * no environment sniffing, so a build that talks plaintext to a real realm
60
+ * has to have said so in its own source.
61
+ */
62
+ allowInsecureLoopbackHttp?: boolean;
63
+ }
64
+
65
+ const originPattern = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/?#]*)/;
66
+ const ipv4LoopbackPattern = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
67
+
68
+ /**
69
+ * Host of an absolute URL, minus any userinfo and port. Hand-rolled because
70
+ * GJS has no `URL` any more than it has `URLSearchParams`.
71
+ */
72
+ function hostOf(authority: string): string {
73
+ const afterUserinfo = authority.slice(authority.lastIndexOf("@") + 1);
74
+ // An IPv6 literal is bracketed, so its colons are not a port separator.
75
+ if (afterUserinfo.startsWith("[")) {
76
+ return afterUserinfo.slice(1, afterUserinfo.indexOf("]")).toLowerCase();
77
+ }
78
+ const colon = afterUserinfo.indexOf(":");
79
+ return (colon === -1 ? afterUserinfo : afterUserinfo.slice(0, colon)).toLowerCase();
80
+ }
81
+
82
+ function isLoopbackHost(host: string): boolean {
83
+ return host === "localhost" || host === "::1" || ipv4LoopbackPattern.test(host);
84
+ }
85
+
86
+ /**
87
+ * Reject any issuer the OIDC Core §3.1.3.7 carve-out would not cover.
88
+ *
89
+ * Exported so a caller can check a configured URL before it builds anything
90
+ * from it — the message names the offending host rather than just failing.
91
+ */
92
+ export function assertSecureIssuer({ url, allowInsecureLoopbackHttp }: RealmLocation): void {
93
+ const match = originPattern.exec(url);
94
+ if (!match) {
95
+ throw new OAuthError(
96
+ "insecure_issuer",
97
+ `keycloak url must be an absolute http(s) url, got ${JSON.stringify(url)}`,
98
+ );
99
+ }
100
+ const scheme = match[1].toLowerCase();
101
+ if (scheme === "https") return;
102
+ if (scheme !== "http") {
103
+ throw new OAuthError("insecure_issuer", `keycloak url must use https, got ${scheme}://`);
104
+ }
105
+ const host = hostOf(match[2]);
106
+ if (!isLoopbackHost(host)) {
107
+ throw new OAuthError(
108
+ "insecure_issuer",
109
+ `keycloak url must use https — refusing plaintext http to ${host}`,
110
+ );
111
+ }
112
+ if (!allowInsecureLoopbackHttp) {
113
+ throw new OAuthError(
114
+ "insecure_issuer",
115
+ `keycloak url must use https — plaintext http to the loopback host ${host} needs an explicit allowInsecureLoopbackHttp`,
116
+ );
117
+ }
118
+ }
119
+
120
+ export function keycloakEndpoints(location: RealmLocation): OidcEndpoints {
121
+ assertSecureIssuer(location);
122
+ const issuer = `${location.url.replace(/\/+$/, "")}/realms/${encodeURIComponent(location.realm)}`;
123
+ const base = `${issuer}/protocol/openid-connect`;
124
+ // The one place the brand is applied, immediately after the only call to
125
+ // assertSecureIssuer. The brand is type-only, so nothing is added here at
126
+ // runtime and the returned object is exactly its six URLs.
127
+ return {
128
+ issuer,
129
+ authorization: `${base}/auth`,
130
+ token: `${base}/token`,
131
+ endSession: `${base}/logout`,
132
+ userinfo: `${base}/userinfo`,
133
+ revocation: `${base}/revoke`,
134
+ } as OidcEndpoints;
135
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * File: /src/oauth/errors.ts
3
+ * Project: @multiplatform.one/keycloak
4
+ */
5
+
6
+ /** An OAuth 2.0 / OIDC protocol failure, carrying the spec's `error` code. */
7
+ export class OAuthError extends Error {
8
+ constructor(
9
+ /** The `error` field from the authorization or token response. */
10
+ public readonly code: string,
11
+ message?: string,
12
+ /** The `error_description` field, when the server sent one. */
13
+ public readonly description?: string,
14
+ ) {
15
+ super(message ?? description ?? code);
16
+ this.name = "OAuthError";
17
+ }
18
+ }