@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 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,EAIf,MAAM,OAAO,CAAC;AAef,OAAO,KAAK,EAEV,WAAW,EACX,iBAAiB,EAElB,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,YAAY,mDAAgD,CAAC;AAO1E,UAAU,kBAAkB;IAC1B,MAAM,EAAE,WAAW,CAAC;IACpB,QAAQ,EAAE,SAAS,CAAC;CACrB;AAID,wBAAgB,aAAa,CAAC,EAAC,MAAM,EAAE,QAAQ,EAAC,EAAE,kBAAkB,2CAqcnE"}
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"}
@@ -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
- const TOKEN_STORAGE_KEY = 'authu_tokens';
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
- isLoading: true,
13
- isAuthenticated: false,
14
- user: null,
15
- accessToken: null,
16
- refreshToken: null,
17
- expiresAt: null,
18
- error: null
53
+ ...EMPTY_STATE,
54
+ isLoading: true
19
55
  });
20
- const getAuthorizationUrl = useCallback(async (options) => {
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
- const url = await getAuthorizationUrl(options);
83
- console.log('[AuthU SDK] login() called, redirecting to:', url);
84
- window.location.href = url;
85
- }, [getAuthorizationUrl]);
86
- const logout = useCallback(() => {
87
- localStorage.removeItem(TOKEN_STORAGE_KEY);
88
- setState({
89
- isLoading: false,
90
- isAuthenticated: false,
91
- user: null,
92
- accessToken: null,
93
- refreshToken: null,
94
- expiresAt: null,
95
- error: null
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
- if (!state.refreshToken)
185
+ const refreshToken = tokensRef.current?.refreshToken;
186
+ if (!refreshToken)
120
187
  return false;
121
- try {
122
- const response = await fetch(`${buildBaseUrl(config.domain)}/oauth/token`, {
123
- method: 'POST',
124
- headers: { 'Content-Type': 'application/json' },
125
- body: JSON.stringify({
126
- grant_type: 'refresh_token',
127
- refresh_token: state.refreshToken,
128
- client_id: config.clientId
129
- })
130
- });
131
- if (!response.ok) {
132
- logout();
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
- const tokens = await response.json();
136
- const expiresAt = Date.now() + tokens.expires_in * 1000;
137
- const user = tokens.id_token ? parseIdToken(tokens.id_token) : state.user;
138
- localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify({
139
- accessToken: tokens.access_token,
140
- refreshToken: tokens.refresh_token,
141
- expiresAt
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
- if (!state.accessToken)
227
+ const tokens = tokensRef.current;
228
+ if (!tokens)
166
229
  return null;
167
- if (state.expiresAt && Date.now() >= state.expiresAt - 60000) {
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 state.accessToken;
173
- }, [state.accessToken, state.expiresAt, refreshTokens]);
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
- const handleCallback = async () => {
176
- console.log('[AuthU SDK] handleCallback starting, URL:', window.location.href);
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
- setState(prev => ({
184
- ...prev,
185
- isLoading: false,
186
- error: new Error(params.get('error_description') || error)
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(prev => ({
194
- ...prev,
195
- isLoading: false,
280
+ setState({
281
+ ...EMPTY_STATE,
196
282
  error: new Error('Invalid state parameter')
197
- }));
283
+ });
198
284
  return;
199
285
  }
200
- // Get and immediately clear code verifier to prevent duplicate exchanges
201
- // (React StrictMode double-mount protection)
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
- // Clear URL immediately to prevent issues on refresh
211
- window.history.replaceState({}, document.title, window.location.pathname);
294
+ restoreUrl();
212
295
  try {
213
296
  const tokens = await exchangeCodeForTokensWithVerifier(code, codeVerifier);
214
- const user = parseIdToken(tokens.id_token);
215
- const expiresAt = Date.now() + tokens.expires_in * 1000;
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
- error: null
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(prev => ({
233
- ...prev,
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
- const stored = localStorage.getItem(TOKEN_STORAGE_KEY);
241
- console.log('[AuthU SDK] Checking localStorage for tokens:', !!stored);
242
- if (stored) {
243
- try {
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
- console.log('[AuthU SDK] No valid tokens found, setting isLoading: false');
310
- setState(prev => ({ ...prev, isLoading: false }));
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
- handleCallback();
323
+ bootstrap();
313
324
  }, [
325
+ buildAuthorizationUrl,
314
326
  exchangeCodeForTokensWithVerifier,
315
327
  fetchUserInfo,
316
- config.clientId,
317
- config.domain,
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
@@ -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,EAAC,WAAW,EAAE,iBAAiB,EAAE,SAAS,EAAC,MAAM,YAAY,CAAC;AAC1E,OAAO,EAAC,WAAW,EAAC,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAC,QAAQ,EAAC,MAAM,eAAe,CAAC"}
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';
@@ -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;AAMD,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"}
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;
@@ -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;CACnB;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;CACvD;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"}
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.65",
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
  }