@multiplatform.one/keycloak 7.34.4 → 7.35.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 +120 -70
- package/dist/cjs/index.native.cjs +283 -148
- package/dist/esm/index.js +78 -28
- package/dist/esm/index.native.js +270 -135
- package/package.json +9 -9
- package/src/betterAuth/expoAuthFlow.deadline.spec.ts +230 -0
- package/src/betterAuth/expoAuthFlow.native.ts +67 -27
- package/src/keycloak/base.ts +3 -2
- package/src/keycloak/context.ts +2 -2
- package/src/keycloak/index.ts +26 -13
- package/src/keycloak/login.error.spec.tsx +85 -0
- package/src/provider/KeycloakProvider.loading.spec.tsx +37 -0
- package/src/provider/KeycloakProvider.tsx +16 -9
- package/src/provider/authProvider/index.native.tsx +170 -108
- package/src/provider/authProvider/nonblocking.native.spec.tsx +303 -0
- package/src/session/authError.ts +30 -0
- package/src/session/index.native.ts +6 -1
- package/src/session/index.ts +11 -4
- package/types/betterAuth/expoAuthFlow.native.d.ts +3 -1
- package/types/betterAuth/expoAuthFlow.native.d.ts.map +1 -1
- package/types/keycloak/base.d.ts +1 -1
- package/types/keycloak/base.d.ts.map +1 -1
- package/types/keycloak/context.d.ts +2 -2
- package/types/keycloak/context.d.ts.map +1 -1
- package/types/keycloak/index.d.ts +1 -1
- package/types/keycloak/index.d.ts.map +1 -1
- package/types/keycloak/index.native.d.ts +1 -1
- package/types/keycloak/index.native.d.ts.map +1 -1
- package/types/keycloak/index.webext.d.ts +1 -1
- package/types/keycloak/index.webext.d.ts.map +1 -1
- package/types/provider/KeycloakProvider.d.ts +3 -2
- package/types/provider/KeycloakProvider.d.ts.map +1 -1
- package/types/provider/authProvider/index.native.d.ts +1 -1
- package/types/provider/authProvider/index.native.d.ts.map +1 -1
- package/types/session/authError.d.ts +7 -0
- package/types/session/authError.d.ts.map +1 -0
- package/types/session/index.d.ts +2 -0
- package/types/session/index.d.ts.map +1 -1
- package/types/session/index.native.d.ts +1 -0
- package/types/session/index.native.d.ts.map +1 -1
|
@@ -14,143 +14,205 @@
|
|
|
14
14
|
import { useEffect, useMemo, useState } from "react";
|
|
15
15
|
import { AppState, type AppStateStatus } from "react-native";
|
|
16
16
|
import * as Linking from "expo-linking";
|
|
17
|
-
import { Loading } from "../../Loading";
|
|
18
17
|
import {
|
|
19
18
|
createExpoAuthFlow,
|
|
20
19
|
rewriteLoopbackHostForNative,
|
|
21
20
|
} from "../../betterAuth/expoAuthFlow.native";
|
|
22
21
|
import { clearKeycloakBearerToken } from "../../frappeToken.native";
|
|
23
|
-
import { KeycloakConfigContext } from "../../keycloak/config";
|
|
24
|
-
import { Keycloak } from "../../keycloak/index";
|
|
22
|
+
import { KeycloakConfigContext, type KeycloakConfig } from "../../keycloak/config";
|
|
23
|
+
import { Keycloak } from "../../keycloak/index.native";
|
|
25
24
|
import type { KeycloakLoginOptions, KeycloakLogoutOptions } from "../../keycloak/base";
|
|
26
25
|
import { KeycloakContext } from "../../keycloak/context";
|
|
27
26
|
import { setNativeSession } from "../../session/index.native";
|
|
27
|
+
import { setAuthError } from "../../session/authError";
|
|
28
|
+
import type { Session } from "../../session/index";
|
|
28
29
|
import { AfterAuth } from "../AfterAuth";
|
|
29
30
|
import type { AuthProviderProps } from "./shared";
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
disabled,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
function createController(
|
|
33
|
+
config: KeycloakConfig,
|
|
34
|
+
disabled: boolean | undefined,
|
|
35
|
+
notify: () => void,
|
|
36
|
+
) {
|
|
37
|
+
const flow = disabled
|
|
38
|
+
? null
|
|
39
|
+
: createExpoAuthFlow(
|
|
40
|
+
rewriteLoopbackHostForNative(config.betterAuthBaseUrl || "http://localhost:8000"),
|
|
41
|
+
config.expoScheme,
|
|
42
|
+
);
|
|
43
|
+
let active = true;
|
|
44
|
+
let sequence = 0;
|
|
45
|
+
let interactive = false;
|
|
46
|
+
let session: Session | null = null;
|
|
47
|
+
const current = (request: number) => active && request === sequence;
|
|
48
|
+
const controller = {
|
|
49
|
+
keycloak: makeKeycloak(null),
|
|
50
|
+
loading: !disabled,
|
|
51
|
+
start() {
|
|
52
|
+
active = true;
|
|
53
|
+
if (!flow) {
|
|
54
|
+
publish(null);
|
|
55
|
+
} else if (!interactive) {
|
|
56
|
+
void refresh().catch(() => undefined);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
stop() {
|
|
60
|
+
active = false;
|
|
61
|
+
sequence++;
|
|
62
|
+
interactive = false;
|
|
63
|
+
},
|
|
64
|
+
refresh,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function makeKeycloak(next: Session | null) {
|
|
68
|
+
const keycloak = new Keycloak(config, undefined, undefined, undefined, login, logout);
|
|
69
|
+
if (next?.user) {
|
|
70
|
+
keycloak.authenticated = true;
|
|
71
|
+
keycloak.subject = next.user.id;
|
|
72
|
+
keycloak.email = next.user.email ?? undefined;
|
|
73
|
+
keycloak.username = next.user.name ?? undefined;
|
|
74
|
+
}
|
|
75
|
+
return keycloak;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function publish(next: Session | null, error?: Error) {
|
|
79
|
+
session = next;
|
|
80
|
+
controller.keycloak = makeKeycloak(next);
|
|
81
|
+
controller.loading = false;
|
|
82
|
+
setAuthError(error);
|
|
83
|
+
setNativeSession(next);
|
|
84
|
+
notify();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function begin() {
|
|
88
|
+
const request = ++sequence;
|
|
89
|
+
controller.loading = true;
|
|
90
|
+
setAuthError();
|
|
91
|
+
setNativeSession(session, "loading");
|
|
92
|
+
notify();
|
|
93
|
+
return request;
|
|
94
|
+
}
|
|
39
95
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
96
|
+
async function probe(request: number) {
|
|
97
|
+
const next = await flow!.getSession();
|
|
98
|
+
if (!current(request)) return;
|
|
99
|
+
publish(
|
|
100
|
+
next?.user
|
|
101
|
+
? {
|
|
102
|
+
user: {
|
|
103
|
+
id: next.user.id,
|
|
104
|
+
name: next.user.name ?? null,
|
|
105
|
+
email: next.user.email ?? null,
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
: null,
|
|
48
109
|
);
|
|
49
|
-
}
|
|
110
|
+
}
|
|
50
111
|
|
|
51
|
-
|
|
112
|
+
function fail(request: number, cause: unknown): never {
|
|
113
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
114
|
+
if (current(request)) publish(null, error);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
52
117
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
118
|
+
async function refresh() {
|
|
119
|
+
if (!active || !flow || interactive) return;
|
|
120
|
+
const request = begin();
|
|
121
|
+
try {
|
|
122
|
+
await probe(request);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
fail(request, error);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
57
127
|
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
128
|
+
async function login(options: KeycloakLoginOptions) {
|
|
129
|
+
if (!active || !flow) return undefined;
|
|
130
|
+
interactive = true;
|
|
131
|
+
const request = begin();
|
|
132
|
+
try {
|
|
133
|
+
await flow.signIn("keycloak", options.redirectUri || Linking.createURL("/"));
|
|
134
|
+
if (!current(request)) return undefined;
|
|
135
|
+
clearKeycloakBearerToken();
|
|
136
|
+
await probe(request);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
fail(request, error);
|
|
139
|
+
} finally {
|
|
140
|
+
if (current(request)) interactive = false;
|
|
62
141
|
}
|
|
63
|
-
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
64
144
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
145
|
+
async function logout(options: KeycloakLogoutOptions) {
|
|
146
|
+
if (!active || !flow) return undefined;
|
|
147
|
+
interactive = true;
|
|
148
|
+
const request = ++sequence;
|
|
149
|
+
clearKeycloakBearerToken();
|
|
150
|
+
publish(null);
|
|
151
|
+
try {
|
|
152
|
+
const redirectUri = options.redirectUri?.includes("://")
|
|
153
|
+
? options.redirectUri
|
|
154
|
+
: Linking.createURL("/");
|
|
155
|
+
await flow.signOut(redirectUri);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
fail(request, error);
|
|
158
|
+
} finally {
|
|
159
|
+
if (current(request)) {
|
|
160
|
+
clearKeycloakBearerToken();
|
|
161
|
+
interactive = false;
|
|
73
162
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
undefined,
|
|
78
|
-
undefined,
|
|
79
|
-
async (options: KeycloakLoginOptions) => {
|
|
80
|
-
// Default the OAuth return URL to the app's own runtime URL: Expo Go
|
|
81
|
-
// can only receive exp:// redirects (Linking.createURL derives
|
|
82
|
-
// exp://<host>/--/ there; the bare app scheme in dev builds).
|
|
83
|
-
await flow.signIn("keycloak", options.redirectUri || Linking.createURL("/"));
|
|
84
|
-
// Drop any signed-out cooldown/stale token so the first Frappe
|
|
85
|
-
// request after sign-in fetches a fresh Bearer token.
|
|
86
|
-
clearKeycloakBearerToken();
|
|
87
|
-
await refresh();
|
|
88
|
-
return undefined;
|
|
89
|
-
},
|
|
90
|
-
async (options: KeycloakLogoutOptions) => {
|
|
91
|
-
// Single sign-out: ends the Better Auth session AND rides the
|
|
92
|
-
// Keycloak end-session endpoint through the system browser (see
|
|
93
|
-
// expoAuthFlow.signOut). Only custom-scheme URLs make sense as the
|
|
94
|
-
// post-logout return on native; web-style paths fall back to the
|
|
95
|
-
// app's own runtime URL.
|
|
96
|
-
const redirectUri =
|
|
97
|
-
options.redirectUri && options.redirectUri.includes("://")
|
|
98
|
-
? options.redirectUri
|
|
99
|
-
: Linking.createURL("/");
|
|
100
|
-
await flow.signOut(redirectUri);
|
|
101
|
-
clearKeycloakBearerToken();
|
|
102
|
-
await refresh();
|
|
103
|
-
return undefined;
|
|
104
|
-
},
|
|
105
|
-
);
|
|
106
|
-
if (session?.user) {
|
|
107
|
-
_keycloak.authenticated = true;
|
|
108
|
-
_keycloak.email = session.user.email || undefined;
|
|
109
|
-
_keycloak.username = session.user.name || undefined;
|
|
110
|
-
_keycloak.subject = session.user.id;
|
|
111
|
-
}
|
|
112
|
-
if (!alive) return;
|
|
113
|
-
setNativeSession(
|
|
114
|
-
session?.user
|
|
115
|
-
? {
|
|
116
|
-
user: {
|
|
117
|
-
id: session.user.id,
|
|
118
|
-
name: session.user.name ?? null,
|
|
119
|
-
email: session.user.email ?? null,
|
|
120
|
-
},
|
|
121
|
-
}
|
|
122
|
-
: null,
|
|
123
|
-
);
|
|
124
|
-
setKeycloak(_keycloak);
|
|
125
|
-
};
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
126
166
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
});
|
|
167
|
+
return controller;
|
|
168
|
+
}
|
|
130
169
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
170
|
+
export function AuthProvider({
|
|
171
|
+
children,
|
|
172
|
+
disabled,
|
|
173
|
+
keycloakConfig,
|
|
174
|
+
loadingComponent: LoadingComponent,
|
|
175
|
+
}: AuthProviderProps) {
|
|
176
|
+
const [, setRevision] = useState(0);
|
|
177
|
+
const { url, realm, clientId, publicClientId, betterAuthBaseUrl, expoScheme } = keycloakConfig;
|
|
178
|
+
const scopes = JSON.stringify(keycloakConfig.scopes);
|
|
179
|
+
const controller = useMemo(
|
|
180
|
+
() =>
|
|
181
|
+
createController(
|
|
182
|
+
{
|
|
183
|
+
url,
|
|
184
|
+
realm,
|
|
185
|
+
clientId,
|
|
186
|
+
publicClientId,
|
|
187
|
+
betterAuthBaseUrl,
|
|
188
|
+
expoScheme,
|
|
189
|
+
scopes: scopes ? JSON.parse(scopes) : undefined,
|
|
190
|
+
},
|
|
191
|
+
disabled,
|
|
192
|
+
() => setRevision((revision) => revision + 1),
|
|
193
|
+
),
|
|
194
|
+
[url, realm, clientId, publicClientId, betterAuthBaseUrl, expoScheme, scopes, disabled],
|
|
195
|
+
);
|
|
139
196
|
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (disabled) return;
|
|
199
|
+
controller.start();
|
|
200
|
+
const appSub = AppState.addEventListener("change", (next: AppStateStatus) => {
|
|
201
|
+
if (next === "active") void controller.refresh().catch(() => undefined);
|
|
202
|
+
});
|
|
140
203
|
return () => {
|
|
141
|
-
|
|
204
|
+
controller.stop();
|
|
142
205
|
appSub.remove();
|
|
143
206
|
};
|
|
144
|
-
}, [
|
|
207
|
+
}, [controller, disabled]);
|
|
145
208
|
|
|
146
209
|
if (disabled) return <>{children}</>;
|
|
147
|
-
|
|
148
|
-
return <Loading loadingComponent={loadingComponent} />;
|
|
149
|
-
}
|
|
210
|
+
|
|
150
211
|
return (
|
|
151
|
-
<KeycloakConfigContext.Provider value={
|
|
152
|
-
<KeycloakContext.Provider value={keycloak}>
|
|
212
|
+
<KeycloakConfigContext.Provider value={controller.keycloak.config}>
|
|
213
|
+
<KeycloakContext.Provider value={controller.keycloak}>
|
|
153
214
|
<AfterAuth>{children}</AfterAuth>
|
|
215
|
+
{controller.loading && LoadingComponent ? <LoadingComponent /> : null}
|
|
154
216
|
</KeycloakContext.Provider>
|
|
155
217
|
</KeycloakConfigContext.Provider>
|
|
156
218
|
);
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { useContext, useEffect, useState, type PropsWithChildren } from "react";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { AppState } from "react-native";
|
|
6
|
+
import { setAuthError } from "../../session/authError";
|
|
7
|
+
import { AuthProvider } from "./index.native";
|
|
8
|
+
import { KeycloakContext } from "../../keycloak/context";
|
|
9
|
+
import { setNativeSession, useSession } from "../../session/index.native";
|
|
10
|
+
import type { KeycloakConfig } from "../../keycloak/config";
|
|
11
|
+
|
|
12
|
+
const { appState, clearBearer, jar, afterAuth } = vi.hoisted(() => ({
|
|
13
|
+
appState: { current: undefined as undefined | ((state: string) => void), remove: vi.fn() },
|
|
14
|
+
clearBearer: vi.fn(),
|
|
15
|
+
jar: new Map<string, string>(),
|
|
16
|
+
afterAuth: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
vi.mock("@multiplatform.one/platform", () => ({
|
|
19
|
+
isBrowser: false,
|
|
20
|
+
isServer: false,
|
|
21
|
+
isStorybook: false,
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("expo-linking", () => ({
|
|
24
|
+
createURL: () => "test://home",
|
|
25
|
+
addEventListener: () => ({ remove() {} }),
|
|
26
|
+
}));
|
|
27
|
+
vi.mock("expo-secure-store", () => ({
|
|
28
|
+
getItem: (key: string) => jar.get(key),
|
|
29
|
+
setItem: (key: string, value: string) => jar.set(key, value),
|
|
30
|
+
}));
|
|
31
|
+
vi.mock("expo-web-browser", () => ({
|
|
32
|
+
openAuthSessionAsync: async () => ({ type: "success", url: "test://home?cookie=session=new" }),
|
|
33
|
+
dismissAuthSession: vi.fn(),
|
|
34
|
+
}));
|
|
35
|
+
vi.mock("../../frappeToken.native", () => ({ clearKeycloakBearerToken: clearBearer }));
|
|
36
|
+
vi.mock("../../hooks/index", () => ({ useAuthConfig: () => ({}) }));
|
|
37
|
+
vi.mock("../../betterAuth/client", () => ({ getAuthClient: vi.fn() }));
|
|
38
|
+
vi.mock("../../Loading", () => ({
|
|
39
|
+
Loading: ({ loadingComponent: Slot }: { loadingComponent?: import("react").ComponentType }) =>
|
|
40
|
+
Slot ? <Slot /> : null,
|
|
41
|
+
}));
|
|
42
|
+
vi.mock("../AfterAuth", () => ({
|
|
43
|
+
AfterAuth: ({ children }: PropsWithChildren) => {
|
|
44
|
+
afterAuth();
|
|
45
|
+
return children;
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const config: KeycloakConfig = {
|
|
50
|
+
url: "https://idp.test/auth",
|
|
51
|
+
realm: "test",
|
|
52
|
+
clientId: "app",
|
|
53
|
+
betterAuthBaseUrl: "https://app.test",
|
|
54
|
+
};
|
|
55
|
+
const mounted = vi.fn();
|
|
56
|
+
let currentKeycloak: import("../../keycloak/base").BaseKeycloak | undefined | null;
|
|
57
|
+
let sessionState: ReturnType<typeof useSession>;
|
|
58
|
+
function Child() {
|
|
59
|
+
currentKeycloak = useContext(KeycloakContext);
|
|
60
|
+
sessionState = useSession();
|
|
61
|
+
const [count, setCount] = useState(0);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
mounted();
|
|
64
|
+
}, []);
|
|
65
|
+
return (
|
|
66
|
+
<>
|
|
67
|
+
<button onClick={() => setCount(count + 1)}>child {count}</button>
|
|
68
|
+
<output>{sessionState.status}</output>
|
|
69
|
+
<span>{sessionState.error?.message}</span>
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
function Slot() {
|
|
74
|
+
return <div>auth slot</div>;
|
|
75
|
+
}
|
|
76
|
+
const json = (data: unknown) => new Response(JSON.stringify(data));
|
|
77
|
+
const user = { user: { id: "fresh", name: "New User" } };
|
|
78
|
+
function deferred<T>() {
|
|
79
|
+
let resolve!: (value: T) => void;
|
|
80
|
+
const promise = new Promise<T>((done) => {
|
|
81
|
+
resolve = done;
|
|
82
|
+
});
|
|
83
|
+
return { promise, resolve };
|
|
84
|
+
}
|
|
85
|
+
function mount(cfg = config) {
|
|
86
|
+
return render(
|
|
87
|
+
<AuthProvider keycloakConfig={cfg} loadingComponent={Slot}>
|
|
88
|
+
<Child />
|
|
89
|
+
</AuthProvider>,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
vi.useFakeTimers();
|
|
95
|
+
vi.spyOn(AppState, "addEventListener").mockImplementation((_event, callback) => {
|
|
96
|
+
appState.current = callback as (state: string) => void;
|
|
97
|
+
return { remove: appState.remove };
|
|
98
|
+
});
|
|
99
|
+
setAuthError();
|
|
100
|
+
currentKeycloak = undefined;
|
|
101
|
+
mounted.mockClear();
|
|
102
|
+
afterAuth.mockClear();
|
|
103
|
+
clearBearer.mockClear();
|
|
104
|
+
appState.remove.mockClear();
|
|
105
|
+
jar.clear();
|
|
106
|
+
setNativeSession(null, "loading");
|
|
107
|
+
});
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
cleanup();
|
|
110
|
+
vi.restoreAllMocks();
|
|
111
|
+
vi.useRealTimers();
|
|
112
|
+
vi.unstubAllGlobals();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("nonblocking native auth", () => {
|
|
116
|
+
it("bypasses auth effects and context when disabled", () => {
|
|
117
|
+
const fetchMock = vi.fn();
|
|
118
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
119
|
+
render(
|
|
120
|
+
<AuthProvider disabled keycloakConfig={config} loadingComponent={Slot}>
|
|
121
|
+
<Child />
|
|
122
|
+
</AuthProvider>,
|
|
123
|
+
);
|
|
124
|
+
expect(screen.queryByText("child 0")).not.toBeNull();
|
|
125
|
+
expect(screen.queryByText("auth slot")).toBeNull();
|
|
126
|
+
expect(currentKeycloak).toBeUndefined();
|
|
127
|
+
expect(afterAuth).not.toHaveBeenCalled();
|
|
128
|
+
expect(AppState.addEventListener).not.toHaveBeenCalled();
|
|
129
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("renders a usable login and preserves child state through timeout and a successful retry", async () => {
|
|
133
|
+
const fetchMock = vi
|
|
134
|
+
.fn()
|
|
135
|
+
.mockImplementationOnce(() => new Promise(() => {}))
|
|
136
|
+
.mockResolvedValue(json(user));
|
|
137
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
138
|
+
mount();
|
|
139
|
+
expect(screen.queryByText("child 0")).not.toBeNull();
|
|
140
|
+
expect(screen.queryByText("auth slot")).not.toBeNull();
|
|
141
|
+
expect(currentKeycloak?.login).toBeTypeOf("function");
|
|
142
|
+
expect(currentKeycloak?.authenticated).toBe(false);
|
|
143
|
+
fireEvent.click(screen.getByText("child 0"));
|
|
144
|
+
await act(() => vi.advanceTimersByTimeAsync(10_000));
|
|
145
|
+
expect(sessionState.status).toBe("unauthenticated");
|
|
146
|
+
expect(sessionState.error?.name).toBe("AuthTimeoutError");
|
|
147
|
+
expect(screen.queryByText("auth slot")).toBeNull();
|
|
148
|
+
expect(screen.queryByText("child 1")).not.toBeNull();
|
|
149
|
+
expect(appState.current).toBeTypeOf("function");
|
|
150
|
+
await act(async () => {
|
|
151
|
+
appState.current?.("active");
|
|
152
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
153
|
+
});
|
|
154
|
+
expect(sessionState.status).toBe("authenticated");
|
|
155
|
+
expect(sessionState.error).toBeUndefined();
|
|
156
|
+
expect(currentKeycloak?.subject).toBe("fresh");
|
|
157
|
+
expect(mounted).toHaveBeenCalledOnce();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("renders children while loading without a loading slot", () => {
|
|
161
|
+
vi.stubGlobal(
|
|
162
|
+
"fetch",
|
|
163
|
+
vi.fn(() => new Promise(() => {})),
|
|
164
|
+
);
|
|
165
|
+
render(
|
|
166
|
+
<AuthProvider keycloakConfig={config}>
|
|
167
|
+
<Child />
|
|
168
|
+
</AuthProvider>,
|
|
169
|
+
);
|
|
170
|
+
expect(screen.queryByText("child 0")).not.toBeNull();
|
|
171
|
+
expect(sessionState.status).toBe("loading");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("login during the initial probe wins over its late signed-out response", async () => {
|
|
175
|
+
const probe = deferred<Response>();
|
|
176
|
+
vi.stubGlobal(
|
|
177
|
+
"fetch",
|
|
178
|
+
vi
|
|
179
|
+
.fn()
|
|
180
|
+
.mockReturnValueOnce(probe.promise)
|
|
181
|
+
.mockResolvedValueOnce(json({ url: "https://idp.test/login" }))
|
|
182
|
+
.mockResolvedValueOnce(json(user)),
|
|
183
|
+
);
|
|
184
|
+
mount();
|
|
185
|
+
expect(currentKeycloak).toBeDefined();
|
|
186
|
+
await act(async () => {
|
|
187
|
+
await currentKeycloak!.login();
|
|
188
|
+
});
|
|
189
|
+
expect(sessionState.status).toBe("authenticated");
|
|
190
|
+
expect(currentKeycloak?.authenticated).toBe(true);
|
|
191
|
+
await act(async () => {
|
|
192
|
+
probe.resolve(json(null));
|
|
193
|
+
});
|
|
194
|
+
expect(sessionState.status).toBe("authenticated");
|
|
195
|
+
expect(currentKeycloak?.authenticated).toBe(true);
|
|
196
|
+
expect(mounted).toHaveBeenCalledOnce();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("rejects native sign-in, publishes the same error, and clears it on retry", async () => {
|
|
200
|
+
const failure = new Error("login offline");
|
|
201
|
+
vi.stubGlobal(
|
|
202
|
+
"fetch",
|
|
203
|
+
vi
|
|
204
|
+
.fn()
|
|
205
|
+
.mockResolvedValueOnce(json(null))
|
|
206
|
+
.mockRejectedValueOnce(failure)
|
|
207
|
+
.mockResolvedValueOnce(json({ url: "https://idp.test/login" }))
|
|
208
|
+
.mockResolvedValueOnce(json(user)),
|
|
209
|
+
);
|
|
210
|
+
await act(async () => {
|
|
211
|
+
mount();
|
|
212
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
213
|
+
});
|
|
214
|
+
await act(async () => {
|
|
215
|
+
await expect(currentKeycloak!.login()).rejects.toBe(failure);
|
|
216
|
+
});
|
|
217
|
+
expect(sessionState.error).toBe(failure);
|
|
218
|
+
await act(async () => {
|
|
219
|
+
await currentKeycloak!.login();
|
|
220
|
+
});
|
|
221
|
+
expect(sessionState.error).toBeUndefined();
|
|
222
|
+
expect(currentKeycloak?.authenticated).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("clears session and bearer state when remote logout fails and ignores a stale probe", async () => {
|
|
226
|
+
const stale = deferred<Response>();
|
|
227
|
+
const failure = new Error("logout offline");
|
|
228
|
+
vi.stubGlobal(
|
|
229
|
+
"fetch",
|
|
230
|
+
vi
|
|
231
|
+
.fn()
|
|
232
|
+
.mockResolvedValueOnce(json(user))
|
|
233
|
+
.mockReturnValueOnce(stale.promise)
|
|
234
|
+
.mockRejectedValue(failure),
|
|
235
|
+
);
|
|
236
|
+
await act(async () => {
|
|
237
|
+
mount();
|
|
238
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
239
|
+
});
|
|
240
|
+
expect(appState.current).toBeTypeOf("function");
|
|
241
|
+
await act(async () => {
|
|
242
|
+
appState.current?.("active");
|
|
243
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
244
|
+
});
|
|
245
|
+
await act(async () => {
|
|
246
|
+
await expect(currentKeycloak!.logout()).rejects.toBe(failure);
|
|
247
|
+
});
|
|
248
|
+
expect(sessionState.status).toBe("unauthenticated");
|
|
249
|
+
expect(sessionState.error).toBe(failure);
|
|
250
|
+
expect(currentKeycloak?.authenticated).toBe(false);
|
|
251
|
+
expect(clearBearer).toHaveBeenCalled();
|
|
252
|
+
await act(async () => {
|
|
253
|
+
stale.resolve(json(user));
|
|
254
|
+
});
|
|
255
|
+
expect(sessionState.status).toBe("unauthenticated");
|
|
256
|
+
expect(sessionState.error).toBe(failure);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("ignores old configuration and unmounted probes without restarting identical config", async () => {
|
|
260
|
+
const old = deferred<Response>();
|
|
261
|
+
const last = deferred<Response>();
|
|
262
|
+
const fetchMock = vi
|
|
263
|
+
.fn()
|
|
264
|
+
.mockReturnValueOnce(old.promise)
|
|
265
|
+
.mockResolvedValueOnce(json(user))
|
|
266
|
+
.mockReturnValueOnce(last.promise);
|
|
267
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
268
|
+
const view = mount();
|
|
269
|
+
view.rerender(
|
|
270
|
+
<AuthProvider keycloakConfig={{ ...config }} loadingComponent={Slot}>
|
|
271
|
+
<Child />
|
|
272
|
+
</AuthProvider>,
|
|
273
|
+
);
|
|
274
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
275
|
+
await act(async () => {
|
|
276
|
+
view.rerender(
|
|
277
|
+
<AuthProvider
|
|
278
|
+
keycloakConfig={{ ...config, betterAuthBaseUrl: "https://new.test" }}
|
|
279
|
+
loadingComponent={Slot}
|
|
280
|
+
>
|
|
281
|
+
<Child />
|
|
282
|
+
</AuthProvider>,
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
expect(sessionState.session?.user?.id).toBe("fresh");
|
|
286
|
+
await act(async () => {
|
|
287
|
+
old.resolve(json({ user: { id: "old" } }));
|
|
288
|
+
});
|
|
289
|
+
expect(sessionState.session?.user?.id).toBe("fresh");
|
|
290
|
+
expect(appState.current).toBeTypeOf("function");
|
|
291
|
+
await act(async () => {
|
|
292
|
+
appState.current?.("active");
|
|
293
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
294
|
+
});
|
|
295
|
+
view.unmount();
|
|
296
|
+
await act(async () => {
|
|
297
|
+
last.resolve(json({ user: { id: "unmounted" } }));
|
|
298
|
+
});
|
|
299
|
+
render(<Child />);
|
|
300
|
+
expect(sessionState.session?.user?.id).not.toBe("unmounted");
|
|
301
|
+
expect(appState.remove).toHaveBeenCalled();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
let error: Error | undefined;
|
|
4
|
+
const listeners = new Set<() => void>();
|
|
5
|
+
|
|
6
|
+
export class AuthTimeoutError extends Error {
|
|
7
|
+
constructor(timeoutMs = 10_000) {
|
|
8
|
+
super(`Authentication request timed out after ${timeoutMs}ms`);
|
|
9
|
+
this.name = "AuthTimeoutError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function setAuthError(next?: Error) {
|
|
14
|
+
if (error === next) return;
|
|
15
|
+
error = next;
|
|
16
|
+
for (const listener of listeners) listener();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getAuthError() {
|
|
20
|
+
return error;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function subscribe(listener: () => void) {
|
|
24
|
+
listeners.add(listener);
|
|
25
|
+
return () => listeners.delete(listener);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useAuthError() {
|
|
29
|
+
return useSyncExternalStore(subscribe, getAuthError, getAuthError);
|
|
30
|
+
}
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { useSyncExternalStore } from "react";
|
|
10
|
+
import { useAuthError } from "./authError";
|
|
11
|
+
|
|
12
|
+
export * from "./authError";
|
|
10
13
|
import type { Session, SessionContextValue, SessionStatus } from "./index";
|
|
11
14
|
|
|
12
15
|
interface NativeSessionState {
|
|
@@ -38,9 +41,11 @@ export async function getSession() {
|
|
|
38
41
|
export function useSession<R extends boolean = false>(
|
|
39
42
|
_options?: Record<string, unknown>,
|
|
40
43
|
): SessionContextValue<R> {
|
|
41
|
-
|
|
44
|
+
const session = useSyncExternalStore(
|
|
42
45
|
subscribe,
|
|
43
46
|
() => state,
|
|
44
47
|
() => state,
|
|
45
48
|
);
|
|
49
|
+
const error = useAuthError();
|
|
50
|
+
return { ...session, error };
|
|
46
51
|
}
|