@seamless-auth/react 0.4.0 → 0.6.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/CHANGELOG.md +144 -0
- package/LICENSE.md +2 -2
- package/README.md +333 -63
- package/dist/AuthProvider.d.ts +20 -19
- package/dist/client/createSeamlessAuthClient.d.ts +83 -60
- package/dist/client/errors.d.ts +15 -0
- package/dist/client/result.d.ts +36 -0
- package/dist/client/webauthnSupport.d.ts +10 -0
- package/dist/components/AuthFallbackOptions.d.ts +1 -1
- package/dist/hooks/useAuthClient.d.ts +1 -1
- package/dist/index.d.ts +12 -10
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/routes.d.ts +21 -0
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
- `useAuthClient()`
|
|
18
18
|
- `usePasskeySupport()`
|
|
19
19
|
- `hasScopedRole()` and `roleGrantsAccess()`
|
|
20
|
-
-
|
|
20
|
+
- `SeamlessAuthError`, the error type carried on a failed result
|
|
21
|
+
- types including `AuthContextType`, `Credential`, `User`, `OAuthProvider`, `StepUpStatus`, the `SeamlessAuthResult` wrapper, and the headless client input/result types
|
|
21
22
|
|
|
22
23
|
## Installation
|
|
23
24
|
|
|
@@ -114,21 +115,21 @@ You are still responsible for your app’s route protection and redirects.
|
|
|
114
115
|
markSignedIn(): void;
|
|
115
116
|
hasRole(role: string): boolean | undefined;
|
|
116
117
|
hasScopedRole(role: string | string[]): boolean | undefined;
|
|
117
|
-
listOAuthProviders(): Promise<OAuthProvidersResult
|
|
118
|
-
startOAuthLogin(input: StartOAuthLoginInput): Promise<StartOAuthLoginResult
|
|
119
|
-
finishOAuthLogin(input: FinishOAuthLoginInput): Promise<
|
|
120
|
-
refreshSession(): Promise<
|
|
121
|
-
refreshStepUpStatus(): Promise<StepUpStatus
|
|
122
|
-
verifyStepUpWithPasskey(): Promise<
|
|
123
|
-
verifyStepUpWithPasskeyPrf(input: PasskeyPrfInput): Promise<
|
|
124
|
-
verifyStepUpWithTotp(code: string): Promise<
|
|
125
|
-
logout(): Promise<
|
|
126
|
-
logoutAllSessions(): Promise<
|
|
127
|
-
deleteUser(): Promise<
|
|
128
|
-
login(identifier: string, passkeyAvailable: boolean): Promise<
|
|
129
|
-
handlePasskeyLogin(): Promise<
|
|
130
|
-
updateCredential(credential: Credential): Promise<Credential
|
|
131
|
-
deleteCredential(credentialId: string): Promise<
|
|
118
|
+
listOAuthProviders(): Promise<SeamlessAuthResult<OAuthProvidersResult>>;
|
|
119
|
+
startOAuthLogin(input: StartOAuthLoginInput): Promise<SeamlessAuthResult<StartOAuthLoginResult>>;
|
|
120
|
+
finishOAuthLogin(input: FinishOAuthLoginInput): Promise<SeamlessAuthResult<MessageResult>>;
|
|
121
|
+
refreshSession(): Promise<SeamlessAuthResult<CurrentUserResult>>;
|
|
122
|
+
refreshStepUpStatus(): Promise<SeamlessAuthResult<StepUpStatus>>;
|
|
123
|
+
verifyStepUpWithPasskey(): Promise<SeamlessAuthResult<StepUpStatus>>;
|
|
124
|
+
verifyStepUpWithPasskeyPrf(input: PasskeyPrfInput): Promise<SeamlessAuthResult<StepUpPrfData>>;
|
|
125
|
+
verifyStepUpWithTotp(code: string): Promise<SeamlessAuthResult<StepUpStatus>>;
|
|
126
|
+
logout(): Promise<SeamlessAuthResult<MessageResult>>;
|
|
127
|
+
logoutAllSessions(): Promise<SeamlessAuthResult<MessageResult>>;
|
|
128
|
+
deleteUser(): Promise<SeamlessAuthResult<MessageResult>>;
|
|
129
|
+
login(identifier: string, passkeyAvailable: boolean): Promise<SeamlessAuthResult<LoginStartResult>>;
|
|
130
|
+
handlePasskeyLogin(): Promise<SeamlessAuthResult<PasskeyLoginData>>;
|
|
131
|
+
updateCredential(credential: Credential): Promise<SeamlessAuthResult<Credential>>;
|
|
132
|
+
deleteCredential(credentialId: string): Promise<SeamlessAuthResult<MessageResult>>;
|
|
132
133
|
}
|
|
133
134
|
```
|
|
134
135
|
|
|
@@ -160,12 +161,12 @@ If you are building a fully custom flow, call `markSignedIn()` after a successfu
|
|
|
160
161
|
const { markSignedIn, refreshSession } = useAuth();
|
|
161
162
|
|
|
162
163
|
async function completeLogin() {
|
|
163
|
-
const
|
|
164
|
+
const { error } = await authClient.login({
|
|
164
165
|
identifier: 'user@example.com',
|
|
165
166
|
passkeyAvailable: true,
|
|
166
167
|
});
|
|
167
168
|
|
|
168
|
-
if (
|
|
169
|
+
if (!error) {
|
|
169
170
|
markSignedIn();
|
|
170
171
|
await refreshSession();
|
|
171
172
|
}
|
|
@@ -201,8 +202,8 @@ function DeleteAccountButton() {
|
|
|
201
202
|
const { refreshStepUpStatus, verifyStepUpWithPasskey } = useAuth();
|
|
202
203
|
|
|
203
204
|
async function handleDeleteAccount() {
|
|
204
|
-
const status = await refreshStepUpStatus();
|
|
205
|
-
const fresh = status?.fresh ? true : (await verifyStepUpWithPasskey()).
|
|
205
|
+
const { data: status } = await refreshStepUpStatus();
|
|
206
|
+
const fresh = status?.fresh ? true : !(await verifyStepUpWithPasskey()).error;
|
|
206
207
|
|
|
207
208
|
if (!fresh) {
|
|
208
209
|
return;
|
|
@@ -215,13 +216,13 @@ function DeleteAccountButton() {
|
|
|
215
216
|
}
|
|
216
217
|
```
|
|
217
218
|
|
|
218
|
-
Step-up supports WebAuthn/passkeys and TOTP (authenticator apps). `refreshStepUpStatus()` calls `/step-up/status`, `verifyStepUpWithPasskey()` performs the `/step-up/webauthn/start` and `/step-up/webauthn/finish` challenge flow, and `verifyStepUpWithTotp(code)` verifies a 6-digit authenticator code via `/totp/verify-mfa`.
|
|
219
|
+
Step-up supports WebAuthn/passkeys and TOTP (authenticator apps). `refreshStepUpStatus()` calls `/step-up/status`, `verifyStepUpWithPasskey()` performs the `/step-up/webauthn/start` and `/step-up/webauthn/finish` challenge flow, and `verifyStepUpWithTotp(code)` verifies a 6-digit authenticator code via `/totp/verify-mfa`. The verification helpers return a `SeamlessAuthResult<StepUpStatus>` and refresh the provider's `stepUpStatus` when they succeed.
|
|
219
220
|
|
|
220
221
|
```tsx
|
|
221
222
|
const { verifyStepUpWithTotp } = useAuth();
|
|
222
223
|
|
|
223
|
-
const
|
|
224
|
-
if (
|
|
224
|
+
const { error } = await verifyStepUpWithTotp('123456'); // 6-digit code from the authenticator app
|
|
225
|
+
if (!error) {
|
|
225
226
|
// step-up is fresh; proceed with the sensitive action
|
|
226
227
|
}
|
|
227
228
|
```
|
|
@@ -237,16 +238,14 @@ import type { TotpStatus, TotpEnrollmentStartResult } from '@seamless-auth/react
|
|
|
237
238
|
const authClient = createSeamlessAuthClient({ apiHost: 'https://your.api' });
|
|
238
239
|
|
|
239
240
|
// 1. Check whether TOTP is already enabled
|
|
240
|
-
const
|
|
241
|
+
const { data: status } = await authClient.getTotpStatus();
|
|
241
242
|
|
|
242
243
|
// 2. Start enrollment: render `otpauthUrl` as a QR code (or show `secret` for manual entry)
|
|
243
|
-
const
|
|
244
|
-
await authClient.startTotpEnrollment()
|
|
245
|
-
).json();
|
|
244
|
+
const { data: enrollment } = await authClient.startTotpEnrollment();
|
|
246
245
|
|
|
247
246
|
// 3. Confirm the first code from the user's authenticator app
|
|
248
|
-
const
|
|
249
|
-
if (
|
|
247
|
+
const { error } = await authClient.verifyTotpEnrollment('123456');
|
|
248
|
+
if (!error) {
|
|
250
249
|
// TOTP is now enabled
|
|
251
250
|
}
|
|
252
251
|
|
|
@@ -254,7 +253,7 @@ if (verifyResponse.ok) {
|
|
|
254
253
|
await authClient.disableTotp('123456');
|
|
255
254
|
```
|
|
256
255
|
|
|
257
|
-
|
|
256
|
+
These methods follow the standard result convention: check `error`, then read `data`. Enrolling TOTP is a sensitive change; gate it behind a fresh step-up when appropriate.
|
|
258
257
|
|
|
259
258
|
> TOTP is not currently a login second factor. The Seamless Auth API issues a full session on the first factor and does not gate login on TOTP, so TOTP applies to step-up verification, not to the login flow.
|
|
260
259
|
|
|
@@ -292,18 +291,18 @@ if (prfSupported) {
|
|
|
292
291
|
For local key unwrap flows such as Seamless Secrets, use PRF during step-up and consume the returned bytes in browser memory:
|
|
293
292
|
|
|
294
293
|
```ts
|
|
295
|
-
const
|
|
294
|
+
const { data, error } = await authClient.verifyStepUpWithPasskeyPrf({
|
|
296
295
|
salt: vaultSaltBase64url,
|
|
297
296
|
credentialId,
|
|
298
297
|
});
|
|
299
298
|
|
|
300
|
-
if (
|
|
301
|
-
throw
|
|
299
|
+
if (error) {
|
|
300
|
+
throw error;
|
|
302
301
|
}
|
|
303
302
|
|
|
304
303
|
const vaultUnlockMaterial: { credentialId: string; output: Uint8Array } = {
|
|
305
|
-
credentialId:
|
|
306
|
-
output:
|
|
304
|
+
credentialId: data.credentialId,
|
|
305
|
+
output: data.prf.output,
|
|
307
306
|
};
|
|
308
307
|
```
|
|
309
308
|
|
|
@@ -405,7 +404,7 @@ For production providers, configure exact `redirectUris` on the Seamless Auth AP
|
|
|
405
404
|
send the callback URL it expects to receive, but redirect allowlisting, signed state expiry, OIDC
|
|
406
405
|
nonce handling, email verification policy, and account-linking policy are enforced by the API.
|
|
407
406
|
|
|
408
|
-
The built-in views avoid logging OTPs, magic-link tokens,
|
|
407
|
+
The built-in views avoid logging OTPs, magic-link tokens, PRF salts, or raw
|
|
409
408
|
exception payloads that may contain sensitive request URLs.
|
|
410
409
|
|
|
411
410
|
## Headless Client
|
|
@@ -419,14 +418,18 @@ const authClient = createSeamlessAuthClient({
|
|
|
419
418
|
apiHost: 'https://your.api',
|
|
420
419
|
});
|
|
421
420
|
|
|
422
|
-
const
|
|
421
|
+
const { data, error } = await authClient.login({
|
|
423
422
|
identifier: 'user@example.com',
|
|
424
423
|
passkeyAvailable: true,
|
|
425
424
|
});
|
|
426
425
|
|
|
427
|
-
if (
|
|
428
|
-
//
|
|
426
|
+
if (error) {
|
|
427
|
+
// error.message, error.status, and error.body carry the server detail
|
|
428
|
+
return;
|
|
429
429
|
}
|
|
430
|
+
|
|
431
|
+
// data is typed as LoginStartResult
|
|
432
|
+
console.log(data.loginMethods);
|
|
430
433
|
```
|
|
431
434
|
|
|
432
435
|
The headless client exposes helpers for:
|
|
@@ -443,17 +446,40 @@ The headless client exposes helpers for:
|
|
|
443
446
|
- logout and delete-user
|
|
444
447
|
- credential update and deletion
|
|
445
448
|
|
|
446
|
-
|
|
449
|
+
### Result convention
|
|
450
|
+
|
|
451
|
+
Every request method resolves to a `SeamlessAuthResult<T>`:
|
|
452
|
+
|
|
453
|
+
```ts
|
|
454
|
+
type SeamlessAuthResult<T> =
|
|
455
|
+
| { data: T; error: null }
|
|
456
|
+
| { data: null; error: SeamlessAuthError };
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
Check `error` first, then read `data`. TypeScript enforces this: `data` is not readable until the
|
|
460
|
+
error has been ruled out.
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
const { data, error } = await authClient.getCurrentUser();
|
|
464
|
+
|
|
465
|
+
if (error) {
|
|
466
|
+
console.log(error.message, error.status, error.body);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
setUser(data.user); // typed as CurrentUserResult
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Nothing throws for an HTTP failure, and transport failures are absorbed too, reported as an error
|
|
474
|
+
with `status` `0`. That means an expected auth outcome such as a wrong OTP, an expired magic link, or
|
|
475
|
+
a disabled provider is a value you can map straight to UI state rather than an exception to catch.
|
|
476
|
+
|
|
477
|
+
`SeamlessAuthError` carries the server's `message`, the HTTP `status`, and the parsed response
|
|
478
|
+
`body`, so you can branch on a specific failure.
|
|
447
479
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
- `verifyStepUpWithPasskey(): Promise<StepUpVerificationResult>`
|
|
452
|
-
- `verifyStepUpWithTotp(code): Promise<StepUpVerificationResult>`
|
|
453
|
-
- `verifyStepUpWithPasskeyPrf(input): Promise<StepUpWithPasskeyPrfResult>`
|
|
454
|
-
- `listOAuthProviders(): Promise<OAuthProvidersResult>`
|
|
455
|
-
- `startOAuthLogin(input): Promise<StartOAuthLoginResult>`
|
|
456
|
-
- `finishOAuthLogin(input): Promise<Response>`
|
|
480
|
+
The single exception is `isPasskeySupported`-style capability checks:
|
|
481
|
+
`isPasskeyPrfSupported(): Promise<boolean>` is a local check rather than a request, so it returns a
|
|
482
|
+
plain boolean.
|
|
457
483
|
|
|
458
484
|
## React Hooks For Custom UI
|
|
459
485
|
|
|
@@ -468,12 +494,12 @@ function CustomLogin() {
|
|
|
468
494
|
const { passkeySupported, loading } = usePasskeySupport();
|
|
469
495
|
|
|
470
496
|
async function handleEmailLogin() {
|
|
471
|
-
const
|
|
497
|
+
const { error } = await authClient.login({
|
|
472
498
|
identifier: 'user@example.com',
|
|
473
499
|
passkeyAvailable: passkeySupported,
|
|
474
500
|
});
|
|
475
501
|
|
|
476
|
-
if (
|
|
502
|
+
if (!error) {
|
|
477
503
|
await refreshSession();
|
|
478
504
|
}
|
|
479
505
|
}
|
|
@@ -486,21 +512,260 @@ function CustomLogin() {
|
|
|
486
512
|
}
|
|
487
513
|
```
|
|
488
514
|
|
|
515
|
+
### One error style everywhere
|
|
516
|
+
|
|
517
|
+
`useAuth()` helpers and the headless client report failure the same way: both return
|
|
518
|
+
`{ data, error }` and neither throws. Whatever surface you reach for, the handling is identical.
|
|
519
|
+
|
|
520
|
+
```tsx
|
|
521
|
+
const { error } = await updateCredential({ ...credential, friendlyName: 'Work laptop' });
|
|
522
|
+
|
|
523
|
+
if (error) {
|
|
524
|
+
setMessage(error.message);
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
Helpers that also mutate provider state, such as `switchOrganization` and `deleteCredential`, apply
|
|
529
|
+
that state change only when the call succeeds, then hand the result back for you to inspect.
|
|
530
|
+
|
|
531
|
+
## Custom UI Recipes
|
|
532
|
+
|
|
533
|
+
Worked examples for the flows the bundled screens cover, using only public primitives.
|
|
534
|
+
|
|
535
|
+
### Custom registration
|
|
536
|
+
|
|
537
|
+
Registration is two steps: create the account, then verify the emailed code. Call `markSignedIn()`
|
|
538
|
+
once the account is live so returning visits can default to sign-in.
|
|
539
|
+
|
|
540
|
+
```tsx
|
|
541
|
+
import { useAuth, useAuthClient } from '@seamless-auth/react';
|
|
542
|
+
import { useState } from 'react';
|
|
543
|
+
|
|
544
|
+
function CustomRegistration() {
|
|
545
|
+
const { markSignedIn, refreshSession } = useAuth();
|
|
546
|
+
const authClient = useAuthClient();
|
|
547
|
+
const [step, setStep] = useState<'details' | 'verify'>('details');
|
|
548
|
+
const [message, setMessage] = useState('');
|
|
549
|
+
|
|
550
|
+
async function createAccount(email: string) {
|
|
551
|
+
// Registration needs only an email. A phone can be added and verified later.
|
|
552
|
+
const { error } = await authClient.register({ email });
|
|
553
|
+
|
|
554
|
+
if (error) {
|
|
555
|
+
setMessage(error.message);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// The API emails a verification code as part of registering.
|
|
560
|
+
setStep('verify');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function verifyCode(code: string) {
|
|
564
|
+
const { error } = await authClient.verifyEmailOtp(code);
|
|
565
|
+
|
|
566
|
+
if (error) {
|
|
567
|
+
setMessage(error.message);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
markSignedIn();
|
|
572
|
+
await refreshSession();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return step === 'details' ? (
|
|
576
|
+
<DetailsForm onSubmit={createAccount} error={message} />
|
|
577
|
+
) : (
|
|
578
|
+
<CodeForm
|
|
579
|
+
onSubmit={verifyCode}
|
|
580
|
+
onResend={() => authClient.requestEmailOtp()}
|
|
581
|
+
error={message}
|
|
582
|
+
/>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
To offer a passkey right after registering, call `registerPasskey()` before `refreshSession()`:
|
|
588
|
+
|
|
589
|
+
```ts
|
|
590
|
+
const { data, error } = await authClient.registerPasskey({
|
|
591
|
+
friendlyName: 'My laptop',
|
|
592
|
+
platform: 'macOS',
|
|
593
|
+
browser: 'Chrome',
|
|
594
|
+
deviceInfo: navigator.userAgent,
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
if (!error) {
|
|
598
|
+
console.log(data.credentialId, data.prfCapable);
|
|
599
|
+
}
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### OTP and magic-link continuation
|
|
603
|
+
|
|
604
|
+
> **The request helpers take no identifier.** `requestMagicLink()`, `requestLoginEmailOtp()`, and
|
|
605
|
+
> `requestLoginPhoneOtp()` send nothing but the session cookie. They rely on server-side state
|
|
606
|
+
> established by a preceding `login()` call, so calling them without it fails or targets the wrong
|
|
607
|
+
> account. This is not obvious from their signatures. Always call `login()` first, and use the same
|
|
608
|
+
> browser session for the continuation step.
|
|
609
|
+
|
|
610
|
+
```tsx
|
|
611
|
+
function CustomLoginContinuation() {
|
|
612
|
+
const { login, refreshSession } = useAuth();
|
|
613
|
+
const authClient = useAuthClient();
|
|
614
|
+
|
|
615
|
+
async function start(identifier: string) {
|
|
616
|
+
// Required first: this is what the request helpers below depend on.
|
|
617
|
+
const { data, error } = await login(identifier, false);
|
|
618
|
+
|
|
619
|
+
if (error) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Offer only what the server says this account supports.
|
|
624
|
+
return data.loginMethods ?? ['magic_link', 'email_otp'];
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function sendEmailCode() {
|
|
628
|
+
const { error } = await authClient.requestLoginEmailOtp();
|
|
629
|
+
if (error) {
|
|
630
|
+
// surface error.message
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function submitEmailCode(code: string) {
|
|
635
|
+
const { error } = await authClient.verifyLoginEmailOtp(code);
|
|
636
|
+
|
|
637
|
+
if (!error) {
|
|
638
|
+
await refreshSession();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
Magic links complete in whichever tab opens the emailed link, so a custom flow needs two pieces.
|
|
645
|
+
|
|
646
|
+
The waiting screen polls until the link is used:
|
|
647
|
+
|
|
648
|
+
```ts
|
|
649
|
+
const interval = setInterval(async () => {
|
|
650
|
+
const { error } = await authClient.checkMagicLink();
|
|
651
|
+
|
|
652
|
+
if (!error) {
|
|
653
|
+
clearInterval(interval);
|
|
654
|
+
await refreshSession();
|
|
655
|
+
}
|
|
656
|
+
}, 5000);
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
The landing route verifies the token from the query string, then refreshes its own session:
|
|
660
|
+
|
|
661
|
+
```tsx
|
|
662
|
+
function CustomMagicLinkLanding() {
|
|
663
|
+
const { refreshSession } = useAuth(); // plus: import { useEffect } from 'react'
|
|
664
|
+
const authClient = useAuthClient();
|
|
665
|
+
|
|
666
|
+
useEffect(() => {
|
|
667
|
+
const token = new URLSearchParams(window.location.search).get('token');
|
|
668
|
+
if (!token) return;
|
|
669
|
+
|
|
670
|
+
void authClient.verifyMagicLink(token).then(async ({ error }) => {
|
|
671
|
+
if (!error) {
|
|
672
|
+
// Refresh here too. This tab set the cookie, but its provider state
|
|
673
|
+
// was loaded before the cookie existed.
|
|
674
|
+
await refreshSession();
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
}, [authClient, refreshSession]);
|
|
678
|
+
|
|
679
|
+
return <p>Finishing sign-in...</p>;
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
The auth API emails a link pointing at `/verify-magiclink?token=...`, so a custom app must serve that
|
|
684
|
+
path.
|
|
685
|
+
|
|
686
|
+
### Credential management
|
|
687
|
+
|
|
688
|
+
`useAuth()` exposes the signed-in user's passkeys plus helpers to rename and remove them. These
|
|
689
|
+
helpers update provider state on success and report failure through `error`.
|
|
690
|
+
|
|
691
|
+
```tsx
|
|
692
|
+
import { useAuth } from '@seamless-auth/react';
|
|
693
|
+
import type { Credential } from '@seamless-auth/react';
|
|
694
|
+
import { useState } from 'react';
|
|
695
|
+
|
|
696
|
+
function PasskeyList() {
|
|
697
|
+
const { credentials, updateCredential, deleteCredential } = useAuth();
|
|
698
|
+
const [message, setMessage] = useState('');
|
|
699
|
+
|
|
700
|
+
async function rename(credential: Credential, friendlyName: string) {
|
|
701
|
+
const { error } = await updateCredential({ ...credential, friendlyName });
|
|
702
|
+
|
|
703
|
+
if (error) {
|
|
704
|
+
setMessage(error.message);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function remove(credentialId: string) {
|
|
709
|
+
const { error } = await deleteCredential(credentialId);
|
|
710
|
+
|
|
711
|
+
if (error) {
|
|
712
|
+
setMessage(error.message);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return (
|
|
717
|
+
<ul>
|
|
718
|
+
{credentials.map(credential => (
|
|
719
|
+
<li key={credential.id}>
|
|
720
|
+
{credential.friendlyName ?? credential.deviceInfo}
|
|
721
|
+
<button onClick={() => void rename(credential, 'Work laptop')}>Rename</button>
|
|
722
|
+
<button onClick={() => void remove(credential.id)}>Remove</button>
|
|
723
|
+
</li>
|
|
724
|
+
))}
|
|
725
|
+
</ul>
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
Removing a passkey is a sensitive change. Gate it behind a fresh step-up when the account has other
|
|
731
|
+
factors, using `refreshStepUpStatus()` and `verifyStepUpWithPasskey()` from the step-up section.
|
|
732
|
+
|
|
489
733
|
## Built-In Routes
|
|
490
734
|
|
|
491
|
-
`AuthRoutes`
|
|
735
|
+
`AuthRoutes` serves these canonical paths:
|
|
492
736
|
|
|
493
737
|
- `/login`
|
|
494
|
-
- `/
|
|
495
|
-
- `/
|
|
496
|
-
- `/
|
|
497
|
-
- `/verify-
|
|
738
|
+
- `/passkey-login`
|
|
739
|
+
- `/verify-phone-otp`
|
|
740
|
+
- `/verify-email-otp`
|
|
741
|
+
- `/verify-magic-link`
|
|
498
742
|
- `/oauth/callback`
|
|
499
|
-
- `/
|
|
500
|
-
- `/
|
|
743
|
+
- `/register-passkey`
|
|
744
|
+
- `/magic-link-sent`
|
|
501
745
|
|
|
502
746
|
These are optional UI wrappers over the same SDK primitives the package now exports for custom flows.
|
|
503
747
|
|
|
748
|
+
### Renamed routes
|
|
749
|
+
|
|
750
|
+
The earlier mixed-case paths were renamed and are no longer served. Anything linking directly to
|
|
751
|
+
them now falls through to `/login`, so update those links:
|
|
752
|
+
|
|
753
|
+
| Old path | New path |
|
|
754
|
+
| ------------------ | ------------------- |
|
|
755
|
+
| `/passKeyLogin` | `/passkey-login` |
|
|
756
|
+
| `/verifyPhoneOTP` | `/verify-phone-otp` |
|
|
757
|
+
| `/verifyEmailOTP` | `/verify-email-otp` |
|
|
758
|
+
| `/registerPasskey` | `/register-passkey` |
|
|
759
|
+
| `/magiclinks-sent` | `/magic-link-sent` |
|
|
760
|
+
|
|
761
|
+
Two paths are unchanged because they are owned by contracts outside this package:
|
|
762
|
+
|
|
763
|
+
- `/verify-magiclink` is the URL the auth API builds when it emails a magic link, so it has to match
|
|
764
|
+
that value exactly. Renaming it here would send every emailed link to `/login` with the token
|
|
765
|
+
discarded.
|
|
766
|
+
- `/oauth/callback` is registered with OAuth providers as an allowed redirect URI, so renaming it
|
|
767
|
+
would break configured integrations.
|
|
768
|
+
|
|
504
769
|
## Backend Expectations
|
|
505
770
|
|
|
506
771
|
This package assumes a Seamless Auth-compatible backend with the auth adapter mounted at `/auth`.
|
|
@@ -520,15 +785,15 @@ The built-in flows assume compatible endpoints for:
|
|
|
520
785
|
- `/webAuthn/login/finish`
|
|
521
786
|
- `/webAuthn/register/start`
|
|
522
787
|
- `/webAuthn/register/finish`
|
|
523
|
-
-
|
|
524
|
-
-
|
|
788
|
+
- `POST /otp/generate-phone-otp`
|
|
789
|
+
- `POST /otp/generate-email-otp`
|
|
525
790
|
- `/otp/verify-phone-otp`
|
|
526
791
|
- `/otp/verify-email-otp`
|
|
527
|
-
-
|
|
528
|
-
-
|
|
792
|
+
- `POST /otp/generate-login-phone-otp`
|
|
793
|
+
- `POST /otp/generate-login-email-otp`
|
|
529
794
|
- `/otp/verify-login-phone-otp`
|
|
530
795
|
- `/otp/verify-login-email-otp`
|
|
531
|
-
-
|
|
796
|
+
- `POST /magic-link`
|
|
532
797
|
- `/magic-link/check`
|
|
533
798
|
- `/magic-link/verify/:token`
|
|
534
799
|
- `/oauth/providers`
|
|
@@ -551,6 +816,11 @@ The built-in flows assume compatible endpoints for:
|
|
|
551
816
|
- `/organizations/:organizationId/members`
|
|
552
817
|
- `/organizations/:organizationId/members/:userId`
|
|
553
818
|
|
|
819
|
+
The state-changing OTP and magic-link request routes are `POST` (marked above). They were previously
|
|
820
|
+
`GET`, which made them reachable as simple cross-site requests, so an `<img>` tag could trigger SMS or
|
|
821
|
+
email sends to a signed-in user. Using `@seamless-auth/react` with an older adapter that only serves the
|
|
822
|
+
`GET` forms returns a 404 for those requests. See the changelog for the minimum adapter version.
|
|
823
|
+
|
|
554
824
|
## Notes
|
|
555
825
|
|
|
556
826
|
- This package does not create its own `<BrowserRouter>`.
|
package/dist/AuthProvider.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { FinishOAuthLoginInput, OAuthProvidersResult, StartOAuthLoginInput, StartOAuthLoginResult,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { CurrentUserResult, FinishOAuthLoginInput, LoginStartResult, MessageResult, OAuthProvidersResult, OrganizationSwitchResult, PasskeyLoginData, StartOAuthLoginInput, StartOAuthLoginResult, StepUpPrfData, StepUpStatus } from './client/createSeamlessAuthClient';
|
|
2
|
+
import type { SeamlessAuthResult } from './client/result';
|
|
3
|
+
import { PasskeyPrfInput } from './client/webauthnPrf';
|
|
4
|
+
import { Credential, Organization, User } from './types';
|
|
4
5
|
import React, { ReactNode } from 'react';
|
|
5
6
|
export interface AuthContextType {
|
|
6
7
|
user: User | null;
|
|
7
|
-
logout: () => Promise<
|
|
8
|
-
logoutAllSessions: () => Promise<
|
|
9
|
-
deleteUser: () => Promise<
|
|
10
|
-
refreshSession: () => Promise<
|
|
8
|
+
logout: () => Promise<SeamlessAuthResult<MessageResult>>;
|
|
9
|
+
logoutAllSessions: () => Promise<SeamlessAuthResult<MessageResult>>;
|
|
10
|
+
deleteUser: () => Promise<SeamlessAuthResult<MessageResult>>;
|
|
11
|
+
refreshSession: () => Promise<SeamlessAuthResult<CurrentUserResult>>;
|
|
11
12
|
isAuthenticated: boolean;
|
|
12
13
|
hasRole: (role: string) => boolean | undefined;
|
|
13
14
|
hasScopedRole: (role: string | string[]) => boolean | undefined;
|
|
@@ -17,19 +18,19 @@ export interface AuthContextType {
|
|
|
17
18
|
credentials: Credential[];
|
|
18
19
|
organizations: Organization[];
|
|
19
20
|
activeOrganization: Organization | null;
|
|
20
|
-
switchOrganization: (organizationId: string) => Promise<
|
|
21
|
-
listOAuthProviders: () => Promise<OAuthProvidersResult
|
|
22
|
-
startOAuthLogin: (input: StartOAuthLoginInput) => Promise<StartOAuthLoginResult
|
|
23
|
-
finishOAuthLogin: (input: FinishOAuthLoginInput) => Promise<
|
|
21
|
+
switchOrganization: (organizationId: string) => Promise<SeamlessAuthResult<OrganizationSwitchResult>>;
|
|
22
|
+
listOAuthProviders: () => Promise<SeamlessAuthResult<OAuthProvidersResult>>;
|
|
23
|
+
startOAuthLogin: (input: StartOAuthLoginInput) => Promise<SeamlessAuthResult<StartOAuthLoginResult>>;
|
|
24
|
+
finishOAuthLogin: (input: FinishOAuthLoginInput) => Promise<SeamlessAuthResult<MessageResult>>;
|
|
24
25
|
stepUpStatus: StepUpStatus | null;
|
|
25
|
-
updateCredential: (credential: Credential) => Promise<Credential
|
|
26
|
-
deleteCredential: (credentialId: string) => Promise<
|
|
27
|
-
login: (identifier: string, passkeyAvailable: boolean) => Promise<
|
|
28
|
-
handlePasskeyLogin: () => Promise<
|
|
29
|
-
refreshStepUpStatus: () => Promise<StepUpStatus
|
|
30
|
-
verifyStepUpWithPasskey: () => Promise<
|
|
31
|
-
verifyStepUpWithPasskeyPrf: (input: PasskeyPrfInput) => Promise<
|
|
32
|
-
verifyStepUpWithTotp: (code: string) => Promise<
|
|
26
|
+
updateCredential: (credential: Credential) => Promise<SeamlessAuthResult<Credential>>;
|
|
27
|
+
deleteCredential: (credentialId: string) => Promise<SeamlessAuthResult<MessageResult>>;
|
|
28
|
+
login: (identifier: string, passkeyAvailable: boolean) => Promise<SeamlessAuthResult<LoginStartResult>>;
|
|
29
|
+
handlePasskeyLogin: () => Promise<SeamlessAuthResult<PasskeyLoginData>>;
|
|
30
|
+
refreshStepUpStatus: () => Promise<SeamlessAuthResult<StepUpStatus>>;
|
|
31
|
+
verifyStepUpWithPasskey: () => Promise<SeamlessAuthResult<StepUpStatus>>;
|
|
32
|
+
verifyStepUpWithPasskeyPrf: (input: PasskeyPrfInput) => Promise<SeamlessAuthResult<StepUpPrfData>>;
|
|
33
|
+
verifyStepUpWithTotp: (code: string) => Promise<SeamlessAuthResult<StepUpStatus>>;
|
|
33
34
|
loading: boolean;
|
|
34
35
|
}
|
|
35
36
|
/**
|