@multiplatform.one/keycloak 6.7.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,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("<>&"'");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("escapes the ampersand first, so no entity is double-decoded", () => {
|
|
18
|
+
expect(escapeHtml("<script>")).toBe("&lt;script&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("<script>");
|
|
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("<script>alert(1)</script>");
|
|
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, "&")
|
|
30
|
+
.replace(/</g, "<")
|
|
31
|
+
.replace(/>/g, ">")
|
|
32
|
+
.replace(/"/g, """)
|
|
33
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|