@acl-identity/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +82 -0
- package/dist/index.d.mts +121 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +252 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +252 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +37 -0
- package/src/components/callback-handler.tsx +53 -0
- package/src/components/sign-in-button.tsx +55 -0
- package/src/components/silent-callback-page.tsx +32 -0
- package/src/context.ts +30 -0
- package/src/hooks/use-auth.ts +13 -0
- package/src/index.ts +20 -0
- package/src/provider.tsx +221 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +11 -0
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import type { ACLBrowserClient } from '@acl-identity/browser';
|
|
9
|
+
import { tokenExpiresAt } from '@acl-identity/core';
|
|
10
|
+
import type { UserClaims } from '@acl-identity/types';
|
|
11
|
+
import { AuthContext, type SignInOptions, type SignOutOptions } from './context';
|
|
12
|
+
|
|
13
|
+
export interface AuthProviderProps {
|
|
14
|
+
client: ACLBrowserClient;
|
|
15
|
+
/** OAuth2 client_id — passed to buildAuthorizationUrl and silentRenew */
|
|
16
|
+
clientId: string;
|
|
17
|
+
/** Redirect URI registered with the auth server for the main auth flow */
|
|
18
|
+
redirectUri: string;
|
|
19
|
+
/** Organization slug for multi-tenant routing */
|
|
20
|
+
orgSlug?: string;
|
|
21
|
+
/** Space-separated OAuth2 scopes. Defaults to 'openid profile email' */
|
|
22
|
+
scopes?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Redirect URI for silent token renewal via hidden iframe.
|
|
25
|
+
* Must be registered with the auth server. The page at this URI
|
|
26
|
+
* must render <SilentCallbackPage />.
|
|
27
|
+
*/
|
|
28
|
+
silentRedirectUri?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Called after the OAuth2 redirect callback is processed.
|
|
31
|
+
* Use this to navigate the user to their intended destination.
|
|
32
|
+
*/
|
|
33
|
+
onRedirectCallback?: (appState?: unknown) => void;
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface AuthState {
|
|
38
|
+
user: UserClaims | null;
|
|
39
|
+
isAuthenticated: boolean;
|
|
40
|
+
isLoading: boolean;
|
|
41
|
+
error: Error | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PKCE_KEY_PREFIX = 'acl:pkce:';
|
|
45
|
+
|
|
46
|
+
export function AuthProvider({
|
|
47
|
+
client,
|
|
48
|
+
clientId,
|
|
49
|
+
redirectUri,
|
|
50
|
+
orgSlug,
|
|
51
|
+
scopes,
|
|
52
|
+
silentRedirectUri,
|
|
53
|
+
onRedirectCallback,
|
|
54
|
+
children,
|
|
55
|
+
}: AuthProviderProps) {
|
|
56
|
+
const [state, setState] = useState<AuthState>({
|
|
57
|
+
user: null,
|
|
58
|
+
isAuthenticated: false,
|
|
59
|
+
isLoading: true,
|
|
60
|
+
error: null,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const initialized = useRef(false);
|
|
64
|
+
|
|
65
|
+
const syncAuthState = useCallback(async () => {
|
|
66
|
+
const [user, isAuthenticated] = await Promise.all([
|
|
67
|
+
client.auth.parseCurrentUser(),
|
|
68
|
+
client.auth.isAuthenticated(),
|
|
69
|
+
]);
|
|
70
|
+
setState({ user, isAuthenticated, isLoading: false, error: null });
|
|
71
|
+
}, [client]);
|
|
72
|
+
|
|
73
|
+
const handleSilentRenew = useCallback(async () => {
|
|
74
|
+
if (!silentRedirectUri) {
|
|
75
|
+
setState((s) => ({ ...s, isLoading: false }));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
await client.auth.silentRenew(silentRedirectUri, clientId, { orgSlug, scope: scopes });
|
|
80
|
+
await syncAuthState();
|
|
81
|
+
} catch {
|
|
82
|
+
setState({ user: null, isAuthenticated: false, isLoading: false, error: null });
|
|
83
|
+
}
|
|
84
|
+
}, [client, silentRedirectUri, clientId, orgSlug, scopes, syncAuthState]);
|
|
85
|
+
|
|
86
|
+
// Initial session check — runs once on mount
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (initialized.current) return;
|
|
89
|
+
initialized.current = true;
|
|
90
|
+
|
|
91
|
+
const url = new URL(window.location.href);
|
|
92
|
+
const isCallbackUrl = url.searchParams.has('code') && url.searchParams.has('state');
|
|
93
|
+
|
|
94
|
+
if (isCallbackUrl) {
|
|
95
|
+
// Stay loading while CallbackHandler processes the code exchange
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
client.auth.isAuthenticated().then((isAuth) => {
|
|
100
|
+
if (isAuth) {
|
|
101
|
+
syncAuthState();
|
|
102
|
+
} else {
|
|
103
|
+
handleSilentRenew();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
107
|
+
|
|
108
|
+
// Schedule proactive silent renew before the current token expires
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!state.user || !silentRedirectUri) return;
|
|
111
|
+
|
|
112
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
113
|
+
|
|
114
|
+
client.auth.getAccessToken().then((token) => {
|
|
115
|
+
if (!token) return;
|
|
116
|
+
const expiresAtMs = tokenExpiresAt(token);
|
|
117
|
+
if (!expiresAtMs) return;
|
|
118
|
+
|
|
119
|
+
const msUntilRenew = expiresAtMs - Date.now() - 30_000;
|
|
120
|
+
if (msUntilRenew <= 0) {
|
|
121
|
+
handleSilentRenew();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
timer = setTimeout(handleSilentRenew, msUntilRenew);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return () => clearTimeout(timer);
|
|
128
|
+
}, [state.user, silentRedirectUri, handleSilentRenew, client]);
|
|
129
|
+
|
|
130
|
+
const signIn = useCallback(
|
|
131
|
+
async (options?: SignInOptions) => {
|
|
132
|
+
const { url, state: authState, codeVerifier } = await client.auth.buildAuthorizationUrl({
|
|
133
|
+
clientId,
|
|
134
|
+
redirectUri,
|
|
135
|
+
orgSlug,
|
|
136
|
+
scope: scopes,
|
|
137
|
+
});
|
|
138
|
+
sessionStorage.setItem(
|
|
139
|
+
`${PKCE_KEY_PREFIX}${authState}`,
|
|
140
|
+
JSON.stringify({ codeVerifier, appState: options?.appState ?? null })
|
|
141
|
+
);
|
|
142
|
+
window.location.href = url;
|
|
143
|
+
},
|
|
144
|
+
[client, clientId, redirectUri, orgSlug, scopes]
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const signOut = useCallback(
|
|
148
|
+
async (options?: SignOutOptions) => {
|
|
149
|
+
await client.auth.logout({ revokeToken: options?.revokeToken });
|
|
150
|
+
setState({ user: null, isAuthenticated: false, isLoading: false, error: null });
|
|
151
|
+
if (options?.returnTo) {
|
|
152
|
+
window.location.href = options.returnTo;
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
[client]
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const getAccessToken = useCallback(() => client.auth.getAccessToken(), [client]);
|
|
159
|
+
|
|
160
|
+
const handleCallback = useCallback(
|
|
161
|
+
async (callbackUrl?: string): Promise<{ appState?: unknown }> => {
|
|
162
|
+
const url = new URL(callbackUrl ?? window.location.href);
|
|
163
|
+
const error = url.searchParams.get('error');
|
|
164
|
+
|
|
165
|
+
if (error) {
|
|
166
|
+
const desc = url.searchParams.get('error_description') ?? error;
|
|
167
|
+
const err = new Error(desc);
|
|
168
|
+
setState((s) => ({ ...s, error: err, isLoading: false }));
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const code = url.searchParams.get('code');
|
|
173
|
+
const authState = url.searchParams.get('state');
|
|
174
|
+
|
|
175
|
+
if (!code || !authState) {
|
|
176
|
+
const err = new Error('Missing code or state in callback URL');
|
|
177
|
+
setState((s) => ({ ...s, error: err, isLoading: false }));
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const stored = sessionStorage.getItem(`${PKCE_KEY_PREFIX}${authState}`);
|
|
182
|
+
if (!stored) {
|
|
183
|
+
const err = new Error('Auth session expired or state mismatch. Please sign in again.');
|
|
184
|
+
setState((s) => ({ ...s, error: err, isLoading: false }));
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { codeVerifier, appState } = JSON.parse(stored) as {
|
|
189
|
+
codeVerifier: string;
|
|
190
|
+
appState?: unknown;
|
|
191
|
+
};
|
|
192
|
+
sessionStorage.removeItem(`${PKCE_KEY_PREFIX}${authState}`);
|
|
193
|
+
|
|
194
|
+
await client.auth.exchangeCode(code, codeVerifier, redirectUri);
|
|
195
|
+
await syncAuthState();
|
|
196
|
+
|
|
197
|
+
onRedirectCallback?.(appState);
|
|
198
|
+
return { appState };
|
|
199
|
+
},
|
|
200
|
+
[client, redirectUri, syncAuthState, onRedirectCallback]
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<AuthContext.Provider
|
|
205
|
+
value={{
|
|
206
|
+
...state,
|
|
207
|
+
client,
|
|
208
|
+
clientId,
|
|
209
|
+
redirectUri,
|
|
210
|
+
orgSlug,
|
|
211
|
+
scopes,
|
|
212
|
+
signIn,
|
|
213
|
+
signOut,
|
|
214
|
+
getAccessToken,
|
|
215
|
+
handleCallback,
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
{children}
|
|
219
|
+
</AuthContext.Provider>
|
|
220
|
+
);
|
|
221
|
+
}
|
package/tsconfig.json
ADDED
package/tsup.config.ts
ADDED