@proveanything/smartlinks 1.15.11 → 1.15.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/authKit.d.ts +58 -3
- package/dist/api/authKit.js +116 -4
- package/dist/docs/API_SUMMARY.md +118 -9
- package/dist/docs/analytics-metadata-conventions.md +4 -0
- package/dist/docs/analytics.md +37 -3
- package/dist/docs/auth-kit.md +110 -0
- package/dist/openapi.yaml +412 -1
- package/dist/types/analytics.d.ts +37 -2
- package/dist/types/authKit.d.ts +90 -0
- package/docs/API_SUMMARY.md +118 -9
- package/docs/analytics-metadata-conventions.md +4 -0
- package/docs/analytics.md +37 -3
- package/docs/auth-kit.md +110 -0
- package/openapi.yaml +412 -1
- package/package.json +1 -1
package/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 1.15.
|
|
3
|
+
Version: 1.15.13 | Generated: 2026-07-27T08:21:16.504Z
|
|
4
4
|
|
|
5
5
|
This is a concise summary of all available API functions and types.
|
|
6
6
|
|
|
@@ -1139,6 +1139,17 @@ interface AnalyticsFilterRequest {
|
|
|
1139
1139
|
claimIds?: string[]
|
|
1140
1140
|
isAdmin?: boolean
|
|
1141
1141
|
hasLocation?: boolean
|
|
1142
|
+
* Filter web-events rows by the `source` column (list-match). Web-events
|
|
1143
|
+
* only - has no effect on `source: 'tag'` queries.
|
|
1144
|
+
*
|
|
1145
|
+
* There is deliberately no singular `source` filter: the request's own
|
|
1146
|
+
* top-level `source` field (`'events'` vs `'tag'`) already owns that name
|
|
1147
|
+
* as the table selector and predates this column - same word, two
|
|
1148
|
+
* different things. Use a single-element array (`sources: ['portal']`)
|
|
1149
|
+
* for an exact-match filter.
|
|
1150
|
+
sources?: string[]
|
|
1151
|
+
redirectMode?: string
|
|
1152
|
+
redirectModes?: string[]
|
|
1142
1153
|
}
|
|
1143
1154
|
```
|
|
1144
1155
|
|
|
@@ -3096,6 +3107,66 @@ interface AppleLoginOptions {
|
|
|
3096
3107
|
}
|
|
3097
3108
|
```
|
|
3098
3109
|
|
|
3110
|
+
**MfaRequiredDetails** (interface)
|
|
3111
|
+
```typescript
|
|
3112
|
+
interface MfaRequiredDetails {
|
|
3113
|
+
mfaSessionToken: string
|
|
3114
|
+
availableFactors: Array<'email' | 'sms'>
|
|
3115
|
+
preferredFactor: 'email' | 'sms'
|
|
3116
|
+
maskedDestinations: { email?: string; sms?: string }
|
|
3117
|
+
}
|
|
3118
|
+
```
|
|
3119
|
+
|
|
3120
|
+
**MfaChallengeSendResponse** (interface)
|
|
3121
|
+
```typescript
|
|
3122
|
+
interface MfaChallengeSendResponse {
|
|
3123
|
+
success: true
|
|
3124
|
+
factor: 'email' | 'sms'
|
|
3125
|
+
destination: string
|
|
3126
|
+
expiresAt: string
|
|
3127
|
+
}
|
|
3128
|
+
```
|
|
3129
|
+
|
|
3130
|
+
**MfaEnrollSendResponse** (interface)
|
|
3131
|
+
```typescript
|
|
3132
|
+
interface MfaEnrollSendResponse {
|
|
3133
|
+
mfaSessionToken: string
|
|
3134
|
+
destination: string
|
|
3135
|
+
expiresAt: string
|
|
3136
|
+
}
|
|
3137
|
+
```
|
|
3138
|
+
|
|
3139
|
+
**MfaEnrolledResponse** (interface)
|
|
3140
|
+
```typescript
|
|
3141
|
+
interface MfaEnrolledResponse {
|
|
3142
|
+
enrolled: true
|
|
3143
|
+
factor: 'email' | 'sms'
|
|
3144
|
+
}
|
|
3145
|
+
```
|
|
3146
|
+
|
|
3147
|
+
**MfaFactorsResponse** (interface)
|
|
3148
|
+
```typescript
|
|
3149
|
+
interface MfaFactorsResponse {
|
|
3150
|
+
enrolledFactors: {
|
|
3151
|
+
email?: { enrolledAt: string; destination: string }
|
|
3152
|
+
sms?: { enrolledAt: string; destination: string }
|
|
3153
|
+
}
|
|
3154
|
+
recoveryCodesRemaining: number
|
|
3155
|
+
mfaEnabledForClient: boolean
|
|
3156
|
+
}
|
|
3157
|
+
```
|
|
3158
|
+
|
|
3159
|
+
**TrustedDevice** (interface)
|
|
3160
|
+
```typescript
|
|
3161
|
+
interface TrustedDevice {
|
|
3162
|
+
id: string
|
|
3163
|
+
label: string | null
|
|
3164
|
+
createdAtMs: number
|
|
3165
|
+
lastUsedAtMs: number
|
|
3166
|
+
expiresAtMs: number
|
|
3167
|
+
}
|
|
3168
|
+
```
|
|
3169
|
+
|
|
3099
3170
|
**MagicLinkSendResponse** (interface)
|
|
3100
3171
|
```typescript
|
|
3101
3172
|
interface MagicLinkSendResponse {
|
|
@@ -3345,6 +3416,8 @@ interface AuthKitConfig {
|
|
|
3345
3416
|
|
|
3346
3417
|
**AuthKitErrorCode** = ``
|
|
3347
3418
|
|
|
3419
|
+
**MfaErrorCode** = ``
|
|
3420
|
+
|
|
3348
3421
|
**VerifyStatus** = `'pending' | 'verified' | 'failed' | 'expired' | 'unknown'`
|
|
3349
3422
|
|
|
3350
3423
|
### batch
|
|
@@ -8543,8 +8616,8 @@ Gets current account information for the logged in user. Returns user, owner, ac
|
|
|
8543
8616
|
|
|
8544
8617
|
### authKit
|
|
8545
8618
|
|
|
8546
|
-
**login**(clientId: string, email: string, password: string) → `Promise<AuthLoginResponse>`
|
|
8547
|
-
Login with email + password (public).
|
|
8619
|
+
**login**(clientId: string, email: string, password: string, trustedDeviceToken?: string) → `Promise<AuthLoginResponse>`
|
|
8620
|
+
Login with email + password (public). When the client's MFA policy requires a step-up, the server returns **403 `MFA_REQUIRED`** instead of a session — `login()` throws a `SmartlinksApiError` with `err.errorResponse?.errorCode === 'MFA_REQUIRED'` and the challenge details in `err.details` (see {@link MfaRequiredDetails}). Route the caller to {@link mfaChallengeSend} on that error; this method's return type is unchanged. returned one (via {@link mfaChallengeVerify}/{@link mfaRecoveryCode} with `trustDevice: true`), pass it here to skip the challenge entirely as long as it's still valid. If it's revoked/expired, the server silently falls back to requiring a fresh challenge — `login()` just returns `MFA_REQUIRED` again, no special handling.
|
|
8548
8621
|
|
|
8549
8622
|
**register**(clientId: string, data: { email: string; password: string; displayName?: string; accountData?: Record<string, any> }) → `Promise<AuthLoginResponse>`
|
|
8550
8623
|
Register a new user (public).
|
|
@@ -8636,23 +8709,59 @@ Update the authenticated user's profile and replace the bearer token when refres
|
|
|
8636
8709
|
**deleteAccount**(clientId: string, password: string, confirmText: string) → `Promise<SuccessResponse>`
|
|
8637
8710
|
Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
|
|
8638
8711
|
|
|
8712
|
+
**mfaChallengeSend**(clientId: string, mfaSessionToken: string, factor: 'email' | 'sms') → `Promise<MfaChallengeSendResponse>`
|
|
8713
|
+
Send (or resend) an MFA challenge code to the given factor (public).
|
|
8714
|
+
|
|
8715
|
+
**mfaChallengeVerify**(clientId: string, mfaSessionToken: string, code: string, trustDevice?: boolean, deviceLabel?: string) → `Promise<MfaFinalizeResponse>`
|
|
8716
|
+
Verify an MFA challenge code and finalize the login (public). On success this behaves like {@link login} — the bearer token is adopted and the cache invalidated. it and pass it to future {@link login} calls to skip the challenge on this device.
|
|
8717
|
+
|
|
8718
|
+
**mfaRecoveryCode**(clientId: string, mfaSessionToken: string, code: string, trustDevice?: boolean, deviceLabel?: string) → `Promise<MfaFinalizeResponse>`
|
|
8719
|
+
Finalize a challenged login using a single-use recovery code instead of a sent code (public). Same finalize-session behaviour as {@link mfaChallengeVerify}.
|
|
8720
|
+
|
|
8721
|
+
**getMfaFactors**(clientId: string) → `Promise<MfaFactorsResponse>`
|
|
8722
|
+
List enrolled factors and recovery-code count for the current user (authenticated).
|
|
8723
|
+
|
|
8724
|
+
**enrollEmailMfa**(clientId: string) → `Promise<MfaEnrollSendResponse>`
|
|
8725
|
+
Begin email-factor enrollment; sends a code to the account's existing email (authenticated).
|
|
8726
|
+
|
|
8727
|
+
**confirmEmailMfa**(clientId: string, mfaSessionToken: string, code: string) → `Promise<MfaEnrolledResponse>`
|
|
8728
|
+
Confirm email-factor enrollment with the code sent by {@link enrollEmailMfa} (authenticated).
|
|
8729
|
+
|
|
8730
|
+
**enrollSmsMfa**(clientId: string, phoneNumber: string) → `Promise<MfaEnrollSendResponse>`
|
|
8731
|
+
Begin SMS-factor enrollment; sends a code to the given phone number (authenticated).
|
|
8732
|
+
|
|
8733
|
+
**confirmSmsMfa**(clientId: string, mfaSessionToken: string, code: string) → `Promise<MfaEnrolledResponse>`
|
|
8734
|
+
Confirm SMS-factor enrollment with the code sent by {@link enrollSmsMfa} (authenticated).
|
|
8735
|
+
|
|
8736
|
+
**generateMfaRecoveryCodes**(clientId: string, password: string) → `Promise<`
|
|
8737
|
+
Generate a fresh set of recovery codes, invalidating any previous set (authenticated). Returned **in plaintext, exactly once** — nothing else in the API ever returns them again, so the caller must display/export them immediately.
|
|
8738
|
+
|
|
8739
|
+
**removeMfaFactor**(clientId: string, factor: 'email' | 'sms', password: string) → `Promise<SuccessResponse>`
|
|
8740
|
+
Remove an enrolled MFA factor (authenticated). Server route is DELETE with a body.
|
|
8741
|
+
|
|
8742
|
+
**listTrustedDevices**(clientId: string) → `Promise<`
|
|
8743
|
+
List devices trusted to skip MFA challenges for the current user (authenticated).
|
|
8744
|
+
|
|
8745
|
+
**revokeTrustedDevice**(clientId: string, id: string) → `Promise<SuccessResponse>`
|
|
8746
|
+
Revoke a single trusted device by id (authenticated).
|
|
8747
|
+
|
|
8639
8748
|
**load**(authKitId: string) → `Promise<AuthKitConfig>`
|
|
8640
|
-
|
|
8749
|
+
Revoke a single trusted device by id (authenticated).
|
|
8641
8750
|
|
|
8642
8751
|
**get**(collectionId: string, authKitId: string) → `Promise<AuthKitConfig>`
|
|
8643
|
-
|
|
8752
|
+
Revoke a single trusted device by id (authenticated).
|
|
8644
8753
|
|
|
8645
8754
|
**list**(collectionId: string, admin?: boolean) → `Promise<AuthKitConfig[]>`
|
|
8646
|
-
|
|
8755
|
+
Revoke a single trusted device by id (authenticated).
|
|
8647
8756
|
|
|
8648
8757
|
**create**(collectionId: string, data: any) → `Promise<AuthKitConfig>`
|
|
8649
|
-
|
|
8758
|
+
Revoke a single trusted device by id (authenticated).
|
|
8650
8759
|
|
|
8651
8760
|
**update**(collectionId: string, authKitId: string, data: any) → `Promise<AuthKitConfig>`
|
|
8652
|
-
|
|
8761
|
+
Revoke a single trusted device by id (authenticated).
|
|
8653
8762
|
|
|
8654
8763
|
**remove**(collectionId: string, authKitId: string) → `Promise<void>`
|
|
8655
|
-
|
|
8764
|
+
Revoke a single trusted device by id (authenticated).
|
|
8656
8765
|
|
|
8657
8766
|
### batch
|
|
8658
8767
|
|
|
@@ -15,6 +15,8 @@ Some of these are now promoted top-level analytics fields. Others remain good me
|
|
|
15
15
|
- `entryType`
|
|
16
16
|
- `pageId`
|
|
17
17
|
- `scanMethod`
|
|
18
|
+
- `source` - collection/web-events only. Free-form client app identifier, e.g. `'portal'`, `'hub'`. No enum/whitelist. Not available on tag events - see the `analytics.tag.track(event)` section in [docs/analytics.md](analytics.md) for why.
|
|
19
|
+
- `redirectMode` - tag-events only. Usually server-written; only set this yourself if you're logging a redirect-style event.
|
|
18
20
|
|
|
19
21
|
These should be sent as top-level analytics fields, not inside `metadata`.
|
|
20
22
|
|
|
@@ -59,6 +61,7 @@ These keys give teams a shared vocabulary for:
|
|
|
59
61
|
- Send promoted fields at top level.
|
|
60
62
|
- Keep values flat and scalar where possible so they are easier to filter and break down later.
|
|
61
63
|
- Promote a field to a first-class backend column only when it becomes a hot platform-wide dimension.
|
|
64
|
+
- Note: `source` (the event column) and the query-time `source` parameter (`'events' | 'tag'`, which table to query) are unrelated fields that happen to share a name. When filtering by the `source` column, use the plural `sources` array - there is no singular `source` filter, precisely to avoid colliding with the table selector.
|
|
62
65
|
|
|
63
66
|
---
|
|
64
67
|
|
|
@@ -77,6 +80,7 @@ analytics.collection.track({
|
|
|
77
80
|
campaign: 'summer-launch',
|
|
78
81
|
utmSource: 'email',
|
|
79
82
|
pageId: 'QR123',
|
|
83
|
+
source: 'portal',
|
|
80
84
|
metadata: {
|
|
81
85
|
pagePath: '/c/demo-collection',
|
|
82
86
|
},
|
package/docs/analytics.md
CHANGED
|
@@ -48,6 +48,12 @@ The backend stores custom analytics dimensions in `metadata`, but promoted analy
|
|
|
48
48
|
|
|
49
49
|
See [docs/analytics-metadata-conventions.md](analytics-metadata-conventions.md) for the recommended key set.
|
|
50
50
|
|
|
51
|
+
### A note on server-written events
|
|
52
|
+
|
|
53
|
+
Not every row in these tables is written by your app. As of 2026-07-23, the server itself logs a `scan_redirect` event on the tag-events table at the moment a GS1 digital-link scan, claim short-link scan, or NFC tap decides on a redirect destination - before the client ever loads anything. This is separate from `scan_tag`, which your app still writes on landing exactly as before. `scan_blank_tag` remains excluded from the analytics pipeline.
|
|
54
|
+
|
|
55
|
+
Server-issued redirects also append `?sid=<value>` to the destination URL. If your landing page reads `sid` off the URL and reuses it as its own `sessionId` on the next `analytics.collection.track(...)` or `analytics.tag.track(...)` call, "did the redirect actually land" becomes a free `sessionId` join - nothing new to send, just reuse the value if it's present.
|
|
56
|
+
|
|
51
57
|
---
|
|
52
58
|
|
|
53
59
|
## Quick Start
|
|
@@ -67,6 +73,7 @@ analytics.collection.track({
|
|
|
67
73
|
appId: 'homepage',
|
|
68
74
|
path: '/c/demo-collection',
|
|
69
75
|
deviceType: 'mobile',
|
|
76
|
+
source: 'portal',
|
|
70
77
|
})
|
|
71
78
|
```
|
|
72
79
|
|
|
@@ -298,7 +305,9 @@ Tracks generic collection analytics events such as:
|
|
|
298
305
|
- internal navigation
|
|
299
306
|
- outbound link activity
|
|
300
307
|
|
|
301
|
-
Supported top-level fields include the core event fields plus promoted analytics columns such as `visitorId`, `referrerHost`, `pageId`, and `
|
|
308
|
+
Supported top-level fields include the core event fields plus promoted analytics columns such as `visitorId`, `referrerHost`, `pageId`, `entryType`, and `source`, along with custom metadata dimensions like `group`, `placement`, `pagePath`, and `qrCodeId`.
|
|
309
|
+
|
|
310
|
+
`source` is a free-form string identifying which client app logged the event, e.g. `'portal'`, `'hub'` - there's no enum or whitelist, send whatever identifies your app. It's web-events only; there is no equivalent field on tag events (see the `analytics.tag.track(event)` section below for why).
|
|
302
311
|
|
|
303
312
|
Example:
|
|
304
313
|
|
|
@@ -321,6 +330,7 @@ analytics.collection.track({
|
|
|
321
330
|
group: 'summer-launch',
|
|
322
331
|
placement: 'hero',
|
|
323
332
|
pageId: 'QR123',
|
|
333
|
+
source: 'portal',
|
|
324
334
|
metadata: { pagePath: '/c/demo-collection?pageId=QR123' },
|
|
325
335
|
})
|
|
326
336
|
```
|
|
@@ -334,7 +344,11 @@ Tracks physical scan analytics such as:
|
|
|
334
344
|
- claim/code activity
|
|
335
345
|
- admin vs customer scan behavior
|
|
336
346
|
|
|
337
|
-
Supported top-level fields include the core scan fields plus promoted analytics columns such as `visitorId`, `entryType`, and `
|
|
347
|
+
Supported top-level fields include the core scan fields plus promoted analytics columns such as `visitorId`, `entryType`, `scanMethod`, and `redirectMode`, along with custom metadata dimensions like `group`, `tag`, and campaign extras.
|
|
348
|
+
|
|
349
|
+
`redirectMode` is only relevant if you're logging a redirect-style event yourself - in practice it's mostly written by the server automatically (see [A note on server-written events](#a-note-on-server-written-events) above).
|
|
350
|
+
|
|
351
|
+
There is deliberately no `source` field on tag events, even though one exists on collection events. `eventType` already tells you who wrote a tag-events row - `scan_redirect` is always server-written, `scan_tag`/`scan_blank_tag` are always client-written - so a `source` column here would just duplicate that. To segment tag scans by NFC vs. QR, use `entryType`/`scanMethod` instead.
|
|
338
352
|
|
|
339
353
|
Example:
|
|
340
354
|
|
|
@@ -541,6 +555,24 @@ const countries = await analytics.admin.breakdown('demo-collection', {
|
|
|
541
555
|
})
|
|
542
556
|
```
|
|
543
557
|
|
|
558
|
+
Breaking down or filtering by the new `source` *column* (web-events only) works the same way, just watch the naming: the request's own top-level `source: 'events'` is the pre-existing table selector, and it sits alongside a `sources` array that filters by the column's value. They are unrelated fields that happen to share a name.
|
|
559
|
+
|
|
560
|
+
```typescript
|
|
561
|
+
const appSources = await analytics.admin.breakdown('demo-collection', {
|
|
562
|
+
source: 'events', // table selector: query web-events
|
|
563
|
+
dimension: 'source', // break down by the `source` column
|
|
564
|
+
metric: 'count',
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
const portalOnly = await analytics.admin.events('demo-collection', {
|
|
568
|
+
source: 'events', // table selector
|
|
569
|
+
sources: ['portal'], // column filter - single-element array for exact match
|
|
570
|
+
limit: 100,
|
|
571
|
+
})
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
There is no singular `source` filter field for this reason - use `sources: ['portal']` for an exact match.
|
|
575
|
+
|
|
544
576
|
### Raw events
|
|
545
577
|
|
|
546
578
|
```typescript
|
|
@@ -588,7 +620,7 @@ For metrics, generic queries support:
|
|
|
588
620
|
- `uniqueSessions`
|
|
589
621
|
- `uniqueVisitors`
|
|
590
622
|
|
|
591
|
-
Extra collection-event filters include:
|
|
623
|
+
Extra collection-event (web-events) filters include:
|
|
592
624
|
|
|
593
625
|
- `appId`, `appIds[]`
|
|
594
626
|
- `destinationAppId`, `destinationAppIds[]`
|
|
@@ -596,6 +628,7 @@ Extra collection-event filters include:
|
|
|
596
628
|
- `href`, `path`
|
|
597
629
|
- `hrefContains`, `pathContains`
|
|
598
630
|
- `isExternal`
|
|
631
|
+
- `sources[]` - filter by the `source` column (list-match only, no singular form - see [Breakdown](#breakdown))
|
|
599
632
|
|
|
600
633
|
Extra tag-event filters include:
|
|
601
634
|
|
|
@@ -603,6 +636,7 @@ Extra tag-event filters include:
|
|
|
603
636
|
- `claimId`, `claimIds[]`
|
|
604
637
|
- `isAdmin`
|
|
605
638
|
- `hasLocation`
|
|
639
|
+
- `redirectMode`, `redirectModes[]`
|
|
606
640
|
|
|
607
641
|
---
|
|
608
642
|
|
package/docs/auth-kit.md
CHANGED
|
@@ -304,6 +304,116 @@ clearPersistedTokens();
|
|
|
304
304
|
|
|
305
305
|
---
|
|
306
306
|
|
|
307
|
+
## Step-up MFA (Phase 1)
|
|
308
|
+
|
|
309
|
+
Backend-only for now — **no admin-console UI and no challenge UI ship with this pass.**
|
|
310
|
+
Factors: **email OTP, SMS OTP, recovery codes**. WhatsApp OTP, TOTP, and passkeys are
|
|
311
|
+
deferred to a later phase; don't build against them yet.
|
|
312
|
+
|
|
313
|
+
The step-up gate is wired into **`login()` only** — `register`, `verifyPhoneCode`, and
|
|
314
|
+
`verifyMagicLink` never return `MFA_REQUIRED`, regardless of client config.
|
|
315
|
+
|
|
316
|
+
### Handling `MFA_REQUIRED` from `login()`
|
|
317
|
+
|
|
318
|
+
`login()`'s return type is unchanged. When the client's MFA policy requires a step-up,
|
|
319
|
+
the server returns 403 instead of a session, and `login()` throws:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
import { authKit, SmartlinksApiError } from '@proveanything/smartlinks';
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const session = await authKit.login(clientId, email, password);
|
|
326
|
+
// logged in, no MFA required
|
|
327
|
+
} catch (err) {
|
|
328
|
+
if (err instanceof SmartlinksApiError && err.errorCode === 'MFA_REQUIRED') {
|
|
329
|
+
const { mfaSessionToken, availableFactors, preferredFactor, maskedDestinations } = err.details!;
|
|
330
|
+
// → route to a challenge UI; call authKit.mfaChallengeSend(clientId, mfaSessionToken, preferredFactor)
|
|
331
|
+
} else {
|
|
332
|
+
throw err;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Completing the challenge
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
// Send (or resend, or switch factor) a code on the same mfaSessionToken
|
|
341
|
+
const sent = await authKit.mfaChallengeSend(clientId, mfaSessionToken, 'sms');
|
|
342
|
+
// sent.destination is masked, e.g. "+1******1234"
|
|
343
|
+
|
|
344
|
+
// Verify the code — behaves like login() on success: bearer token is adopted automatically
|
|
345
|
+
const session = await authKit.mfaChallengeVerify(clientId, mfaSessionToken, '123456', /* trustDevice */ true, 'My Laptop');
|
|
346
|
+
|
|
347
|
+
// Or finalize with a single-use recovery code instead
|
|
348
|
+
const session = await authKit.mfaRecoveryCode(clientId, mfaSessionToken, recoveryCode);
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
`mfaSessionToken` is short-lived (10 min) and single-use — burned on 5 failed attempts
|
|
352
|
+
(`MFA_TOO_MANY_ATTEMPTS`) or on success. Once burned/expired, there's no "resend within the
|
|
353
|
+
same session" — the caller must restart from `login()`.
|
|
354
|
+
|
|
355
|
+
### Trusted devices — "don't ask again on this device"
|
|
356
|
+
|
|
357
|
+
There's no fingerprinting involved: a "recognized device" is purely "the caller presented a
|
|
358
|
+
valid, unexpired, unrevoked `trustedDeviceToken`". Handle it like the native refresh token
|
|
359
|
+
above — same secure storage, same persist-before-next-call discipline:
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
// 1) Persist trustedDeviceToken from a successful challenge (trustDevice: true)
|
|
363
|
+
persistTrustedDeviceToken(session.trustedDeviceToken, session.trustedDeviceExpiresAt);
|
|
364
|
+
|
|
365
|
+
// 2) Send it on every subsequent login() — if still valid, the challenge is skipped entirely
|
|
366
|
+
const session = await authKit.login(clientId, email, password, storedTrustedDeviceToken);
|
|
367
|
+
// If it's revoked/expired, the server silently falls back to requiring a fresh challenge —
|
|
368
|
+
// login() just throws MFA_REQUIRED again, no special-case handling needed.
|
|
369
|
+
|
|
370
|
+
// 3) Let users audit/revoke devices from a Settings screen
|
|
371
|
+
const { devices } = await authKit.listTrustedDevices(clientId);
|
|
372
|
+
await authKit.revokeTrustedDevice(clientId, deviceId);
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Factor management (Settings → Security)
|
|
376
|
+
|
|
377
|
+
Authenticated (bearer token) — same `mfaSessionToken` + code mechanism as login challenges,
|
|
378
|
+
just against a `purpose: 'enroll'` challenge instead of `'login'`.
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
// Enroll email (uses the account's existing email)
|
|
382
|
+
const { mfaSessionToken } = await authKit.enrollEmailMfa(clientId);
|
|
383
|
+
await authKit.confirmEmailMfa(clientId, mfaSessionToken, code);
|
|
384
|
+
|
|
385
|
+
// Enroll SMS
|
|
386
|
+
const { mfaSessionToken: smsToken } = await authKit.enrollSmsMfa(clientId, '+61400000000');
|
|
387
|
+
await authKit.confirmSmsMfa(clientId, smsToken, code);
|
|
388
|
+
|
|
389
|
+
// Recovery codes — plaintext, shown exactly once; nothing else in the API returns them again
|
|
390
|
+
const { recoveryCodes } = await authKit.generateMfaRecoveryCodes(clientId, password);
|
|
391
|
+
|
|
392
|
+
// Inspect / remove
|
|
393
|
+
const factors = await authKit.getMfaFactors(clientId);
|
|
394
|
+
// factors: { enrolledFactors: { email?, sms? }, recoveryCodesRemaining, mfaEnabledForClient }
|
|
395
|
+
await authKit.removeMfaFactor(clientId, 'sms', password);
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
> Enrollment endpoints don't currently check the client's `mfa.mode` — enrolling is possible
|
|
399
|
+
> even when MFA is configured `'off'` for a client. Those factors simply won't be challenged
|
|
400
|
+
> at login until an admin turns the mode on (via the existing admin AuthKit config update,
|
|
401
|
+
> no new admin route in this pass).
|
|
402
|
+
|
|
403
|
+
### MFA error codes
|
|
404
|
+
|
|
405
|
+
| `errorCode` | HTTP | Meaning / client action |
|
|
406
|
+
|---|---|---|
|
|
407
|
+
| `MFA_REQUIRED` | 403 | From `login()` only — see above |
|
|
408
|
+
| `INVALID_MFA_CODE` | 401 | Wrong code — let the user retry (attempts are capped) |
|
|
409
|
+
| `MFA_TOO_MANY_ATTEMPTS` | 429 | 5 wrong attempts — the challenge is burned; restart from `login()` |
|
|
410
|
+
| `MFA_FACTOR_NOT_ENROLLED` | 400 | Requested factor isn't enrolled — programming error if the UI only offers `availableFactors` |
|
|
411
|
+
| `MFA_SESSION_EXPIRED` | 401 | `mfaSessionToken` past its 10-minute TTL — restart from `login()` |
|
|
412
|
+
| `MFA_SESSION_INVALID` | 401 | Unknown/wrong-client/already-consumed token, or (on enroll confirm) mismatched user |
|
|
413
|
+
| `RECOVERY_CODE_INVALID` | 401 | Wrong or already-used recovery code |
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
307
417
|
## Profile management
|
|
308
418
|
|
|
309
419
|
```ts
|