@multiplatform.one/keycloak 6.6.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.
Files changed (82) 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 +16 -11
  6. package/src/betterAuth/client.ts +3 -1
  7. package/src/betterAuth/expoAuthFlow.native.spec.ts +307 -0
  8. package/src/betterAuth/expoAuthFlow.native.ts +189 -55
  9. package/src/frappeToken.native.spec.ts +104 -0
  10. package/src/frappeToken.native.ts +46 -2
  11. package/src/frappeToken.spec.ts +141 -0
  12. package/src/frappeToken.ts +106 -14
  13. package/src/index.ts +1 -1
  14. package/src/keycloak/index.ts +24 -7
  15. package/src/keycloak/index.webext.ts +16 -0
  16. package/src/oauth/authorizationUrl.spec.ts +83 -0
  17. package/src/oauth/authorizationUrl.ts +74 -0
  18. package/src/oauth/callback.spec.ts +72 -0
  19. package/src/oauth/callback.ts +46 -0
  20. package/src/oauth/callbackPage.spec.ts +61 -0
  21. package/src/oauth/callbackPage.ts +57 -0
  22. package/src/oauth/endpoints.spec.ts +193 -0
  23. package/src/oauth/endpoints.ts +135 -0
  24. package/src/oauth/errors.ts +18 -0
  25. package/src/oauth/form.spec.ts +35 -0
  26. package/src/oauth/form.ts +30 -0
  27. package/src/oauth/idToken.spec.ts +191 -0
  28. package/src/oauth/idToken.ts +153 -0
  29. package/src/oauth/index.ts +10 -0
  30. package/src/oauth/jwt.ts +90 -0
  31. package/src/oauth/loopbackFlow.spec.ts +226 -0
  32. package/src/oauth/loopbackFlow.ts +114 -0
  33. package/src/oauth/pkce.spec.ts +60 -0
  34. package/src/oauth/pkce.ts +80 -0
  35. package/src/oauth/tokenExchange.spec.ts +331 -0
  36. package/src/oauth/tokenExchange.ts +232 -0
  37. package/src/one.ts +30 -3
  38. package/src/provider/AfterAuth.tsx +7 -0
  39. package/src/provider/authProvider/index.native.tsx +37 -4
  40. package/src/session/index.ts +2 -2
  41. package/src/state.ts +5 -2
  42. package/types/betterAuth/client.d.ts +2 -791
  43. package/types/betterAuth/client.d.ts.map +1 -1
  44. package/types/betterAuth/expoAuthFlow.native.d.ts +50 -3
  45. package/types/betterAuth/expoAuthFlow.native.d.ts.map +1 -1
  46. package/types/frappeToken.d.ts +14 -1
  47. package/types/frappeToken.d.ts.map +1 -1
  48. package/types/frappeToken.native.d.ts +16 -2
  49. package/types/frappeToken.native.d.ts.map +1 -1
  50. package/types/index.d.ts +1 -1
  51. package/types/index.d.ts.map +1 -1
  52. package/types/keycloak/index.d.ts.map +1 -1
  53. package/types/keycloak/index.webext.d.ts.map +1 -1
  54. package/types/oauth/authorizationUrl.d.ts +32 -0
  55. package/types/oauth/authorizationUrl.d.ts.map +1 -0
  56. package/types/oauth/callback.d.ts +26 -0
  57. package/types/oauth/callback.d.ts.map +1 -0
  58. package/types/oauth/callbackPage.d.ts +34 -0
  59. package/types/oauth/callbackPage.d.ts.map +1 -0
  60. package/types/oauth/endpoints.d.ts +68 -0
  61. package/types/oauth/endpoints.d.ts.map +1 -0
  62. package/types/oauth/errors.d.ts +17 -0
  63. package/types/oauth/errors.d.ts.map +1 -0
  64. package/types/oauth/form.d.ts +16 -0
  65. package/types/oauth/form.d.ts.map +1 -0
  66. package/types/oauth/idToken.d.ts +83 -0
  67. package/types/oauth/idToken.d.ts.map +1 -0
  68. package/types/oauth/index.d.ts +11 -0
  69. package/types/oauth/index.d.ts.map +1 -0
  70. package/types/oauth/jwt.d.ts +31 -0
  71. package/types/oauth/jwt.d.ts.map +1 -0
  72. package/types/oauth/loopbackFlow.d.ts +64 -0
  73. package/types/oauth/loopbackFlow.d.ts.map +1 -0
  74. package/types/oauth/pkce.d.ts +49 -0
  75. package/types/oauth/pkce.d.ts.map +1 -0
  76. package/types/oauth/tokenExchange.d.ts +89 -0
  77. package/types/oauth/tokenExchange.d.ts.map +1 -0
  78. package/types/one.d.ts +7 -0
  79. package/types/one.d.ts.map +1 -1
  80. package/types/provider/AfterAuth.d.ts.map +1 -1
  81. package/types/provider/authProvider/index.native.d.ts.map +1 -1
  82. package/types/state.d.ts.map +1 -1
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Specs for the native Keycloak Bearer-token getter: caching, stampede
3
+ * dedup, signed-out cooldown, and the clear-on-auth-transition reset.
4
+ */
5
+
6
+ import { beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { AppState } from "react-native";
8
+
9
+ const getAccessToken = vi.fn();
10
+
11
+ vi.mock("./betterAuth/expoAuthFlow.native", () => ({
12
+ getActiveExpoAuthFlow: () => ({ getAccessToken }),
13
+ }));
14
+
15
+ import {
16
+ clearKeycloakBearerToken,
17
+ getKeycloakBearerToken,
18
+ watchKeycloakBearerTokenRevalidate,
19
+ } from "./frappeToken.native";
20
+
21
+ describe("getKeycloakBearerToken (native)", () => {
22
+ beforeEach(() => {
23
+ getAccessToken.mockReset();
24
+ clearKeycloakBearerToken();
25
+ });
26
+
27
+ it("caches a token until near expiry", async () => {
28
+ getAccessToken.mockResolvedValue({
29
+ accessToken: "jwt-1",
30
+ accessTokenExpiresAt: new Date(Date.now() + 300_000).toISOString(),
31
+ });
32
+ expect(await getKeycloakBearerToken()).toBe("jwt-1");
33
+ expect(await getKeycloakBearerToken()).toBe("jwt-1");
34
+ expect(getAccessToken).toHaveBeenCalledTimes(1);
35
+ });
36
+
37
+ it("shares one in-flight fetch across parallel callers", async () => {
38
+ let release: (value: unknown) => void = () => {};
39
+ getAccessToken.mockImplementation(
40
+ () =>
41
+ new Promise((resolve) => {
42
+ release = resolve;
43
+ }),
44
+ );
45
+ const a = getKeycloakBearerToken();
46
+ const b = getKeycloakBearerToken();
47
+ release({
48
+ accessToken: "jwt-2",
49
+ accessTokenExpiresAt: new Date(Date.now() + 300_000).toISOString(),
50
+ });
51
+ expect(await a).toBe("jwt-2");
52
+ expect(await b).toBe("jwt-2");
53
+ expect(getAccessToken).toHaveBeenCalledTimes(1);
54
+ });
55
+
56
+ it("answers signed-out once per cooldown window instead of per request", async () => {
57
+ getAccessToken.mockResolvedValue(null);
58
+ expect(await getKeycloakBearerToken()).toBeUndefined();
59
+ expect(await getKeycloakBearerToken()).toBeUndefined();
60
+ expect(getAccessToken).toHaveBeenCalledTimes(1);
61
+ });
62
+
63
+ it("clearKeycloakBearerToken resets the cooldown (sign-in transition)", async () => {
64
+ getAccessToken.mockResolvedValueOnce(null);
65
+ expect(await getKeycloakBearerToken()).toBeUndefined();
66
+ clearKeycloakBearerToken();
67
+ getAccessToken.mockResolvedValueOnce({
68
+ accessToken: "jwt-3",
69
+ accessTokenExpiresAt: new Date(Date.now() + 300_000).toISOString(),
70
+ });
71
+ expect(await getKeycloakBearerToken()).toBe("jwt-3");
72
+ expect(getAccessToken).toHaveBeenCalledTimes(2);
73
+ });
74
+
75
+ it("treats a thrown fetch as signed out (guest requests)", async () => {
76
+ getAccessToken.mockRejectedValue(new Error("network down"));
77
+ expect(await getKeycloakBearerToken()).toBeUndefined();
78
+ });
79
+
80
+ it("watchKeycloakBearerTokenRevalidate busts the cache on foreground", async () => {
81
+ const listeners: Array<(status: string) => void> = [];
82
+ const spy = vi.spyOn(AppState, "addEventListener").mockImplementation((_event, cb) => {
83
+ listeners.push(cb as (status: string) => void);
84
+ return { remove: vi.fn() } as never;
85
+ });
86
+ getAccessToken.mockResolvedValue({
87
+ accessToken: "jwt-1",
88
+ accessTokenExpiresAt: new Date(Date.now() + 300_000).toISOString(),
89
+ });
90
+ expect(await getKeycloakBearerToken()).toBe("jwt-1");
91
+ expect(getAccessToken).toHaveBeenCalledTimes(1);
92
+
93
+ const unsub = watchKeycloakBearerTokenRevalidate();
94
+ getAccessToken.mockResolvedValue({
95
+ accessToken: "jwt-fresh",
96
+ accessTokenExpiresAt: new Date(Date.now() + 300_000).toISOString(),
97
+ });
98
+ for (const cb of listeners) cb("active");
99
+ expect(await getKeycloakBearerToken()).toBe("jwt-fresh");
100
+ expect(getAccessToken).toHaveBeenCalledTimes(2);
101
+ unsub();
102
+ spy.mockRestore();
103
+ });
104
+ });
@@ -9,20 +9,44 @@
9
9
  * when expired), authenticated with the SecureStore cookie jar.
10
10
  */
11
11
 
12
+ import { AppState, type AppStateStatus } from "react-native";
12
13
  import { getActiveExpoAuthFlow } from "./betterAuth/expoAuthFlow.native";
13
14
 
14
15
  let cached: { token: string; expiresAt: number } | undefined;
16
+ /** In-flight fetch, shared so parallel callers don't stampede the endpoint. */
17
+ let pending: Promise<string | undefined> | undefined;
18
+ /** After a signed-out answer, skip re-asking until this time (ms epoch). */
19
+ let signedOutUntil = 0;
15
20
 
16
- /** Returns a valid Keycloak access token, or undefined when signed out. */
21
+ const SIGNED_OUT_COOLDOWN_MS = 15_000;
22
+
23
+ /**
24
+ * Returns a valid Keycloak access token, or undefined when signed out.
25
+ *
26
+ * Signed-out clients get a single 401 per cooldown window instead of one per
27
+ * concurrent collection sync: parallel callers share the in-flight fetch, and
28
+ * a signed-out answer short-circuits further attempts for a few seconds. The
29
+ * AuthProvider clears this module state on sign-in / sign-out.
30
+ */
17
31
  export async function getKeycloakBearerToken(): Promise<string | undefined> {
18
32
  // 60s slack so we never hand out a token that expires mid-request.
19
33
  if (cached && cached.expiresAt - 60_000 > Date.now()) return cached.token;
34
+ if (Date.now() < signedOutUntil) return undefined;
35
+ if (pending) return pending;
36
+ pending = fetchToken().finally(() => {
37
+ pending = undefined;
38
+ });
39
+ return pending;
40
+ }
41
+
42
+ async function fetchToken(): Promise<string | undefined> {
20
43
  const flow = getActiveExpoAuthFlow();
21
44
  if (!flow) return undefined;
22
45
  try {
23
46
  const res = await flow.getAccessToken("keycloak");
24
47
  if (!res?.accessToken) {
25
48
  cached = undefined;
49
+ signedOutUntil = Date.now() + SIGNED_OUT_COOLDOWN_MS;
26
50
  return undefined;
27
51
  }
28
52
  cached = {
@@ -35,11 +59,31 @@ export async function getKeycloakBearerToken(): Promise<string | undefined> {
35
59
  } catch {
36
60
  // Signed out / server unreachable -> guest requests.
37
61
  cached = undefined;
62
+ signedOutUntil = Date.now() + SIGNED_OUT_COOLDOWN_MS;
38
63
  return undefined;
39
64
  }
40
65
  }
41
66
 
42
- /** Drop the cached token (sign-out). */
67
+ /** Drop the cached token (sign-out / fresh sign-in). */
43
68
  export function clearKeycloakBearerToken() {
44
69
  cached = undefined;
70
+ pending = undefined;
71
+ signedOutUntil = 0;
72
+ }
73
+
74
+ /**
75
+ * Bust the client cache and re-fetch when the app returns to the
76
+ * foreground, so a completed ASWebAuthenticationSession (or an SLO in
77
+ * Safari that the Linking listener missed) is noticed without a reload.
78
+ * Returns an unsubscribe. AfterAuth mounts this once.
79
+ */
80
+ export function watchKeycloakBearerTokenRevalidate(): () => void {
81
+ const onChange = (next: AppStateStatus) => {
82
+ if (next !== "active") return;
83
+ cached = undefined;
84
+ signedOutUntil = 0;
85
+ void getKeycloakBearerToken();
86
+ };
87
+ const sub = AppState.addEventListener("change", onChange);
88
+ return () => sub.remove();
45
89
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Specs for the web Keycloak Bearer-token getter: caching, stampede dedup,
3
+ * signed-out cooldown, and transient Keycloak-restart retries.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+
8
+ const notify = vi.fn();
9
+
10
+ vi.mock("./betterAuth/client", () => ({
11
+ getAuthClient: () => ({ $store: { notify } }),
12
+ }));
13
+
14
+ import { clearKeycloakBearerToken, getKeycloakBearerToken } from "./frappeToken";
15
+
16
+ function jsonResponse(status: number, body: unknown) {
17
+ return new Response(JSON.stringify(body), {
18
+ status,
19
+ headers: { "content-type": "application/json" },
20
+ });
21
+ }
22
+
23
+ describe("getKeycloakBearerToken (web)", () => {
24
+ beforeEach(() => {
25
+ notify.mockReset();
26
+ clearKeycloakBearerToken();
27
+ vi.stubGlobal(
28
+ "fetch",
29
+ vi.fn().mockResolvedValue(
30
+ jsonResponse(200, {
31
+ accessToken: "jwt-1",
32
+ accessTokenExpiresAt: new Date(Date.now() + 300_000).toISOString(),
33
+ }),
34
+ ),
35
+ );
36
+ });
37
+
38
+ afterEach(() => {
39
+ vi.unstubAllGlobals();
40
+ vi.useRealTimers();
41
+ });
42
+
43
+ it("caches a token until near expiry", async () => {
44
+ expect(await getKeycloakBearerToken()).toBe("jwt-1");
45
+ expect(await getKeycloakBearerToken()).toBe("jwt-1");
46
+ expect(fetch).toHaveBeenCalledTimes(1);
47
+ });
48
+
49
+ it("shares one in-flight fetch across parallel callers", async () => {
50
+ let release: (value: Response) => void = () => {};
51
+ vi.stubGlobal(
52
+ "fetch",
53
+ vi.fn(
54
+ () =>
55
+ new Promise<Response>((resolve) => {
56
+ release = resolve;
57
+ }),
58
+ ),
59
+ );
60
+ const a = getKeycloakBearerToken();
61
+ const b = getKeycloakBearerToken();
62
+ release(
63
+ jsonResponse(200, {
64
+ accessToken: "jwt-2",
65
+ accessTokenExpiresAt: new Date(Date.now() + 300_000).toISOString(),
66
+ }),
67
+ );
68
+ expect(await a).toBe("jwt-2");
69
+ expect(await b).toBe("jwt-2");
70
+ expect(fetch).toHaveBeenCalledTimes(1);
71
+ });
72
+
73
+ it("answers signed-out once per cooldown window instead of per request", async () => {
74
+ vi.stubGlobal(
75
+ "fetch",
76
+ vi.fn().mockResolvedValue(jsonResponse(400, { code: "FAILED_TO_GET_ACCESS_TOKEN" })),
77
+ );
78
+ expect(await getKeycloakBearerToken()).toBeUndefined();
79
+ expect(await getKeycloakBearerToken()).toBeUndefined();
80
+ expect(fetch).toHaveBeenCalledTimes(1);
81
+ expect(notify).toHaveBeenCalledWith("$sessionSignal");
82
+ });
83
+
84
+ it("retries TOKEN_REFRESH_UNAVAILABLE and succeeds without signing out", async () => {
85
+ vi.useFakeTimers();
86
+ const fetchMock = vi
87
+ .fn()
88
+ .mockResolvedValueOnce(jsonResponse(500, { code: "TOKEN_REFRESH_UNAVAILABLE" }))
89
+ .mockResolvedValueOnce(
90
+ jsonResponse(200, {
91
+ accessToken: "jwt-after-restart",
92
+ accessTokenExpiresAt: new Date(Date.now() + 300_000).toISOString(),
93
+ }),
94
+ );
95
+ vi.stubGlobal("fetch", fetchMock);
96
+
97
+ const pending = getKeycloakBearerToken();
98
+ await vi.runAllTimersAsync();
99
+ expect(await pending).toBe("jwt-after-restart");
100
+ expect(fetchMock).toHaveBeenCalledTimes(2);
101
+ expect(notify).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("does not start the signed-out cooldown on a network blip", async () => {
105
+ vi.useFakeTimers();
106
+ const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
107
+ vi.stubGlobal("fetch", fetchMock);
108
+
109
+ const pending = getKeycloakBearerToken();
110
+ await vi.runAllTimersAsync();
111
+ expect(await pending).toBeUndefined();
112
+ expect(notify).not.toHaveBeenCalled();
113
+
114
+ clearKeycloakBearerToken();
115
+ fetchMock.mockResolvedValueOnce(
116
+ jsonResponse(200, {
117
+ accessToken: "jwt-recovered",
118
+ accessTokenExpiresAt: new Date(Date.now() + 300_000).toISOString(),
119
+ }),
120
+ );
121
+ expect(await getKeycloakBearerToken()).toBe("jwt-recovered");
122
+ });
123
+
124
+ it("clearKeycloakBearerToken resets the cooldown (sign-in transition)", async () => {
125
+ vi.stubGlobal(
126
+ "fetch",
127
+ vi
128
+ .fn()
129
+ .mockResolvedValueOnce(jsonResponse(401, {}))
130
+ .mockResolvedValueOnce(
131
+ jsonResponse(200, {
132
+ accessToken: "jwt-3",
133
+ accessTokenExpiresAt: new Date(Date.now() + 300_000).toISOString(),
134
+ }),
135
+ ),
136
+ );
137
+ expect(await getKeycloakBearerToken()).toBeUndefined();
138
+ clearKeycloakBearerToken();
139
+ expect(await getKeycloakBearerToken()).toBe("jwt-3");
140
+ });
141
+ });
@@ -11,15 +11,59 @@
11
11
  * cookie) and sends `Authorization: Bearer <token>` on every REST request
12
12
  * and socket handshake — the same transport native uses. The bench's
13
13
  * frappe_keycloak auth hook validates the JWT against the realm public key.
14
+ *
15
+ * Failures are classified, not collapsed:
16
+ * - 401/403, or 400 `FAILED_TO_GET_ACCESS_TOKEN` (server already decided
17
+ * the refresh token is dead): signed-out cooldown + session notify.
18
+ * - 5xx `TOKEN_REFRESH_UNAVAILABLE`, other 5xx, network: retry with
19
+ * backoff, keep the session. A Keycloak restart must not look like logout.
14
20
  */
15
21
 
22
+ import { getAuthClient } from "./betterAuth/client";
23
+
16
24
  let cached: { token: string; expiresAt: number } | undefined;
17
25
  /** In-flight fetch, shared so parallel callers don't stampede the endpoint. */
18
26
  let pending: Promise<string | undefined> | undefined;
19
27
  /** After a signed-out answer, skip re-asking until this time (ms epoch). */
20
28
  let signedOutUntil = 0;
29
+ /** After a transient Keycloak blip, skip re-asking briefly (not signed-out). */
30
+ let unavailableUntil = 0;
21
31
 
22
32
  const SIGNED_OUT_COOLDOWN_MS = 15_000;
33
+ const TRANSIENT_COOLDOWN_MS = 3_000;
34
+ const RETRY_DELAYS_MS = [0, 400, 1000] as const;
35
+ const FAILED_TO_GET_ACCESS_TOKEN = "FAILED_TO_GET_ACCESS_TOKEN";
36
+
37
+ type FailureKind = "signed-out" | "transient";
38
+
39
+ function classifyAccessTokenResponse(status: number, body?: { code?: string } | null): FailureKind {
40
+ if (status === 401 || status === 403) return "signed-out";
41
+ if (status === 400 && body?.code === FAILED_TO_GET_ACCESS_TOKEN) return "signed-out";
42
+ return "transient";
43
+ }
44
+
45
+ /**
46
+ * A definitive refresh failure (Keycloak declared the refresh token dead —
47
+ * e.g. its SSO session was wiped by a restart). The server revokes the stale
48
+ * Better Auth session on this answer and clears its cookies in the same
49
+ * response, so poke the session store to refetch: the UI flips to signed-out
50
+ * instead of silently degrading to Guest requests.
51
+ */
52
+ function notifySessionInvalidated() {
53
+ try {
54
+ const client = getAuthClient() as {
55
+ $store?: { notify?: (signal: string) => void };
56
+ };
57
+ client.$store?.notify?.("$sessionSignal");
58
+ } catch {
59
+ // The session store is a UX nicety here; token callers already got their
60
+ // signed-out answer.
61
+ }
62
+ }
63
+
64
+ function sleep(ms: number) {
65
+ return new Promise((resolve) => setTimeout(resolve, ms));
66
+ }
23
67
 
24
68
  /**
25
69
  * Returns a valid Keycloak access token, or undefined when signed out.
@@ -28,13 +72,15 @@ const SIGNED_OUT_COOLDOWN_MS = 15_000;
28
72
  * concurrent data request: parallel callers share the in-flight fetch, and a
29
73
  * signed-out answer short-circuits further attempts for a few seconds. A real
30
74
  * sign-in arrives via the OAuth redirect (full navigation), which resets this
31
- * module state anyway.
75
+ * module state anyway. Transient Keycloak failures retry before giving up,
76
+ * and never start the signed-out cooldown.
32
77
  */
33
78
  export async function getKeycloakBearerToken(): Promise<string | undefined> {
34
79
  if (typeof window === "undefined") return undefined;
35
80
  // 60s slack so we never hand out a token that expires mid-request.
36
81
  if (cached && cached.expiresAt - 60_000 > Date.now()) return cached.token;
37
82
  if (Date.now() < signedOutUntil) return undefined;
83
+ if (Date.now() < unavailableUntil) return undefined;
38
84
  if (pending) return pending;
39
85
  pending = fetchToken().finally(() => {
40
86
  pending = undefined;
@@ -42,7 +88,9 @@ export async function getKeycloakBearerToken(): Promise<string | undefined> {
42
88
  return pending;
43
89
  }
44
90
 
45
- async function fetchToken(): Promise<string | undefined> {
91
+ async function fetchTokenOnce(): Promise<
92
+ { ok: true; token: string; expiresAt: number } | { ok: false; kind: FailureKind }
93
+ > {
46
94
  try {
47
95
  const res = await fetch(`${window.location.origin}/api/auth/get-access-token`, {
48
96
  method: "POST",
@@ -51,32 +99,51 @@ async function fetchToken(): Promise<string | undefined> {
51
99
  body: JSON.stringify({ providerId: "keycloak" }),
52
100
  });
53
101
  if (!res.ok) {
54
- cached = undefined;
55
- signedOutUntil = Date.now() + SIGNED_OUT_COOLDOWN_MS;
56
- return undefined;
102
+ const body = (await res.json().catch(() => null)) as { code?: string } | null;
103
+ return { ok: false, kind: classifyAccessTokenResponse(res.status, body) };
57
104
  }
58
105
  const data = (await res.json().catch(() => null)) as {
59
106
  accessToken?: string;
60
107
  accessTokenExpiresAt?: string;
61
108
  } | null;
62
109
  if (!data?.accessToken) {
63
- cached = undefined;
64
- signedOutUntil = Date.now() + SIGNED_OUT_COOLDOWN_MS;
65
- return undefined;
110
+ return { ok: false, kind: "signed-out" };
66
111
  }
67
- cached = {
112
+ return {
113
+ ok: true,
68
114
  token: data.accessToken,
69
115
  expiresAt: data.accessTokenExpiresAt
70
116
  ? new Date(data.accessTokenExpiresAt).getTime()
71
117
  : Date.now() + 60_000,
72
118
  };
73
- return cached.token;
74
119
  } catch {
75
- // Signed out / server unreachable -> guest requests.
76
- cached = undefined;
77
- signedOutUntil = Date.now() + SIGNED_OUT_COOLDOWN_MS;
78
- return undefined;
120
+ return { ok: false, kind: "transient" };
121
+ }
122
+ }
123
+
124
+ async function fetchToken(): Promise<string | undefined> {
125
+ let last: FailureKind = "transient";
126
+ for (let i = 0; i < RETRY_DELAYS_MS.length; i++) {
127
+ const wait = RETRY_DELAYS_MS[i];
128
+ if (wait) await sleep(wait);
129
+ const result = await fetchTokenOnce();
130
+ if (result.ok) {
131
+ cached = { token: result.token, expiresAt: result.expiresAt };
132
+ return result.token;
133
+ }
134
+ last = result.kind;
135
+ if (result.kind === "signed-out") {
136
+ cached = undefined;
137
+ signedOutUntil = Date.now() + SIGNED_OUT_COOLDOWN_MS;
138
+ notifySessionInvalidated();
139
+ return undefined;
140
+ }
79
141
  }
142
+ cached = undefined;
143
+ if (last === "transient") {
144
+ unavailableUntil = Date.now() + TRANSIENT_COOLDOWN_MS;
145
+ }
146
+ return undefined;
80
147
  }
81
148
 
82
149
  /** Drop the cached token (sign-out / fresh sign-in). */
@@ -84,4 +151,29 @@ export function clearKeycloakBearerToken() {
84
151
  cached = undefined;
85
152
  pending = undefined;
86
153
  signedOutUntil = 0;
154
+ unavailableUntil = 0;
155
+ }
156
+
157
+ /**
158
+ * Bust the client cache and re-fetch when the tab is focused again, so a
159
+ * front-channel logout (or Keycloak recovery) in another window is noticed
160
+ * without a full reload. Returns an unsubscribe. No-op on the server.
161
+ */
162
+ export function watchKeycloakBearerTokenRevalidate(): () => void {
163
+ if (typeof document === "undefined" || typeof window === "undefined") {
164
+ return () => {};
165
+ }
166
+ const onVisible = () => {
167
+ if (document.visibilityState !== "visible") return;
168
+ cached = undefined;
169
+ signedOutUntil = 0;
170
+ unavailableUntil = 0;
171
+ void getKeycloakBearerToken();
172
+ };
173
+ document.addEventListener("visibilitychange", onVisible);
174
+ window.addEventListener("focus", onVisible);
175
+ return () => {
176
+ document.removeEventListener("visibilitychange", onVisible);
177
+ window.removeEventListener("focus", onVisible);
178
+ };
87
179
  }
package/src/index.ts CHANGED
@@ -6,4 +6,4 @@ export * from "./provider/index";
6
6
  export * from "./session/index";
7
7
  export * from "./token";
8
8
  export * from "./types";
9
- export { getKeycloakBearerToken } from "./frappeToken";
9
+ export { getKeycloakBearerToken, watchKeycloakBearerTokenRevalidate } from "./frappeToken";
@@ -10,6 +10,7 @@ import type KeycloakClient from "@multiplatform.one/keycloak-js";
10
10
  import { isBrowser, isServer, isStorybook, isWeb } from "@multiplatform.one/platform";
11
11
  import { useContext } from "react";
12
12
  import { getAuthClient } from "../betterAuth/client";
13
+ import { clearKeycloakBearerToken } from "../frappeToken";
13
14
  import { useAuthConfig } from "../hooks/index";
14
15
  import type { Session } from "../session/index";
15
16
  import { useSession } from "../session/index";
@@ -52,21 +53,37 @@ export class Keycloak extends BaseKeycloak {
52
53
  async logout(options: KeycloakLogoutOptions = {}) {
53
54
  if (this._keycloakClient) {
54
55
  await this._keycloakClient.logout(options);
56
+ } else if (this._logout) {
57
+ await this._logout(options);
58
+ } else if (typeof window !== "undefined" && window.location?.href) {
59
+ // Front-channel single sign-out. Guard on `window.location` (not `isWeb`):
60
+ // vxrn polyfills `window` on native without a Location, and `isWeb` has
61
+ // been false in some One web bundles — falling through to fetch
62
+ // `signOut()` killed the Better Auth cookie but left the Keycloak SSO
63
+ // session alive, so the next Log in silently signed back in.
64
+ clearKeycloakBearerToken();
65
+ const origin = window.location.origin;
66
+ let callbackURL = options.redirectUri || "/";
67
+ try {
68
+ // Same-origin targets travel as app-relative paths (always trusted);
69
+ // cross-origin ones pass through for trustedOrigins to vet.
70
+ const url = new URL(callbackURL, origin);
71
+ callbackURL =
72
+ url.origin === origin ? `${url.pathname}${url.search}${url.hash}` || "/" : url.toString();
73
+ } catch {
74
+ callbackURL = "/";
75
+ }
76
+ window.location.href = `${origin}/api/auth/sign-out/sso?callbackURL=${encodeURIComponent(callbackURL)}`;
77
+ return;
55
78
  } else if (isBrowser && !isServer) {
56
79
  const client = getAuthClient();
57
80
  await client.signOut({
58
81
  fetchOptions: {
59
82
  onSuccess: () => {
60
- if (options.redirectUri) {
61
- if (isWeb) window.location.href = options.redirectUri;
62
- } else {
63
- if (isWeb) window.location.reload();
64
- }
83
+ if (options.redirectUri && isWeb) window.location.href = options.redirectUri;
65
84
  },
66
85
  },
67
86
  });
68
- } else {
69
- await this._logout?.(options);
70
87
  }
71
88
  this._clear();
72
89
  }
@@ -10,6 +10,7 @@ import type KeycloakClient from "@multiplatform.one/keycloak-js";
10
10
  import { isBrowser, isServer } from "@multiplatform.one/platform";
11
11
  import { useContext } from "react";
12
12
  import { getAuthClient } from "../betterAuth/client";
13
+ import { clearKeycloakBearerToken } from "../frappeToken";
13
14
  import { useAuthConfig } from "../hooks";
14
15
  import type { KeycloakMock } from "../types";
15
16
  import { BaseKeycloak, type KeycloakLoginOptions, type KeycloakLogoutOptions } from "./base";
@@ -43,6 +44,21 @@ export class Keycloak extends BaseKeycloak {
43
44
  async logout(options: KeycloakLogoutOptions = {}) {
44
45
  if (this._keycloakClient) {
45
46
  await this._keycloakClient.logout(options);
47
+ } else if (isBrowser && !isServer && typeof window !== "undefined" && window.location) {
48
+ // Same front-channel ride as the web Keycloak class: ending only the
49
+ // Better Auth session leaves the Keycloak SSO cookie alive, so the
50
+ // next SSO-first hop silently signs the user back in.
51
+ clearKeycloakBearerToken();
52
+ const origin = window.location.origin;
53
+ let callbackURL = options.redirectUri || "/";
54
+ try {
55
+ const url = new URL(callbackURL, origin);
56
+ callbackURL =
57
+ url.origin === origin ? `${url.pathname}${url.search}${url.hash}` || "/" : url.toString();
58
+ } catch {
59
+ callbackURL = "/";
60
+ }
61
+ window.location.href = `${origin}/api/auth/sign-out/sso?callbackURL=${encodeURIComponent(callbackURL)}`;
46
62
  } else if (isBrowser && !isServer) {
47
63
  const client = getAuthClient();
48
64
  await client.signOut({
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildAuthorizationUrl, buildLogoutUrl } from "./authorizationUrl";
3
+ import { keycloakEndpoints } from "./endpoints";
4
+
5
+ const endpoints = keycloakEndpoints({ url: "https://kc.example.com", realm: "myrealm" });
6
+
7
+ const baseParams = {
8
+ endpoints,
9
+ clientId: "app-public",
10
+ redirectUri: "http://127.0.0.1:41234/callback",
11
+ state: "state-token",
12
+ codeChallenge: "challenge-value",
13
+ nonce: "nonce-token",
14
+ };
15
+
16
+ describe("buildAuthorizationUrl", () => {
17
+ it("requests an authorization code with an S256 challenge", () => {
18
+ const url = new URL(buildAuthorizationUrl(baseParams));
19
+ expect(`${url.origin}${url.pathname}`).toBe(endpoints.authorization);
20
+ expect(Object.fromEntries(url.searchParams)).toEqual({
21
+ client_id: "app-public",
22
+ redirect_uri: "http://127.0.0.1:41234/callback",
23
+ response_type: "code",
24
+ scope: "openid profile email",
25
+ state: "state-token",
26
+ nonce: "nonce-token",
27
+ code_challenge: "challenge-value",
28
+ code_challenge_method: "S256",
29
+ });
30
+ });
31
+
32
+ it("sends the nonce the id token will be checked against", () => {
33
+ const url = new URL(buildAuthorizationUrl(baseParams));
34
+ expect(url.searchParams.get("nonce")).toBe("nonce-token");
35
+ });
36
+
37
+ it("never puts a client secret or the verifier in the front-channel url", () => {
38
+ const url = buildAuthorizationUrl(baseParams);
39
+ expect(url).not.toContain("client_secret");
40
+ expect(url).not.toContain("code_verifier");
41
+ });
42
+
43
+ it("honours custom scopes, prompt and login_hint", () => {
44
+ const url = new URL(
45
+ buildAuthorizationUrl({
46
+ ...baseParams,
47
+ scopes: ["openid", "offline_access"],
48
+ prompt: "login",
49
+ loginHint: "testuser",
50
+ }),
51
+ );
52
+ expect(url.searchParams.get("scope")).toBe("openid offline_access");
53
+ expect(url.searchParams.get("prompt")).toBe("login");
54
+ expect(url.searchParams.get("login_hint")).toBe("testuser");
55
+ });
56
+
57
+ it("omits prompt and login_hint when not supplied", () => {
58
+ const url = new URL(buildAuthorizationUrl(baseParams));
59
+ expect(url.searchParams.has("prompt")).toBe(false);
60
+ expect(url.searchParams.has("login_hint")).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe("buildLogoutUrl", () => {
65
+ it("passes the id token hint and post-logout redirect", () => {
66
+ const url = new URL(
67
+ buildLogoutUrl({
68
+ endpoints,
69
+ clientId: "app-public",
70
+ idToken: "id-token",
71
+ postLogoutRedirectUri: "http://127.0.0.1:41234/done",
72
+ }),
73
+ );
74
+ expect(`${url.origin}${url.pathname}`).toBe(endpoints.endSession);
75
+ expect(url.searchParams.get("id_token_hint")).toBe("id-token");
76
+ expect(url.searchParams.get("post_logout_redirect_uri")).toBe("http://127.0.0.1:41234/done");
77
+ });
78
+
79
+ it("omits the hint when there is no id token", () => {
80
+ const url = new URL(buildLogoutUrl({ endpoints, clientId: "app-public" }));
81
+ expect(url.searchParams.has("id_token_hint")).toBe(false);
82
+ });
83
+ });