@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.
- 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 +16 -11
- package/src/betterAuth/client.ts +3 -1
- 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/client.d.ts +2 -791
- package/types/betterAuth/client.d.ts.map +1 -1
- 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,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
|
-
|
|
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
|
+
});
|
package/src/frappeToken.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
64
|
-
signedOutUntil = Date.now() + SIGNED_OUT_COOLDOWN_MS;
|
|
65
|
-
return undefined;
|
|
110
|
+
return { ok: false, kind: "signed-out" };
|
|
66
111
|
}
|
|
67
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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";
|
package/src/keycloak/index.ts
CHANGED
|
@@ -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
|
+
});
|