@authu/react 1.0.65 → 1.0.67
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/README.md +75 -0
- package/dist/AuthUProvider.d.ts.map +1 -1
- package/dist/AuthUProvider.js +214 -184
- package/dist/DeviceLinkQrCode.d.ts +16 -0
- package/dist/DeviceLinkQrCode.d.ts.map +1 -0
- package/dist/DeviceLinkQrCode.js +33 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/pkce.d.ts.map +1 -1
- package/dist/pkce.js +8 -0
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/useDeviceLink.d.ts +14 -0
- package/dist/useDeviceLink.d.ts.map +1 -0
- package/dist/useDeviceLink.js +39 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -128,6 +128,79 @@ function ApiComponent() {
|
|
|
128
128
|
}
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
+
### 5. Sign In Another Device with a QR Code
|
|
132
|
+
|
|
133
|
+
`DeviceLinkQrCode` shows a QR code that signs the scanning phone into the
|
|
134
|
+
current account, with no password:
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
import {DeviceLinkQrCode} from '@authu/react';
|
|
138
|
+
|
|
139
|
+
function OpenOnMobile() {
|
|
140
|
+
return <DeviceLinkQrCode size={220} />;
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The code encodes a one-shot AuthU link that expires after two minutes, and the
|
|
145
|
+
component renews it on its own while mounted. Opening the link gives the phone
|
|
146
|
+
its own AuthU session cookie, then redirects to the tenant `homeUrl`, where the
|
|
147
|
+
silent renewal signs the app in. Since anyone photographing the screen within
|
|
148
|
+
that window gets the session, only mount the component when the user asks for
|
|
149
|
+
it.
|
|
150
|
+
|
|
151
|
+
For a custom rendering, `useDeviceLink()` returns `{link, isLoading, error,
|
|
152
|
+
renew}` with the same auto-renewal, and `createDeviceLink()` on `useAuthU()`
|
|
153
|
+
creates a single link.
|
|
154
|
+
|
|
155
|
+
## Token Storage
|
|
156
|
+
|
|
157
|
+
**Tokens never touch `localStorage`, `sessionStorage` or any other persistent
|
|
158
|
+
store.** The access token and the refresh token live in memory for the lifetime
|
|
159
|
+
of the page: an XSS on the host application can still act as the user while the
|
|
160
|
+
page is open, but it can no longer walk away with a 30-day refresh token and
|
|
161
|
+
replay it from anywhere, later.
|
|
162
|
+
|
|
163
|
+
The trade-off is that a reload loses the tokens. The session itself is not
|
|
164
|
+
lost, though: it lives in AuthU's own cookie, and the SDK re-derives the tokens
|
|
165
|
+
from it on every page load with one silent round trip.
|
|
166
|
+
|
|
167
|
+
### Silent renewal (`prompt=none`)
|
|
168
|
+
|
|
169
|
+
On mount, when no code is coming back in the URL, the SDK redirects to
|
|
170
|
+
`/authorize?prompt=none`. AuthU answers without ever showing a page:
|
|
171
|
+
|
|
172
|
+
- session cookie still valid → it redirects straight back with a fresh
|
|
173
|
+
authorization code, which the SDK exchanges for tokens. The visitor sees a
|
|
174
|
+
single redirect and stays signed in.
|
|
175
|
+
- no session → it redirects back with `error=login_required`. The SDK reads
|
|
176
|
+
that as "anonymous", renders accordingly, and records the attempt in
|
|
177
|
+
`sessionStorage` so the tab never loops through AuthU again.
|
|
178
|
+
|
|
179
|
+
The URL the visitor asked for is saved before the round trip and restored
|
|
180
|
+
afterwards, so landing on `redirectUri` is invisible.
|
|
181
|
+
|
|
182
|
+
Set `silentAuth: false` to opt out. The session then does not survive a reload
|
|
183
|
+
at all: every load starts signed out until `login()` is called.
|
|
184
|
+
|
|
185
|
+
### Logging out
|
|
186
|
+
|
|
187
|
+
`logout()` now navigates to AuthU's `/logout` rather than only clearing memory.
|
|
188
|
+
It has to: the session lives in the AuthU cookie, so a purely local logout
|
|
189
|
+
would let the next silent renewal sign the visitor right back in. The browser
|
|
190
|
+
returns to `redirectUri`, or to `options.returnTo` when given - which must be a
|
|
191
|
+
URI registered for the tenant, or on the tenant's home origin, otherwise AuthU
|
|
192
|
+
falls back to its own host.
|
|
193
|
+
|
|
194
|
+
### Upgrading from 1.0.x
|
|
195
|
+
|
|
196
|
+
Two behaviour changes to expect in an app that upgrades:
|
|
197
|
+
|
|
198
|
+
- any token left in `localStorage` under `authu_tokens` by a previous version
|
|
199
|
+
is deleted on the first load;
|
|
200
|
+
- `logout()` performs a full navigation. Code that expected it to be
|
|
201
|
+
synchronous and stay on the page must move whatever it did afterwards into
|
|
202
|
+
`options.returnTo`.
|
|
203
|
+
|
|
131
204
|
## API Reference
|
|
132
205
|
|
|
133
206
|
### AuthUProvider Props
|
|
@@ -139,6 +212,7 @@ function ApiComponent() {
|
|
|
139
212
|
| `redirectUri` | `string` | Yes | Callback URL after login |
|
|
140
213
|
| `scope` | `string` | No | OAuth2 scopes (default: `openid profile email`) |
|
|
141
214
|
| `audience` | `string` | No | API audience for access tokens |
|
|
215
|
+
| `silentAuth` | `boolean` | No | Silent session renewal (default: `true`, see below) |
|
|
142
216
|
|
|
143
217
|
### useAuthU Returns
|
|
144
218
|
|
|
@@ -151,6 +225,7 @@ function ApiComponent() {
|
|
|
151
225
|
| `login(options?)` | `function` | Redirect to login |
|
|
152
226
|
| `logout(options?)` | `function` | Log out user |
|
|
153
227
|
| `getAccessToken()` | `function` | Get current access token |
|
|
228
|
+
| `createDeviceLink()` | `function` | One-shot sign-in link for another device (`{url, expiresAt} \| null`) |
|
|
154
229
|
|
|
155
230
|
## Development
|
|
156
231
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AuthUProvider.d.ts","sourceRoot":"","sources":["../src/AuthUProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,SAAS,
|
|
1
|
+
{"version":3,"file":"AuthUProvider.d.ts","sourceRoot":"","sources":["../src/AuthUProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,SAAS,EAKf,MAAM,OAAO,CAAC;AAef,OAAO,KAAK,EAEV,WAAW,EACX,iBAAiB,EAKlB,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,YAAY,mDAAgD,CAAC;AAO1E,UAAU,kBAAkB;IAC1B,MAAM,EAAE,WAAW,CAAC;IACpB,QAAQ,EAAE,SAAS,CAAC;CACrB;AAuDD,wBAAgB,aAAa,CAAC,EAAC,MAAM,EAAE,QAAQ,EAAC,EAAE,kBAAkB,2CA6ZnE"}
|
package/dist/AuthUProvider.js
CHANGED
|
@@ -1,23 +1,77 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { createContext, useCallback, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { clearCodeVerifier, clearNonce, clearState, generateCodeChallenge, generateCodeVerifier, generateNonce, generateState, getCodeVerifier, getStoredState, storeCodeVerifier, storeNonce, storeState } from './pkce.js';
|
|
4
4
|
export const AuthUContext = createContext(null);
|
|
5
5
|
function buildBaseUrl(domain) {
|
|
6
6
|
const protocol = domain.startsWith('localhost') ? 'http' : 'https';
|
|
7
7
|
return `${protocol}://${domain}`;
|
|
8
8
|
}
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Tokens are never written to disk. Up to 1.0.x they lived in localStorage
|
|
11
|
+
* under this key, where any XSS on the host app could read them and replay a
|
|
12
|
+
* 30-day refresh token from anywhere; the key is only kept here so an app
|
|
13
|
+
* upgrading to this version wipes what the previous one left behind.
|
|
14
|
+
*/
|
|
15
|
+
const LEGACY_TOKEN_STORAGE_KEY = 'authu_tokens';
|
|
16
|
+
/**
|
|
17
|
+
* Holding the tokens in memory means a reload loses them, so the session is
|
|
18
|
+
* re-derived from the AuthU session cookie instead: one `prompt=none` round
|
|
19
|
+
* trip through /authorize, which returns a fresh code without showing a page
|
|
20
|
+
* when the cookie is still valid, and `error=login_required` when it is not.
|
|
21
|
+
* The attempt is recorded per tab so a signed-out visitor bounces once and
|
|
22
|
+
* then renders anonymously rather than looping through AuthU forever.
|
|
23
|
+
*/
|
|
24
|
+
const SILENT_ATTEMPT_KEY = 'authu_silent_attempted';
|
|
25
|
+
const RETURN_TO_KEY = 'authu_return_to';
|
|
26
|
+
const EMPTY_STATE = {
|
|
27
|
+
isLoading: false,
|
|
28
|
+
isAuthenticated: false,
|
|
29
|
+
user: null,
|
|
30
|
+
accessToken: null,
|
|
31
|
+
refreshToken: null,
|
|
32
|
+
expiresAt: null,
|
|
33
|
+
error: null
|
|
34
|
+
};
|
|
35
|
+
function currentLocation() {
|
|
36
|
+
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Only a same-origin path is ever restored: `//evil.example` is a valid
|
|
40
|
+
* relative URL for the browser but a different origin, so a poisoned entry
|
|
41
|
+
* cannot turn the silent round trip into an open redirect.
|
|
42
|
+
*/
|
|
43
|
+
function readReturnTo() {
|
|
44
|
+
const stored = sessionStorage.getItem(RETURN_TO_KEY);
|
|
45
|
+
sessionStorage.removeItem(RETURN_TO_KEY);
|
|
46
|
+
if (!stored || !stored.startsWith('/') || stored.startsWith('//')) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return stored;
|
|
50
|
+
}
|
|
10
51
|
export function AuthUProvider({ config, children }) {
|
|
11
52
|
const [state, setState] = useState({
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
user: null,
|
|
15
|
-
accessToken: null,
|
|
16
|
-
refreshToken: null,
|
|
17
|
-
expiresAt: null,
|
|
18
|
-
error: null
|
|
53
|
+
...EMPTY_STATE,
|
|
54
|
+
isLoading: true
|
|
19
55
|
});
|
|
20
|
-
|
|
56
|
+
// The tokens also live in a ref so getAccessToken() and refreshTokens() read
|
|
57
|
+
// the current pair rather than the one captured when the callback was built:
|
|
58
|
+
// two concurrent API calls racing on an expiring token would otherwise both
|
|
59
|
+
// rotate the refresh token, and the loser is now treated as a replay.
|
|
60
|
+
const tokensRef = useRef(null);
|
|
61
|
+
const refreshInFlight = useRef(null);
|
|
62
|
+
const setTokens = useCallback((tokens, user) => {
|
|
63
|
+
tokensRef.current = tokens;
|
|
64
|
+
setState(prev => ({
|
|
65
|
+
isLoading: false,
|
|
66
|
+
isAuthenticated: tokens !== null,
|
|
67
|
+
user: tokens === null ? null : user !== undefined ? user : prev.user,
|
|
68
|
+
accessToken: tokens?.accessToken ?? null,
|
|
69
|
+
refreshToken: tokens?.refreshToken ?? null,
|
|
70
|
+
expiresAt: tokens?.expiresAt ?? null,
|
|
71
|
+
error: null
|
|
72
|
+
}));
|
|
73
|
+
}, []);
|
|
74
|
+
const buildAuthorizationUrl = useCallback(async (options) => {
|
|
21
75
|
const codeVerifier = generateCodeVerifier();
|
|
22
76
|
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
23
77
|
const stateValue = generateState();
|
|
@@ -44,6 +98,9 @@ export function AuthUProvider({ config, children }) {
|
|
|
44
98
|
if (options?.screenHint) {
|
|
45
99
|
params.set('screen_hint', options.screenHint);
|
|
46
100
|
}
|
|
101
|
+
if (options?.prompt) {
|
|
102
|
+
params.set('prompt', options.prompt);
|
|
103
|
+
}
|
|
47
104
|
return `${buildBaseUrl(config.domain)}/authorize?${params.toString()}`;
|
|
48
105
|
}, [config]);
|
|
49
106
|
const exchangeCodeForTokensWithVerifier = useCallback(async (code, codeVerifier) => {
|
|
@@ -79,22 +136,31 @@ export function AuthUProvider({ config, children }) {
|
|
|
79
136
|
};
|
|
80
137
|
}, []);
|
|
81
138
|
const login = useCallback(async (options) => {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
139
|
+
sessionStorage.removeItem(SILENT_ATTEMPT_KEY);
|
|
140
|
+
if (options?.returnTo) {
|
|
141
|
+
sessionStorage.setItem(RETURN_TO_KEY, options.returnTo);
|
|
142
|
+
}
|
|
143
|
+
window.location.href = await buildAuthorizationUrl(options);
|
|
144
|
+
}, [buildAuthorizationUrl]);
|
|
145
|
+
/**
|
|
146
|
+
* A local logout is no longer enough: the session now lives in AuthU's own
|
|
147
|
+
* cookie, so clearing memory alone would let the next silent renewal sign
|
|
148
|
+
* the visitor straight back in. The browser is sent to AuthU /logout, which
|
|
149
|
+
* kills the session and bounces back to the app.
|
|
150
|
+
*/
|
|
151
|
+
const logout = useCallback((options) => {
|
|
152
|
+
tokensRef.current = null;
|
|
153
|
+
sessionStorage.setItem(SILENT_ATTEMPT_KEY, '1');
|
|
154
|
+
setState(EMPTY_STATE);
|
|
155
|
+
// The destination has to survive AuthU's own check, which only accepts a
|
|
156
|
+
// URI the tenant registered or its home origin: the configured
|
|
157
|
+
// redirectUri is registered by construction, so it is the safe default.
|
|
158
|
+
const params = new URLSearchParams({
|
|
159
|
+
client_id: config.clientId,
|
|
160
|
+
redirect_uri: options?.returnTo || config.redirectUri
|
|
96
161
|
});
|
|
97
|
-
|
|
162
|
+
window.location.href = `${buildBaseUrl(config.domain)}/logout?${params.toString()}`;
|
|
163
|
+
}, [config.domain, config.clientId, config.redirectUri]);
|
|
98
164
|
const fetchUserInfo = useCallback(async (accessToken) => {
|
|
99
165
|
try {
|
|
100
166
|
const response = await fetch(`${buildBaseUrl(config.domain)}/oauth/userinfo`, {
|
|
@@ -116,206 +182,151 @@ export function AuthUProvider({ config, children }) {
|
|
|
116
182
|
}
|
|
117
183
|
}, [config.domain]);
|
|
118
184
|
const refreshTokens = useCallback(async () => {
|
|
119
|
-
|
|
185
|
+
const refreshToken = tokensRef.current?.refreshToken;
|
|
186
|
+
if (!refreshToken)
|
|
120
187
|
return false;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
188
|
+
if (refreshInFlight.current)
|
|
189
|
+
return refreshInFlight.current;
|
|
190
|
+
const run = async () => {
|
|
191
|
+
try {
|
|
192
|
+
const response = await fetch(`${buildBaseUrl(config.domain)}/oauth/token`, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: { 'Content-Type': 'application/json' },
|
|
195
|
+
body: JSON.stringify({
|
|
196
|
+
grant_type: 'refresh_token',
|
|
197
|
+
refresh_token: refreshToken,
|
|
198
|
+
client_id: config.clientId
|
|
199
|
+
})
|
|
200
|
+
});
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
tokensRef.current = null;
|
|
203
|
+
setState(EMPTY_STATE);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
const tokens = await response.json();
|
|
207
|
+
setTokens({
|
|
208
|
+
accessToken: tokens.access_token,
|
|
209
|
+
refreshToken: tokens.refresh_token ?? null,
|
|
210
|
+
expiresAt: Date.now() + tokens.expires_in * 1000
|
|
211
|
+
}, tokens.id_token ? parseIdToken(tokens.id_token) : undefined);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
tokensRef.current = null;
|
|
216
|
+
setState(EMPTY_STATE);
|
|
133
217
|
return false;
|
|
134
218
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}));
|
|
143
|
-
setState(prev => ({
|
|
144
|
-
...prev,
|
|
145
|
-
accessToken: tokens.access_token,
|
|
146
|
-
refreshToken: tokens.refresh_token,
|
|
147
|
-
expiresAt,
|
|
148
|
-
user: user || prev.user
|
|
149
|
-
}));
|
|
150
|
-
return true;
|
|
151
|
-
}
|
|
152
|
-
catch {
|
|
153
|
-
logout();
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
}, [
|
|
157
|
-
state.refreshToken,
|
|
158
|
-
state.user,
|
|
159
|
-
config.domain,
|
|
160
|
-
config.clientId,
|
|
161
|
-
logout,
|
|
162
|
-
parseIdToken
|
|
163
|
-
]);
|
|
219
|
+
};
|
|
220
|
+
const pending = run().finally(() => {
|
|
221
|
+
refreshInFlight.current = null;
|
|
222
|
+
});
|
|
223
|
+
refreshInFlight.current = pending;
|
|
224
|
+
return pending;
|
|
225
|
+
}, [config.domain, config.clientId, parseIdToken, setTokens]);
|
|
164
226
|
const getAccessToken = useCallback(async () => {
|
|
165
|
-
|
|
227
|
+
const tokens = tokensRef.current;
|
|
228
|
+
if (!tokens)
|
|
166
229
|
return null;
|
|
167
|
-
if (
|
|
230
|
+
if (Date.now() >= tokens.expiresAt - 60000) {
|
|
168
231
|
const refreshed = await refreshTokens();
|
|
169
232
|
if (!refreshed)
|
|
170
233
|
return null;
|
|
171
234
|
}
|
|
172
|
-
return
|
|
173
|
-
}, [
|
|
235
|
+
return tokensRef.current?.accessToken ?? null;
|
|
236
|
+
}, [refreshTokens]);
|
|
237
|
+
const configRef = useRef(config);
|
|
238
|
+
configRef.current = config;
|
|
239
|
+
const bootstrapped = useRef(false);
|
|
174
240
|
useEffect(() => {
|
|
175
|
-
|
|
176
|
-
|
|
241
|
+
if (bootstrapped.current)
|
|
242
|
+
return;
|
|
243
|
+
bootstrapped.current = true;
|
|
244
|
+
// Silent renewal lands on redirectUri, which for most apps is a bare
|
|
245
|
+
// /callback route: the URL is rewritten back to the page the visitor
|
|
246
|
+
// actually asked for. replaceState alone would leave a client-side router
|
|
247
|
+
// still rendering /callback, so a popstate is dispatched to make it
|
|
248
|
+
// re-read the location.
|
|
249
|
+
const restoreUrl = () => {
|
|
250
|
+
const returnTo = readReturnTo();
|
|
251
|
+
window.history.replaceState({}, document.title, returnTo || window.location.pathname);
|
|
252
|
+
if (returnTo) {
|
|
253
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
const bootstrap = async () => {
|
|
257
|
+
localStorage.removeItem(LEGACY_TOKEN_STORAGE_KEY);
|
|
177
258
|
const params = new URLSearchParams(window.location.search);
|
|
178
259
|
const code = params.get('code');
|
|
179
260
|
const returnedState = params.get('state');
|
|
180
261
|
const error = params.get('error');
|
|
181
|
-
console.log('[AuthU SDK] handleCallback params - code:', !!code, 'state:', returnedState, 'error:', error);
|
|
182
262
|
if (error) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
263
|
+
const description = params.get('error_description') ||
|
|
264
|
+
params.get('errorDescription') ||
|
|
265
|
+
error;
|
|
266
|
+
restoreUrl();
|
|
267
|
+
// A refused silent renewal is the expected answer for a visitor who is
|
|
268
|
+
// simply not signed in: it is an anonymous render, not an error.
|
|
269
|
+
if (error === 'login_required' || error === 'interaction_required') {
|
|
270
|
+
sessionStorage.setItem(SILENT_ATTEMPT_KEY, '1');
|
|
271
|
+
setState(EMPTY_STATE);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
setState({ ...EMPTY_STATE, error: new Error(description) });
|
|
188
275
|
return;
|
|
189
276
|
}
|
|
190
277
|
if (code) {
|
|
191
278
|
const storedState = getStoredState();
|
|
192
279
|
if (returnedState !== storedState) {
|
|
193
|
-
setState(
|
|
194
|
-
...
|
|
195
|
-
isLoading: false,
|
|
280
|
+
setState({
|
|
281
|
+
...EMPTY_STATE,
|
|
196
282
|
error: new Error('Invalid state parameter')
|
|
197
|
-
})
|
|
283
|
+
});
|
|
198
284
|
return;
|
|
199
285
|
}
|
|
200
|
-
//
|
|
201
|
-
//
|
|
286
|
+
// Read and clear the verifier in one go: React StrictMode mounts this
|
|
287
|
+
// effect twice, and the second pass must not replay the same code.
|
|
202
288
|
const codeVerifier = getCodeVerifier();
|
|
203
|
-
if (!codeVerifier)
|
|
204
|
-
console.log('[AuthU SDK] No code verifier found, skipping duplicate callback');
|
|
289
|
+
if (!codeVerifier)
|
|
205
290
|
return;
|
|
206
|
-
}
|
|
207
291
|
clearCodeVerifier();
|
|
208
292
|
clearState();
|
|
209
293
|
clearNonce();
|
|
210
|
-
|
|
211
|
-
window.history.replaceState({}, document.title, window.location.pathname);
|
|
294
|
+
restoreUrl();
|
|
212
295
|
try {
|
|
213
296
|
const tokens = await exchangeCodeForTokensWithVerifier(code, codeVerifier);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify({
|
|
217
|
-
accessToken: tokens.access_token,
|
|
218
|
-
refreshToken: tokens.refresh_token,
|
|
219
|
-
expiresAt
|
|
220
|
-
}));
|
|
221
|
-
setState({
|
|
222
|
-
isLoading: false,
|
|
223
|
-
isAuthenticated: true,
|
|
224
|
-
user,
|
|
297
|
+
sessionStorage.removeItem(SILENT_ATTEMPT_KEY);
|
|
298
|
+
setTokens({
|
|
225
299
|
accessToken: tokens.access_token,
|
|
226
|
-
refreshToken: tokens.refresh_token,
|
|
227
|
-
expiresAt
|
|
228
|
-
|
|
229
|
-
|
|
300
|
+
refreshToken: tokens.refresh_token ?? null,
|
|
301
|
+
expiresAt: Date.now() + tokens.expires_in * 1000
|
|
302
|
+
}, tokens.id_token
|
|
303
|
+
? parseIdToken(tokens.id_token)
|
|
304
|
+
: await fetchUserInfo(tokens.access_token));
|
|
230
305
|
}
|
|
231
306
|
catch (err) {
|
|
232
|
-
setState(
|
|
233
|
-
...
|
|
234
|
-
isLoading: false,
|
|
307
|
+
setState({
|
|
308
|
+
...EMPTY_STATE,
|
|
235
309
|
error: err instanceof Error ? err : new Error('Authentication failed')
|
|
236
|
-
})
|
|
310
|
+
});
|
|
237
311
|
}
|
|
238
312
|
return;
|
|
239
313
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const tokens = JSON.parse(stored);
|
|
245
|
-
console.log('[AuthU SDK] Found tokens, expiresAt:', tokens.expiresAt, 'now:', Date.now(), 'valid:', tokens.expiresAt > Date.now());
|
|
246
|
-
if (tokens.expiresAt > Date.now()) {
|
|
247
|
-
const user = await fetchUserInfo(tokens.accessToken);
|
|
248
|
-
console.log('[AuthU SDK] Tokens valid, user:', user);
|
|
249
|
-
setState({
|
|
250
|
-
isLoading: false,
|
|
251
|
-
isAuthenticated: true,
|
|
252
|
-
user,
|
|
253
|
-
accessToken: tokens.accessToken,
|
|
254
|
-
refreshToken: tokens.refreshToken,
|
|
255
|
-
expiresAt: tokens.expiresAt,
|
|
256
|
-
error: null
|
|
257
|
-
});
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
if (tokens.refreshToken) {
|
|
261
|
-
console.log('[AuthU SDK] Access token expired, attempting refresh...');
|
|
262
|
-
try {
|
|
263
|
-
const response = await fetch(`${buildBaseUrl(config.domain)}/oauth/token`, {
|
|
264
|
-
method: 'POST',
|
|
265
|
-
headers: { 'Content-Type': 'application/json' },
|
|
266
|
-
body: JSON.stringify({
|
|
267
|
-
grant_type: 'refresh_token',
|
|
268
|
-
refresh_token: tokens.refreshToken,
|
|
269
|
-
client_id: config.clientId
|
|
270
|
-
})
|
|
271
|
-
});
|
|
272
|
-
if (response.ok) {
|
|
273
|
-
const newTokens = await response.json();
|
|
274
|
-
const expiresAt = Date.now() + newTokens.expires_in * 1000;
|
|
275
|
-
const user = newTokens.id_token
|
|
276
|
-
? parseIdToken(newTokens.id_token)
|
|
277
|
-
: await fetchUserInfo(newTokens.access_token);
|
|
278
|
-
localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify({
|
|
279
|
-
accessToken: newTokens.access_token,
|
|
280
|
-
refreshToken: newTokens.refresh_token,
|
|
281
|
-
expiresAt
|
|
282
|
-
}));
|
|
283
|
-
console.log('[AuthU SDK] Token refresh successful, user:', user);
|
|
284
|
-
setState({
|
|
285
|
-
isLoading: false,
|
|
286
|
-
isAuthenticated: true,
|
|
287
|
-
user,
|
|
288
|
-
accessToken: newTokens.access_token,
|
|
289
|
-
refreshToken: newTokens.refresh_token,
|
|
290
|
-
expiresAt,
|
|
291
|
-
error: null
|
|
292
|
-
});
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
console.log('[AuthU SDK] Token refresh failed, clearing tokens');
|
|
296
|
-
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
|
297
|
-
}
|
|
298
|
-
catch {
|
|
299
|
-
console.log('[AuthU SDK] Token refresh error, clearing tokens');
|
|
300
|
-
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
catch {
|
|
305
|
-
console.log('[AuthU SDK] Error parsing stored tokens, removing');
|
|
306
|
-
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
|
307
|
-
}
|
|
314
|
+
if (configRef.current.silentAuth === false ||
|
|
315
|
+
sessionStorage.getItem(SILENT_ATTEMPT_KEY)) {
|
|
316
|
+
setState(EMPTY_STATE);
|
|
317
|
+
return;
|
|
308
318
|
}
|
|
309
|
-
|
|
310
|
-
|
|
319
|
+
sessionStorage.setItem(SILENT_ATTEMPT_KEY, '1');
|
|
320
|
+
sessionStorage.setItem(RETURN_TO_KEY, currentLocation());
|
|
321
|
+
window.location.replace(await buildAuthorizationUrl({ prompt: 'none' }));
|
|
311
322
|
};
|
|
312
|
-
|
|
323
|
+
bootstrap();
|
|
313
324
|
}, [
|
|
325
|
+
buildAuthorizationUrl,
|
|
314
326
|
exchangeCodeForTokensWithVerifier,
|
|
315
327
|
fetchUserInfo,
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
parseIdToken
|
|
328
|
+
parseIdToken,
|
|
329
|
+
setTokens
|
|
319
330
|
]);
|
|
320
331
|
const checkEmailExists = useCallback(async (email) => {
|
|
321
332
|
try {
|
|
@@ -333,6 +344,24 @@ export function AuthUProvider({ config, children }) {
|
|
|
333
344
|
return false;
|
|
334
345
|
}
|
|
335
346
|
}, [config.clientId, config.domain]);
|
|
347
|
+
const createDeviceLink = useCallback(async () => {
|
|
348
|
+
const accessToken = await getAccessToken();
|
|
349
|
+
if (!accessToken)
|
|
350
|
+
return null;
|
|
351
|
+
try {
|
|
352
|
+
const response = await fetch(`${buildBaseUrl(config.domain)}/api/auth/device-link`, {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
355
|
+
});
|
|
356
|
+
if (!response.ok)
|
|
357
|
+
return null;
|
|
358
|
+
const data = await response.json();
|
|
359
|
+
return { url: data.url, expiresAt: new Date(data.expiresAt).getTime() };
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}, [config.domain, getAccessToken]);
|
|
336
365
|
const contextValue = {
|
|
337
366
|
isLoading: state.isLoading,
|
|
338
367
|
isAuthenticated: state.isAuthenticated,
|
|
@@ -341,7 +370,8 @@ export function AuthUProvider({ config, children }) {
|
|
|
341
370
|
login,
|
|
342
371
|
logout,
|
|
343
372
|
getAccessToken,
|
|
344
|
-
checkEmailExists
|
|
373
|
+
checkEmailExists,
|
|
374
|
+
createDeviceLink
|
|
345
375
|
};
|
|
346
376
|
return (_jsx(AuthUContext.Provider, { value: contextValue, children: children }));
|
|
347
377
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type CSSProperties } from 'react';
|
|
2
|
+
interface DeviceLinkQrCodeProps {
|
|
3
|
+
/** Rendered width and height in pixels */
|
|
4
|
+
size?: number;
|
|
5
|
+
color?: string;
|
|
6
|
+
background?: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
style?: CSSProperties;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* QR code that signs the scanning device into the current account. The link
|
|
12
|
+
* renews itself while mounted; nothing is rendered when signed out.
|
|
13
|
+
*/
|
|
14
|
+
export declare function DeviceLinkQrCode({ size, color, background, className, style }: DeviceLinkQrCodeProps): import("react/jsx-runtime").JSX.Element | null;
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=DeviceLinkQrCode.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DeviceLinkQrCode.d.ts","sourceRoot":"","sources":["../src/DeviceLinkQrCode.tsx"],"names":[],"mappings":"AACA,OAAO,EAAC,KAAK,aAAa,EAAU,MAAM,OAAO,CAAC;AAGlD,UAAU,qBAAqB;IAC7B,0CAA0C;IAC1C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,aAAa,CAAC;CACvB;AAKD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,IAAU,EACV,KAAiB,EACjB,UAAsB,EACtB,SAAS,EACT,KAAK,EACN,EAAE,qBAAqB,kDAsCvB"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import qrcode from 'qrcode-generator';
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { useDeviceLink } from './useDeviceLink.js';
|
|
5
|
+
/** Quiet zone around the code, in modules, required by scanners */
|
|
6
|
+
const MARGIN = 2;
|
|
7
|
+
/**
|
|
8
|
+
* QR code that signs the scanning device into the current account. The link
|
|
9
|
+
* renews itself while mounted; nothing is rendered when signed out.
|
|
10
|
+
*/
|
|
11
|
+
export function DeviceLinkQrCode({ size = 200, color = '#000000', background = '#ffffff', className, style }) {
|
|
12
|
+
const { link } = useDeviceLink();
|
|
13
|
+
const qr = useMemo(() => {
|
|
14
|
+
if (!link)
|
|
15
|
+
return null;
|
|
16
|
+
const code = qrcode(0, 'M');
|
|
17
|
+
code.addData(link.url);
|
|
18
|
+
code.make();
|
|
19
|
+
const count = code.getModuleCount();
|
|
20
|
+
let path = '';
|
|
21
|
+
for (let row = 0; row < count; row++) {
|
|
22
|
+
for (let col = 0; col < count; col++) {
|
|
23
|
+
if (code.isDark(row, col)) {
|
|
24
|
+
path += `M${col + MARGIN} ${row + MARGIN}h1v1h-1z`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { path, viewBox: count + MARGIN * 2 };
|
|
29
|
+
}, [link]);
|
|
30
|
+
if (!qr)
|
|
31
|
+
return null;
|
|
32
|
+
return (_jsxs("svg", { role: "img", "aria-label": "Sign-in QR code", width: size, height: size, viewBox: `0 0 ${qr.viewBox} ${qr.viewBox}`, shapeRendering: "crispEdges", className: className, style: style, children: [_jsx("rect", { width: qr.viewBox, height: qr.viewBox, fill: background }), _jsx("path", { d: qr.path, fill: color })] }));
|
|
33
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export { AuthUProvider } from './AuthUProvider.js';
|
|
2
|
+
export { DeviceLinkQrCode } from './DeviceLinkQrCode.js';
|
|
2
3
|
export { PrivateRoute } from './PrivateRoute.js';
|
|
3
4
|
export * from './pkce.js';
|
|
4
|
-
export type { AuthUConfig, AuthUContextValue, AuthUUser } from './types.js';
|
|
5
|
+
export type { AuthUConfig, AuthUContextValue, AuthUUser, DeviceLink } from './types.js';
|
|
5
6
|
export { useApiToken } from './useApiToken.js';
|
|
6
7
|
export { useAuthU } from './useAuthU.js';
|
|
8
|
+
export { useDeviceLink } from './useDeviceLink.js';
|
|
7
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,aAAa,EAAC,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAC,YAAY,EAAC,MAAM,mBAAmB,CAAC;AAC/C,cAAc,WAAW,CAAC;AAC1B,YAAY,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,aAAa,EAAC,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAC,gBAAgB,EAAC,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAC,YAAY,EAAC,MAAM,mBAAmB,CAAC;AAC/C,cAAc,WAAW,CAAC;AAC1B,YAAY,EACV,WAAW,EACX,iBAAiB,EACjB,SAAS,EACT,UAAU,EACX,MAAM,YAAY,CAAC;AACpB,OAAO,EAAC,WAAW,EAAC,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAC,QAAQ,EAAC,MAAM,eAAe,CAAC;AACvC,OAAO,EAAC,aAAa,EAAC,MAAM,oBAAoB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { AuthUProvider } from './AuthUProvider.js';
|
|
2
|
+
export { DeviceLinkQrCode } from './DeviceLinkQrCode.js';
|
|
2
3
|
export { PrivateRoute } from './PrivateRoute.js';
|
|
3
4
|
export * from './pkce.js';
|
|
4
5
|
export { useApiToken } from './useApiToken.js';
|
|
5
6
|
export { useAuthU } from './useAuthU.js';
|
|
7
|
+
export { useDeviceLink } from './useDeviceLink.js';
|
package/dist/pkce.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pkce.d.ts","sourceRoot":"","sources":["../src/pkce.ts"],"names":[],"mappings":"AA2BA,wBAAgB,oBAAoB,IAAI,MAAM,CAE7C;AAED,wBAAsB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG7E;AAED,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAED,wBAAgB,aAAa,IAAI,MAAM,CAEtC;
|
|
1
|
+
{"version":3,"file":"pkce.d.ts","sourceRoot":"","sources":["../src/pkce.ts"],"names":[],"mappings":"AA2BA,wBAAgB,oBAAoB,IAAI,MAAM,CAE7C;AAED,wBAAsB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG7E;AAED,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAED,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAcD,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAExD;AAED,wBAAgB,eAAe,IAAI,MAAM,GAAG,IAAI,CAE/C;AAED,wBAAgB,iBAAiB,IAAI,IAAI,CAExC;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAE9C;AAED,wBAAgB,cAAc,IAAI,MAAM,GAAG,IAAI,CAE9C;AAED,wBAAgB,UAAU,IAAI,IAAI,CAEjC;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAE9C;AAED,wBAAgB,cAAc,IAAI,MAAM,GAAG,IAAI,CAE9C;AAED,wBAAgB,UAAU,IAAI,IAAI,CAEjC"}
|
package/dist/pkce.js
CHANGED
|
@@ -34,6 +34,14 @@ export function generateState() {
|
|
|
34
34
|
export function generateNonce() {
|
|
35
35
|
return generateRandomString(32);
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* The verifier, the state and the nonce have to outlive a full navigation to
|
|
39
|
+
* AuthU and back. They stay in localStorage rather than sessionStorage on
|
|
40
|
+
* purpose: 1.0.10 moved them there after sessionStorage lost them across the
|
|
41
|
+
* cross-domain round trip. They are single-use, consumed within seconds, and
|
|
42
|
+
* cleared as soon as the callback is handled - unlike the tokens, which never
|
|
43
|
+
* touch any storage at all.
|
|
44
|
+
*/
|
|
37
45
|
const STORAGE_KEY_VERIFIER = 'authu_code_verifier';
|
|
38
46
|
const STORAGE_KEY_STATE = 'authu_state';
|
|
39
47
|
const STORAGE_KEY_NONCE = 'authu_nonce';
|
package/dist/types.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ export interface AuthUConfig {
|
|
|
4
4
|
redirectUri: string;
|
|
5
5
|
scope?: string;
|
|
6
6
|
audience?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Silent session renewal through `/authorize?prompt=none`, on by default.
|
|
9
|
+
* Tokens are held in memory only, so this round trip is what carries a
|
|
10
|
+
* session across a page reload. Turning it off means every reload starts
|
|
11
|
+
* signed out.
|
|
12
|
+
*/
|
|
13
|
+
silentAuth?: boolean;
|
|
7
14
|
}
|
|
8
15
|
export interface AuthUUser {
|
|
9
16
|
sub: string;
|
|
@@ -21,6 +28,16 @@ export interface AuthUContextValue {
|
|
|
21
28
|
logout: (options?: LogoutOptions) => void;
|
|
22
29
|
getAccessToken: () => Promise<string | null>;
|
|
23
30
|
checkEmailExists: (email: string) => Promise<boolean>;
|
|
31
|
+
/**
|
|
32
|
+
* One-shot link that signs another device into the same account, meant to
|
|
33
|
+
* be shown as a QR code. Expires after two minutes; null when signed out.
|
|
34
|
+
*/
|
|
35
|
+
createDeviceLink: () => Promise<DeviceLink | null>;
|
|
36
|
+
}
|
|
37
|
+
export interface DeviceLink {
|
|
38
|
+
url: string;
|
|
39
|
+
/** Epoch milliseconds */
|
|
40
|
+
expiresAt: number;
|
|
24
41
|
}
|
|
25
42
|
export interface LoginOptions {
|
|
26
43
|
returnTo?: string;
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,OAAO,CAAC;IACnB,eAAe,EAAE,OAAO,CAAC;IACzB,IAAI,EAAE,SAAS,GAAG,IAAI,CAAC;IACvB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjD,MAAM,EAAE,CAAC,OAAO,CAAC,EAAE,aAAa,KAAK,IAAI,CAAC;IAC1C,cAAc,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC7C,gBAAgB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACtD;;;OAGG;IACH,gBAAgB,EAAE,MAAM,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;CACpD;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,yBAAyB;IACzB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,UAAU,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC;CACjC;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,SAAS;IACxB,SAAS,EAAE,OAAO,CAAC;IACnB,eAAe,EAAE,OAAO,CAAC;IACzB,IAAI,EAAE,SAAS,GAAG,IAAI,CAAC;IACvB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CACrB"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DeviceLink } from './types.js';
|
|
2
|
+
interface UseDeviceLinkReturn {
|
|
3
|
+
link: DeviceLink | null;
|
|
4
|
+
isLoading: boolean;
|
|
5
|
+
error: Error | null;
|
|
6
|
+
renew: () => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Keeps a fresh device link while the component is mounted (and `enabled`),
|
|
10
|
+
* so a QR code can stay on screen without ever showing an expired link.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useDeviceLink(enabled?: boolean): UseDeviceLinkReturn;
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=useDeviceLink.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useDeviceLink.d.ts","sourceRoot":"","sources":["../src/useDeviceLink.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,YAAY,CAAC;AAS3C,UAAU,mBAAmB;IAC3B,IAAI,EAAE,UAAU,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,UAAO,GAAG,mBAAmB,CA8BjE"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { useAuthU } from './useAuthU.js';
|
|
3
|
+
/**
|
|
4
|
+
* Renewed this long before it expires, so a phone scanning at the last second
|
|
5
|
+
* still lands on a valid link.
|
|
6
|
+
*/
|
|
7
|
+
const RENEW_MARGIN_MS = 15_000;
|
|
8
|
+
/**
|
|
9
|
+
* Keeps a fresh device link while the component is mounted (and `enabled`),
|
|
10
|
+
* so a QR code can stay on screen without ever showing an expired link.
|
|
11
|
+
*/
|
|
12
|
+
export function useDeviceLink(enabled = true) {
|
|
13
|
+
const { createDeviceLink, isAuthenticated } = useAuthU();
|
|
14
|
+
const [link, setLink] = useState(null);
|
|
15
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
16
|
+
const [error, setError] = useState(null);
|
|
17
|
+
const renew = useCallback(async () => {
|
|
18
|
+
setIsLoading(true);
|
|
19
|
+
const created = await createDeviceLink();
|
|
20
|
+
setLink(created);
|
|
21
|
+
setError(created ? null : new Error('Could not create a device link'));
|
|
22
|
+
setIsLoading(false);
|
|
23
|
+
}, [createDeviceLink]);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!enabled || !isAuthenticated) {
|
|
26
|
+
setLink(null);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
renew();
|
|
30
|
+
}, [enabled, isAuthenticated, renew]);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!enabled || !link)
|
|
33
|
+
return;
|
|
34
|
+
const delay = Math.max(link.expiresAt - Date.now() - RENEW_MARGIN_MS, 0);
|
|
35
|
+
const timer = setTimeout(renew, delay);
|
|
36
|
+
return () => clearTimeout(timer);
|
|
37
|
+
}, [enabled, link, renew]);
|
|
38
|
+
return { link, isLoading, error, renew };
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@authu/react",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.67",
|
|
4
4
|
"description": "React SDK for AuthU - Centralized Multi-Tenant Authentication Service",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -43,5 +43,8 @@
|
|
|
43
43
|
"@types/react": "^19.0.4",
|
|
44
44
|
"react": "^19.0.0",
|
|
45
45
|
"typescript": "^5.7.3"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"qrcode-generator": "^2.0.4"
|
|
46
49
|
}
|
|
47
50
|
}
|