@proveanything/smartlinks 2.0.66 → 2.0.68

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/docs/auth-kit.md CHANGED
@@ -1,626 +1,650 @@
1
- # SmartLinks Auth Kit (`@proveanything/smartlinks` — `authKit` namespace)
2
-
3
- > End-user authentication flows for SmartLinks microapps. Covers email/password, magic links, phone OTP, Google OAuth, profile management, and password/email change flows.
4
- >
5
- > **This is part of the core SDK** — no separate install required. Import from `@proveanything/smartlinks`.
6
-
7
- ---
8
-
9
- ## What is Auth Kit for?
10
-
11
- Auth Kit is the **end-user identity layer** for microapps that need users to sign in. It is distinct from the admin/platform authentication (Bearer tokens) used to call admin endpoints.
12
-
13
- Use Auth Kit when:
14
- - Your app has a login/register screen for end users (not collection admins)
15
- - You need to gate features behind a verified user identity
16
- - You want to store user-specific data with the `userAppData` API (see [app-data-storage.md](app-data-storage.md))
17
-
18
- Do **not** use Auth Kit for:
19
- - Admin-side API calls (those use Bearer tokens set by the platform shell)
20
- - Claiming proofs (see proof claiming methods)
21
-
22
- ---
23
-
24
- ## Setup: creating an Auth Kit client
25
-
26
- Each app requires an Auth Kit configuration, created by a collection admin:
27
-
28
- ```ts
29
- // Admin setup (one-time, done in admin console)
30
- import { authKit } from '@proveanything/smartlinks';
31
-
32
- // Returns an authKitId to store in your app config
33
- const config = await authKit.create(collectionId, {
34
- name: 'My App Auth',
35
- loginMethods: ['email', 'google', 'magic_link'],
36
- redirectUrl: 'https://myapp.example.com/auth/callback',
37
- });
38
- ```
39
-
40
- ---
41
-
42
- ## Key flows
43
-
44
- ### Email / password
45
-
46
- ```ts
47
- import { authKit } from '@proveanything/smartlinks';
48
-
49
- // Register
50
- const session = await authKit.register(clientId, {
51
- email: 'user@example.com',
52
- password: 'securePassword123',
53
- displayName: 'Alice',
54
- });
55
-
56
- // Login
57
- const session = await authKit.login(clientId, 'user@example.com', 'securePassword123');
58
-
59
- // session.token — store this; pass to initializeApi for subsequent calls
60
- ```
61
-
62
- ### Magic link (passwordless email)
63
-
64
- ```ts
65
- await authKit.sendMagicLink(clientId, {
66
- email: 'user@example.com',
67
- redirectUrl: 'https://myapp.example.com/auth/verify',
68
- });
69
-
70
- // On callback page, extract token from URL and verify
71
- const session = await authKit.verifyMagicLink(clientId, tokenFromUrl);
72
- ```
73
-
74
- ### Phone OTP
75
-
76
- ```ts
77
- await authKit.sendPhoneCode(clientId, '+61400000000');
78
- const session = await authKit.verifyPhoneCode(clientId, '+61400000000', '123456');
79
- ```
80
-
81
- ### Lightweight verification (WhatsApp + SMS)
82
-
83
- Use these flows when you want low-friction verification before or without full account sign-in.
84
-
85
- WhatsApp verification is token-first. The user does not type their phone number in your app for this flow; phone ownership is proven by the inbound WhatsApp sender number.
86
-
87
- ```ts
88
- import { authKit } from '@proveanything/smartlinks';
89
-
90
- // 1) Send WhatsApp verification deep link
91
- const wa = await authKit.sendWhatsApp(clientId);
92
-
93
- // Optional: pass redirect context and/or a post-verification reply
94
- // const wa = await authKit.sendWhatsApp(clientId, {
95
- // redirectUrl: 'https://app.example.com/checkout/continue',
96
- // prefillMessage: 'Please let me bid in this auction. Code: {{token}}',
97
- // contactData: {
98
- // name: 'Jane Doe',
99
- // email: 'jane@example.com',
100
- // source: 'auction-checkout',
101
- // customFields: { agreedToTerms: true },
102
- // },
103
- // reply: {
104
- // cta: {
105
- // body: "You're verified and ready to bid.",
106
- // buttonLabel: 'Back to Auction',
107
- // buttonUrl: '{{returnUrl}}',
108
- // // mediaUrl: 'https://cdn.example.com/bid-confirmed.jpg', // optional: include to use image card template
109
- // },
110
- // text: "You're verified. Return to the app to continue.",
111
- // },
112
- // });
113
-
114
- // wa.waLink can be opened directly by the app/browser
115
- // Poll status while user switches to WhatsApp and back
116
- const status = await authKit.getWhatsAppStatus(clientId, wa.token);
117
-
118
- // Optional: exchange verified WhatsApp proof for an Auth Kit session
119
- if (status.status === 'verified' && wa.sessionKey) {
120
- const session = await authKit.exchangeWhatsAppSession(clientId, wa.token, wa.sessionKey);
121
- // session.token can be used as the authenticated bearer token
122
- }
123
-
124
- // Optional legacy fallback path if webhook confirmation is unavailable
125
- await authKit.verifyWhatsApp(clientId, wa.token, '+447911123456');
126
-
127
- // 2) Or send SMS click-to-verify link
128
- await authKit.sendSmsVerify(clientId, {
129
- phoneNumber: '+447911123456',
130
- redirectUrl: 'https://app.example.com/raffle/checkout',
131
- ctaText: 'Tap to continue',
132
- });
133
-
134
- // Optional API verification path
135
- await authKit.verifySms(clientId, '<token>', '+447911123456');
136
- ```
137
-
138
- `contactData` is optional and is useful when you collect name/email before the customer switches to WhatsApp.
139
-
140
- - Auth Kit stores `contactData` on the verification token metadata first.
141
- - Contact details are written to durable contact storage only after WhatsApp verification succeeds.
142
- - If the user abandons before verification, no contact is created.
143
- - `contactData` must not include phone; the verified inbound WhatsApp sender number is always authoritative.
144
-
145
- ### Contact bootstrap / durable identity
146
-
147
- After verification, upsert contact identity and store `contactId` on downstream records (raffle ticket, bid, claim intent).
148
-
149
- ```ts
150
- const contact = await authKit.upsertContact(clientId, {
151
- phone: '+447911123456',
152
- name: 'Jane Doe',
153
- source: 'raffle-checkout',
154
- customFields: { channelVerified: 'whatsapp' },
155
- externalIds: { raffleSessionId: 'rfl_123' },
156
- });
157
-
158
- // Persist these on your business record
159
- // contact.contactId, contact.collectionId, verified channel, verifiedAt
160
- ```
161
-
162
- Verification status values returned by `authKit.getWhatsAppStatus` are:
163
- - `pending`
164
- - `verified`
165
- - `failed`
166
- - `expired`
167
- - `unknown`
168
-
169
- #### Post-verification reply
170
-
171
- Pass a `reply` object in `sendWhatsApp` to send a message back to the user after they confirm `CONFIRM <token>`. Reply resolution order:
172
-
173
- 1. `reply.contentSid` — explicit Twilio Content SID
174
- 2. `reply.cta` — CTA shorthand:
175
- - If `cta.mediaUrl` is present (valid public `https://` URL), uses the **image card** Twilio Content template (`TWILIO_WHATSAPP_IMAGE_CTA_SID`)
176
- - Otherwise uses the **text CTA** template (`TWILIO_WHATSAPP_GENERIC_CTA_SID`)
177
- 3. `reply.text` — plain-text fallback
178
- 4. Per-client default (`authKit/{clientId}.whatsapp` config)
179
- 5. Built-in default text
180
-
181
- > **Important:** Only pass `mediaUrl` when you have a valid, publicly reachable `https://` JPEG or PNG. If the field is absent or blank, the text-only CTA template is selected automatically. Never pass an empty string — omit the field entirely to avoid Twilio rejecting the send.
182
-
183
- The following template placeholders are available in `reply.text`, `reply.cta` fields, and `reply.contentVariables` values:
184
-
185
- | Placeholder | Description |
186
- |---|---|
187
- | `{{returnUrl}}` | The resolved redirect URL |
188
- | `{{phoneNumber}}` | The verified phone number |
189
- | `{{clientId}}` | The Auth Kit client ID |
190
- | `{{token}}` | The verification token |
191
-
192
- You can also set `prefillMessage` on `sendWhatsApp` to customize the text pre-filled in the `wa.me` deep link. If `{{token}}` is not present, the token is appended to the message.
193
-
194
- #### Session exchange after verification
195
-
196
- After polling returns `status === 'verified'`, exchange the verification proof for an Auth Kit login session:
197
-
198
- ```ts
199
- const session = await authKit.exchangeWhatsAppSession(clientId, wa.token, wa.sessionKey!);
200
- // session: { success, token, user, accountData? }
201
- ```
202
-
203
- `sessionKey` is returned by `sendWhatsApp` and is used to mitigate token replay from contexts that did not initiate the browser flow.
204
-
205
- When `contactData.name` or explicit name parts were supplied on the original `sendWhatsApp` call, `session.user.displayName` and the returned bearer token claims are now seeded from the verified contact record instead of staying `null`.
206
-
207
- > **Note:** `redirectUrl` is optional. WhatsApp tokens are short hex strings (16 chars) for better UX.
208
- >
209
- > **Legacy note:** `verifyWhatsApp` is for older phone-bound token flows. Prefer inbound WhatsApp token confirmation plus status polling for new implementations.
210
-
211
- ### Google OAuth
212
-
213
- ```ts
214
- // After Google sign-in, pass the id_token to Auth Kit
215
- const session = await authKit.googleLogin(clientId, googleIdToken);
216
- ```
217
-
218
- ### Sign in with Apple
219
-
220
- Pass the Apple **identity token** (a JWT). On iOS/native it's
221
- `ASAuthorizationAppleIDCredential.identityToken` (UTF-8 decoded); on web it's
222
- `response.authorization.id_token` from Apple JS.
223
-
224
- ```ts
225
- const session = await authKit.appleLogin(clientId, appleIdentityToken, {
226
- // All optional:
227
- nonce, // raw nonce, if you used nonce binding
228
- userInfo: { name, email }, // first authorization callback ONLY — Apple never resends it
229
- trustedDeviceToken, // skip an MFA step-up challenge on a recognized device — see "Step-up MFA" below
230
- });
231
- // session.isNewUser and session.expiresAt (ms epoch) are populated by this endpoint.
232
- ```
233
-
234
- Apple returns the user's name/email **only on the very first authorization, ever**, and
235
- never inside the token. Capture it from that first callback and forward it via `userInfo`
236
- so the server can seed the display name — it's treated as untrusted and never used for identity.
237
-
238
- Apple login requires the client's AuthKit config to list allowed audiences in
239
- `appleClientIds`; until then the endpoint returns `400 APPLE_AUTH_NOT_CONFIGURED`.
240
-
241
- #### Verified-to-verified account linking (affects Google too)
242
-
243
- Both `appleLogin` and `googleLogin` now refuse to silently merge a federated login into a
244
- pre-existing account whose email is **unverified**. Instead they throw
245
- `SmartlinksApiError` with `errorCode === 'ACCOUNT_EXISTS_UNVERIFIED'` (409) and
246
- `err.details?.requiresEmailVerification === true`. Treat this as recoverable, not fatal:
247
-
248
- ```ts
249
- try {
250
- const session = await authKit.appleLogin(clientId, appleIdentityToken);
251
- } catch (err) {
252
- if (err instanceof SmartlinksApiError && err.errorCode === 'ACCOUNT_EXISTS_UNVERIFIED') {
253
- // "An account with this email exists but isn't verified. Sign in with your
254
- // password (or reset it), then link Apple/Google from settings."
255
- }
256
- }
257
- ```
258
-
259
- > ⚠️ This is a behaviour change for `googleLogin`, which previously merged silently in
260
- > this case. Handle the 409 for both methods.
261
-
262
- ### Refresh tokens (native sessions)
263
-
264
- Native/Capacitor hosts can hold long-lived sessions via refresh tokens. Opt in **once**
265
- at startup so every request carries `X-Client-Platform: native`:
266
-
267
- ```ts
268
- initializeApi({ baseURL, platform: 'native' });
269
- ```
270
-
271
- With the opt-in active, the login endpoints (`login`, `register` in immediate mode,
272
- `googleLogin`, `appleLogin`, `verifyPhoneCode`, `verifyMagicLink`,
273
- `exchangeWhatsAppSession`) additionally return `refreshToken`, `refreshTokenExpiresAt`
274
- (absolute, fixed, ms epoch), and a **short-lived** access `token`. Web clients are
275
- unaffected and receive the unchanged response.
276
-
277
- ```ts
278
- // Later — exchange the refresh token for a fresh access token.
279
- const r = await authKit.refreshToken(clientId, storedRefreshToken);
280
- // r.refreshToken is ROTATED — persist it and discard the old one BEFORE the next call.
281
- // The SDK already swapped in r.token as the active bearer for you.
282
- persist(r.refreshToken, r.refreshTokenExpiresAt);
283
-
284
- // On explicit sign-out — revokes the whole device family server-side + clears the bearer.
285
- await authKit.logout(clientId, storedRefreshToken);
286
- clearPersistedTokens();
287
- ```
288
-
289
- **Rotation rules the host MUST respect:**
290
-
291
- 1. **Single-use + rotation.** Every `refreshToken()` returns a new token. Persist it and
292
- overwrite the old one *before* the next call.
293
- 2. **Serialize refreshes.** Fire a single in-flight refresh and queue callers behind it —
294
- the SDK does **not** serialize for you, and racing two refreshes spends the same token.
295
- 3. **Reuse = family death.** Replaying a consumed token throws
296
- `SmartlinksApiError` with `errorCode === 'REFRESH_TOKEN_REUSE_DETECTED'`; treat as a
297
- hard logout (clear storage, force re-login). `INVALID_REFRESH_TOKEN` (expired/revoked)
298
- is handled the same way.
299
- 4. **Absolute expiry is fixed.** `refreshTokenExpiresAt` never moves on rotation; once it
300
- passes (default 90 days) the user must log in again.
301
-
302
- > Resume-refresh scheduling and transparent refresh-on-401 are the responsibility of the
303
- > host/auth-ui layer, not this SDK — the SDK only exposes the `refreshToken()` / `logout()`
304
- > primitives and the `platform` opt-in.
305
-
306
- ---
307
-
308
- ## Step-up MFA (Phase 1)
309
-
310
- Backend-only for now — **no admin-console UI and no challenge UI ship with this pass.**
311
- Factors: **email OTP, SMS OTP, recovery codes**. WhatsApp OTP, TOTP, and passkeys as
312
- *factors* are deferred to a later phase; don't build against them yet.
313
-
314
- The step-up gate applies to **every login method that issues a session**:
315
-
316
- | Method | Gated? |
317
- |---|---|
318
- | `login()` | ✅ |
319
- | `googleLogin()` | ✅ |
320
- | `appleLogin()` | ✅ — pass `opts.trustedDeviceToken` |
321
- | `verifyPhoneCode()` | ✅ |
322
- | `verifyMagicLink()` | ✅ |
323
- | `exchangeWhatsAppSession()` | ✅ — **this is the WhatsApp method that needs a trusted-device token, not `verifyWhatsApp()`** |
324
- | `verifyWhatsApp()` | ❌ — only confirms the code, never issues a session, so there's nothing to gate |
325
- | `register()` | ❌ — a brand-new user has no enrolled factors to challenge against |
326
- | `googleCodeLogin()` | ❌ — no `/auth/google-code` route exists server-side |
327
-
328
- All six gated methods accept an optional `trustedDeviceToken` param (the last positional
329
- argument, or `opts.trustedDeviceToken` for `appleLogin()`) — same purpose everywhere: skip
330
- the challenge on a device the user already verified.
331
-
332
- ### Handling `MFA_REQUIRED`
333
-
334
- Every gated method's return type is unchanged. When the client's MFA policy requires a
335
- step-up, the server returns 403 instead of a session, and the method throws — identically
336
- for all six:
337
-
338
- ```ts
339
- import { authKit, SmartlinksApiError } from '@proveanything/smartlinks';
340
-
341
- try {
342
- const session = await authKit.login(clientId, email, password);
343
- // logged in, no MFA required
344
- } catch (err) {
345
- if (err instanceof SmartlinksApiError && err.errorCode === 'MFA_REQUIRED') {
346
- const { mfaSessionToken, availableFactors, preferredFactor, maskedDestinations } = err.details!;
347
- // → route to a challenge UI; call authKit.mfaChallengeSend(clientId, mfaSessionToken, preferredFactor)
348
- } else {
349
- throw err;
350
- }
351
- }
352
- ```
353
-
354
- ### Completing the challenge
355
-
356
- ```ts
357
- // Send (or resend, or switch factor) a code on the same mfaSessionToken
358
- const sent = await authKit.mfaChallengeSend(clientId, mfaSessionToken, 'sms');
359
- // sent.destination is masked, e.g. "+1******1234"
360
-
361
- // Verify the code — behaves like login() on success: bearer token is adopted automatically
362
- const session = await authKit.mfaChallengeVerify(clientId, mfaSessionToken, '123456', /* trustDevice */ true, 'My Laptop');
363
-
364
- // Or finalize with a single-use recovery code instead
365
- const session = await authKit.mfaRecoveryCode(clientId, mfaSessionToken, recoveryCode);
366
- ```
367
-
368
- `mfaSessionToken` is short-lived (10 min) and single-use — burned on 5 failed attempts
369
- (`MFA_TOO_MANY_ATTEMPTS`) or on success. Once burned/expired, there's no "resend within the
370
- same session" — the caller must restart from `login()`.
371
-
372
- ### Trusted devices — "don't ask again on this device"
373
-
374
- There's no fingerprinting involved: a "recognized device" is purely "the caller presented a
375
- valid, unexpired, unrevoked `trustedDeviceToken`". Handle it like the native refresh token
376
- above — same secure storage, same persist-before-next-call discipline:
377
-
378
- ```ts
379
- // 1) Persist trustedDeviceToken from a successful challenge (trustDevice: true)
380
- persistTrustedDeviceToken(session.trustedDeviceToken, session.trustedDeviceExpiresAt);
381
-
382
- // 2) Send it on every subsequent call to ANY of the six gated methods — if still valid,
383
- // the challenge is skipped entirely. It isn't tied to which method the user challenged
384
- // through, so "remember this device" works no matter which login method they pick next.
385
- const session = await authKit.login(clientId, email, password, storedTrustedDeviceToken);
386
- const session = await authKit.googleLogin(clientId, idToken, storedTrustedDeviceToken);
387
- const session = await authKit.exchangeWhatsAppSession(clientId, waToken, sessionKey, storedTrustedDeviceToken);
388
- // ...and so on for appleLogin (via opts), verifyPhoneCode, verifyMagicLink.
389
- // If it's revoked/expired, the server silently falls back to requiring a fresh challenge —
390
- // the method just throws MFA_REQUIRED again, no special-case handling needed.
391
-
392
- // 3) Let users audit/revoke devices from a Settings screen
393
- const { devices } = await authKit.listTrustedDevices(clientId);
394
- await authKit.revokeTrustedDevice(clientId, deviceId);
395
- ```
396
-
397
- ### Factor management (Settings → Security)
398
-
399
- Authenticated (bearer token) — same `mfaSessionToken` + code mechanism as login challenges,
400
- just against a `purpose: 'enroll'` challenge instead of `'login'`.
401
-
402
- ```ts
403
- // Enroll email (uses the account's existing email)
404
- const { mfaSessionToken } = await authKit.enrollEmailMfa(clientId);
405
- await authKit.confirmEmailMfa(clientId, mfaSessionToken, code);
406
-
407
- // Enroll SMS
408
- const { mfaSessionToken: smsToken } = await authKit.enrollSmsMfa(clientId, '+61400000000');
409
- await authKit.confirmSmsMfa(clientId, smsToken, code);
410
-
411
- // Recovery codes — plaintext, shown exactly once; nothing else in the API returns them again
412
- const { recoveryCodes } = await authKit.generateMfaRecoveryCodes(clientId, password);
413
-
414
- // Inspect / remove
415
- const factors = await authKit.getMfaFactors(clientId);
416
- // factors: { enrolledFactors: { email?, sms? }, recoveryCodesRemaining, mfaEnabledForClient }
417
- await authKit.removeMfaFactor(clientId, 'sms', password);
418
- ```
419
-
420
- > Enrollment endpoints don't currently check the client's `mfa.mode` — enrolling is possible
421
- > even when MFA is configured `'off'` for a client. Those factors simply won't be challenged
422
- > at login until an admin turns the mode on (via the existing admin AuthKit config update,
423
- > no new admin route in this pass).
424
-
425
- ### MFA error codes
426
-
427
- | `errorCode` | HTTP | Meaning / client action |
428
- |---|---|---|
429
- | `MFA_REQUIRED` | 403 | From any of the six gated login methods — see above |
430
- | `INVALID_MFA_CODE` | 401 | Wrong code — let the user retry (attempts are capped) |
431
- | `MFA_TOO_MANY_ATTEMPTS` | 429 | 5 wrong attempts — the challenge is burned; restart from `login()` |
432
- | `MFA_FACTOR_NOT_ENROLLED` | 400 | Requested factor isn't enrolled — programming error if the UI only offers `availableFactors` |
433
- | `MFA_SESSION_EXPIRED` | 401 | `mfaSessionToken` past its 10-minute TTL — restart from `login()` |
434
- | `MFA_SESSION_INVALID` | 401 | Unknown/wrong-client/already-consumed token, or (on enroll confirm) mismatched user |
435
- | `RECOVERY_CODE_INVALID` | 401 | Wrong or already-used recovery code |
436
-
437
- ---
438
-
439
- ## Profile management
440
-
441
- ```ts
442
- import { authKit } from '@proveanything/smartlinks';
443
-
444
- // Get current user's profile
445
- const profile = await authKit.getProfile(clientId);
446
-
447
- // Update profile
448
- const updatedProfile = await authKit.updateProfile(clientId, {
449
- displayName: 'Alice B.',
450
- avatarUrl: '...'
451
- });
452
-
453
- // The SDK automatically swaps in updatedProfile.token so future auth.verify()
454
- // and authenticated calls use fresh displayName/photoURL claims immediately.
455
-
456
- // Change password
457
- await authKit.changePassword(clientId, 'currentPass', 'newPass');
458
-
459
- // Change email (triggers verification)
460
- await authKit.changeEmail(clientId, 'newemail@example.com', 'password', redirectUrl);
461
-
462
- // Delete account
463
- await authKit.deleteAccount(clientId, 'password', 'DELETE');
464
- ```
465
-
466
- `updateProfile` now returns a fresh bearer token together with the updated profile fields. The SDK replaces the in-memory bearer token automatically so token-backed identity reads stay current without an extra refresh step.
467
-
468
- ---
469
-
470
- ## Email verification
471
-
472
- Auth Kit can send and verify email addresses after registration:
473
-
474
- ```ts
475
- await authKit.sendEmailVerification(clientId, {
476
- userId,
477
- email: 'user@example.com',
478
- redirectUrl: 'https://myapp.example.com/auth/verified',
479
- });
480
-
481
- // On callback page
482
- await authKit.verifyEmail(clientId, tokenFromUrl);
483
- ```
484
-
485
- ---
486
-
487
- ## Password reset
488
-
489
- ```ts
490
- await authKit.requestPasswordReset(clientId, {
491
- email: 'user@example.com',
492
- redirectUrl: 'https://myapp.example.com/auth/reset',
493
- clientName: 'My App',
494
- });
495
-
496
- // On reset page — verify token is still valid before showing the form
497
- await authKit.verifyResetToken(clientId, tokenFromUrl);
498
-
499
- // Complete reset
500
- await authKit.completePasswordReset(clientId, tokenFromUrl, 'newSecurePassword');
501
- ```
502
-
503
- ---
504
-
505
- ## Account security policy
506
-
507
- Each collection can configure account-security rules. **The API enforces all of it**; your
508
- login UI reads the policy for UX only (a live password checklist, idle sign-out). There are
509
- two sides: an **admin** writes the policy, and the **login UI** reads the public subset.
510
-
511
- **Read (login UI, public — no auth):**
512
-
513
- ```ts
514
- const config = await authKit.load(clientId);
515
- const policy = config.security?.passwordPolicy; // min length, char classes, block-common
516
- const session = config.security?.session; // inactivity + absolute timeouts, rememberMe
517
- ```
518
-
519
- `lockout` values are admin-only and never returned by `load`.
520
-
521
- **Write (admin):** set the whole `security` block with `authKit.update` (or `authKit.create`).
522
- The shape is {@link AuthKitSecurityConfig}; see {@link AuthKitConfigInput}.
523
-
524
- ```ts
525
- await authKit.update(collectionId, clientId, {
526
- security: {
527
- passwordPolicy: {
528
- minLength: 8, // hard floor enforced server-side
529
- blockCommonPasswords: true, // reject the common/breached list
530
- requireUppercase: false, requireLowercase: false,
531
- requireNumber: false, requireSymbol: false,
532
- expiryDays: 0, // 0 = never expires
533
- historyCount: 0, // 0 = reuse allowed
534
- },
535
- lockout: { // admin-only; enforced server-side
536
- enabled: true, maxFailedAttempts: 5,
537
- attemptWindowMinutes: 15, lockoutMinutes: 15,
538
- notifyUserOnLockout: true,
539
- },
540
- session: {
541
- inactivityTimeoutMinutes: 0, // 0 = disabled (client-enforced when set)
542
- inactivityWarningSeconds: 60,
543
- absoluteTimeoutHours: 0, // 0 = use token lifetime (server-enforced)
544
- rememberMe: true,
545
- },
546
- },
547
- });
548
- ```
549
-
550
- Defaults if a collection has never set a policy: 8-char minimum + block-common for everyone,
551
- lockout disabled, no expiry/history, no idle/absolute timeout. Omit any field to take its default.
552
-
553
- ### Password policy
554
-
555
- `register`, `completePasswordReset`, and `changePassword` validate the new password
556
- server-side and throw a `SmartlinksApiError` with a {@link PasswordPolicyErrorCode}:
557
-
558
- | `errorCode` (400) | Meaning |
559
- |---|---|
560
- | `PASSWORD_TOO_SHORT` | below `minLength` |
561
- | `PASSWORD_REQUIREMENTS_NOT_MET` | missing a required character class |
562
- | `PASSWORD_TOO_COMMON` | on the common/breached list |
563
- | `PASSWORD_RECENTLY_USED` | matched one of the last `historyCount` passwords |
564
-
565
- Render a live checklist from `policy` so users see the rules before submitting.
566
-
567
- ### Lockout
568
-
569
- After too many failed logins the account is temporarily locked. `login` throws:
570
-
571
- ```ts
572
- try {
573
- await authKit.login(clientId, email, password);
574
- } catch (err) {
575
- if (err.errorCode === 'ACCOUNT_TEMPORARILY_LOCKED') {
576
- const mins = Math.ceil(err.details.retryAfterSeconds / 60);
577
- show(`Too many attempts. Try again in ${mins} minute(s).`);
578
- }
579
- }
580
- ```
581
-
582
- Failed MFA challenges count toward the same lock. Locking responds identically for unknown
583
- accounts (no enumeration).
584
-
585
- ### Password expiry
586
-
587
- If a password is older than `passwordPolicy.expiryDays`, a valid login is refused with
588
- **403 `PASSWORD_EXPIRED`** carrying a short-lived `resetToken` — send the user straight into
589
- the reset form to change it in place:
590
-
591
- ```ts
592
- catch (err) {
593
- if (err.errorCode === 'PASSWORD_EXPIRED') {
594
- await authKit.completePasswordReset(clientId, err.details.resetToken, newPassword);
595
- }
596
- }
597
- ```
598
-
599
- ### Session lifetime
600
-
601
- - **Absolute timeout** (`session.absoluteTimeoutHours`) is enforced server-side on the native
602
- refresh path: once the session is too old, `refreshToken` throws **401 `SESSION_EXPIRED`**
603
- (see {@link RefreshErrorCode}) — clear storage and route to login. Web sessions use the
604
- stateless bearer token and rely on inactivity sign-out below.
605
- - **Inactivity timeout** (`session.inactivityTimeoutMinutes` / `inactivityWarningSeconds`) is
606
- **client-enforced** — sign the user out after idle, warning first. Sync across tabs.
607
- - **`session.rememberMe: false`** → don't persist tokens to durable storage; treat the session
608
- as browser-scoped.
609
-
610
- ---
611
-
612
- ## Relationship to other parts of the SDK
613
-
614
- | Concern | Where it lives |
615
- |---------|---------------|
616
- | End-user sign-in / register | `authKit` namespace (this doc) |
617
- | Admin Bearer token auth | Platform shell — not set by your app |
618
- | Per-user data storage | `userAppData` namespace — see [app-data-storage.md](app-data-storage.md) |
619
- | User identity in analytics | `userId` field on `analytics` and `interactions` calls |
620
-
621
- ---
622
-
623
- ## Further reading
624
-
625
- - [app-data-storage.md](app-data-storage.md) — storing user-specific data after login
626
- - [app-manifest.md](app-manifest.md) — `app.admin.json` setup questions for configuring `clientId`
1
+ # SmartLinks Auth Kit (`@proveanything/smartlinks` — `authKit` namespace)
2
+
3
+ > End-user authentication flows for SmartLinks microapps. Covers email/password, magic links, phone OTP, Google OAuth, profile management, and password/email change flows.
4
+ >
5
+ > **This is part of the core SDK** — no separate install required. Import from `@proveanything/smartlinks`.
6
+
7
+ ---
8
+
9
+ ## What is Auth Kit for?
10
+
11
+ Auth Kit is the **end-user identity layer** for microapps that need users to sign in. It is distinct from the admin/platform authentication (Bearer tokens) used to call admin endpoints.
12
+
13
+ Use Auth Kit when:
14
+ - Your app has a login/register screen for end users (not collection admins)
15
+ - You need to gate features behind a verified user identity
16
+ - You want to store user-specific data with the `userAppData` API (see [app-data-storage.md](app-data-storage.md))
17
+
18
+ Do **not** use Auth Kit for:
19
+ - Admin-side API calls (those use Bearer tokens set by the platform shell)
20
+ - Claiming proofs (see proof claiming methods)
21
+
22
+ ---
23
+
24
+ ## Setup: creating an Auth Kit client
25
+
26
+ Each app requires an Auth Kit configuration, created by a collection admin:
27
+
28
+ ```ts
29
+ // Admin setup (one-time, done in admin console)
30
+ import { authKit } from '@proveanything/smartlinks';
31
+
32
+ // Returns an authKitId to store in your app config
33
+ const config = await authKit.create(collectionId, {
34
+ name: 'My App Auth',
35
+ loginMethods: ['email', 'google', 'magic_link'],
36
+ redirectUrl: 'https://myapp.example.com/auth/callback',
37
+ });
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Key flows
43
+
44
+ ### Email / password
45
+
46
+ ```ts
47
+ import { authKit } from '@proveanything/smartlinks';
48
+
49
+ // Register
50
+ const session = await authKit.register(clientId, {
51
+ email: 'user@example.com',
52
+ password: 'securePassword123',
53
+ displayName: 'Alice',
54
+ });
55
+
56
+ // Login
57
+ const session = await authKit.login(clientId, 'user@example.com', 'securePassword123');
58
+
59
+ // session.token — store this; pass to initializeApi for subsequent calls
60
+ ```
61
+
62
+ ### Magic link (passwordless email)
63
+
64
+ ```ts
65
+ await authKit.sendMagicLink(clientId, {
66
+ email: 'user@example.com',
67
+ redirectUrl: 'https://myapp.example.com/auth/verify',
68
+ });
69
+
70
+ // On callback page, extract token from URL and verify
71
+ const session = await authKit.verifyMagicLink(clientId, tokenFromUrl);
72
+ ```
73
+
74
+ ### Phone OTP
75
+
76
+ ```ts
77
+ await authKit.sendPhoneCode(clientId, '+61400000000');
78
+ const session = await authKit.verifyPhoneCode(clientId, '+61400000000', '123456');
79
+ ```
80
+
81
+ ### Lightweight verification (WhatsApp + SMS)
82
+
83
+ Use these flows when you want low-friction verification before or without full account sign-in.
84
+
85
+ WhatsApp verification is token-first. The user does not type their phone number in your app for this flow; phone ownership is proven by the inbound WhatsApp sender number.
86
+
87
+ ```ts
88
+ import { authKit } from '@proveanything/smartlinks';
89
+
90
+ // 1) Send WhatsApp verification deep link
91
+ const wa = await authKit.sendWhatsApp(clientId);
92
+
93
+ // Optional: pass redirect context and/or a post-verification reply
94
+ // const wa = await authKit.sendWhatsApp(clientId, {
95
+ // redirectUrl: 'https://app.example.com/checkout/continue',
96
+ // prefillMessage: 'Please let me bid in this auction. Code: {{token}}',
97
+ // contactData: {
98
+ // name: 'Jane Doe',
99
+ // email: 'jane@example.com',
100
+ // source: 'auction-checkout',
101
+ // customFields: { agreedToTerms: true },
102
+ // },
103
+ // reply: {
104
+ // cta: {
105
+ // body: "You're verified and ready to bid.",
106
+ // buttonLabel: 'Back to Auction',
107
+ // buttonUrl: '{{returnUrl}}',
108
+ // // mediaUrl: 'https://cdn.example.com/bid-confirmed.jpg', // optional: include to use image card template
109
+ // },
110
+ // text: "You're verified. Return to the app to continue.",
111
+ // },
112
+ // });
113
+
114
+ // wa.waLink can be opened directly by the app/browser
115
+ // Poll status while user switches to WhatsApp and back
116
+ const status = await authKit.getWhatsAppStatus(clientId, wa.token);
117
+
118
+ // Optional: exchange verified WhatsApp proof for an Auth Kit session
119
+ if (status.status === 'verified' && wa.sessionKey) {
120
+ const session = await authKit.exchangeWhatsAppSession(clientId, wa.token, wa.sessionKey);
121
+ // session.token can be used as the authenticated bearer token
122
+ }
123
+
124
+ // Optional legacy fallback path if webhook confirmation is unavailable
125
+ await authKit.verifyWhatsApp(clientId, wa.token, '+447911123456');
126
+
127
+ // 2) Or send SMS click-to-verify link
128
+ await authKit.sendSmsVerify(clientId, {
129
+ phoneNumber: '+447911123456',
130
+ redirectUrl: 'https://app.example.com/raffle/checkout',
131
+ ctaText: 'Tap to continue',
132
+ });
133
+
134
+ // Optional API verification path
135
+ await authKit.verifySms(clientId, '<token>', '+447911123456');
136
+ ```
137
+
138
+ `contactData` is optional and is useful when you collect name/email before the customer switches to WhatsApp.
139
+
140
+ - Auth Kit stores `contactData` on the verification token metadata first.
141
+ - Contact details are written to durable contact storage only after WhatsApp verification succeeds.
142
+ - If the user abandons before verification, no contact is created.
143
+ - `contactData` must not include phone; the verified inbound WhatsApp sender number is always authoritative.
144
+
145
+ ### Contact bootstrap / durable identity
146
+
147
+ After verification, upsert contact identity and store `contactId` on downstream records (raffle ticket, bid, claim intent).
148
+
149
+ ```ts
150
+ const contact = await authKit.upsertContact(clientId, {
151
+ phone: '+447911123456',
152
+ name: 'Jane Doe',
153
+ source: 'raffle-checkout',
154
+ customFields: { channelVerified: 'whatsapp' },
155
+ externalIds: { raffleSessionId: 'rfl_123' },
156
+ });
157
+
158
+ // Persist these on your business record
159
+ // contact.contactId, contact.collectionId, verified channel, verifiedAt
160
+ ```
161
+
162
+ Verification status values returned by `authKit.getWhatsAppStatus` are:
163
+ - `pending`
164
+ - `verified`
165
+ - `failed`
166
+ - `expired`
167
+ - `unknown`
168
+
169
+ #### Post-verification reply
170
+
171
+ Pass a `reply` object in `sendWhatsApp` to send a message back to the user after they confirm `CONFIRM <token>`. Reply resolution order:
172
+
173
+ 1. `reply.contentSid` — explicit Twilio Content SID
174
+ 2. `reply.cta` — CTA shorthand:
175
+ - If `cta.mediaUrl` is present (valid public `https://` URL), uses the **image card** Twilio Content template (`TWILIO_WHATSAPP_IMAGE_CTA_SID`)
176
+ - Otherwise uses the **text CTA** template (`TWILIO_WHATSAPP_GENERIC_CTA_SID`)
177
+ 3. `reply.text` — plain-text fallback
178
+ 4. Per-client default (`authKit/{clientId}.whatsapp` config)
179
+ 5. Built-in default text
180
+
181
+ > **Important:** Only pass `mediaUrl` when you have a valid, publicly reachable `https://` JPEG or PNG. If the field is absent or blank, the text-only CTA template is selected automatically. Never pass an empty string — omit the field entirely to avoid Twilio rejecting the send.
182
+
183
+ The following template placeholders are available in `reply.text`, `reply.cta` fields, and `reply.contentVariables` values:
184
+
185
+ | Placeholder | Description |
186
+ |---|---|
187
+ | `{{returnUrl}}` | The resolved redirect URL |
188
+ | `{{phoneNumber}}` | The verified phone number |
189
+ | `{{clientId}}` | The Auth Kit client ID |
190
+ | `{{token}}` | The verification token |
191
+
192
+ You can also set `prefillMessage` on `sendWhatsApp` to customize the text pre-filled in the `wa.me` deep link. If `{{token}}` is not present, the token is appended to the message.
193
+
194
+ #### Session exchange after verification
195
+
196
+ After polling returns `status === 'verified'`, exchange the verification proof for an Auth Kit login session:
197
+
198
+ ```ts
199
+ const session = await authKit.exchangeWhatsAppSession(clientId, wa.token, wa.sessionKey!);
200
+ // session: { success, token, user, accountData? }
201
+ ```
202
+
203
+ `sessionKey` is returned by `sendWhatsApp` and is used to mitigate token replay from contexts that did not initiate the browser flow.
204
+
205
+ When `contactData.name` or explicit name parts were supplied on the original `sendWhatsApp` call, `session.user.displayName` and the returned bearer token claims are now seeded from the verified contact record instead of staying `null`.
206
+
207
+ > **Note:** `redirectUrl` is optional. WhatsApp tokens are short hex strings (16 chars) for better UX.
208
+ >
209
+ > **Legacy note:** `verifyWhatsApp` is for older phone-bound token flows. Prefer inbound WhatsApp token confirmation plus status polling for new implementations.
210
+
211
+ ### Google OAuth
212
+
213
+ ```ts
214
+ // After Google sign-in, pass the id_token to Auth Kit
215
+ const session = await authKit.googleLogin(clientId, googleIdToken);
216
+ ```
217
+
218
+ ### Sign in with Apple
219
+
220
+ Pass the Apple **identity token** (a JWT). On iOS/native it's
221
+ `ASAuthorizationAppleIDCredential.identityToken` (UTF-8 decoded); on web it's
222
+ `response.authorization.id_token` from Apple JS.
223
+
224
+ ```ts
225
+ const session = await authKit.appleLogin(clientId, appleIdentityToken, {
226
+ // All optional:
227
+ nonce, // raw nonce, if you used nonce binding
228
+ userInfo: { name, email }, // first authorization callback ONLY — Apple never resends it
229
+ trustedDeviceToken, // skip an MFA step-up challenge on a recognized device — see "Step-up MFA" below
230
+ });
231
+ // session.isNewUser and session.expiresAt (ms epoch) are populated by this endpoint.
232
+ ```
233
+
234
+ Apple returns the user's name/email **only on the very first authorization, ever**, and
235
+ never inside the token. Capture it from that first callback and forward it via `userInfo`
236
+ so the server can seed the display name — it's treated as untrusted and never used for identity.
237
+
238
+ Apple login requires the client's AuthKit config to list allowed audiences in
239
+ `appleClientIds`; until then the endpoint returns `400 APPLE_AUTH_NOT_CONFIGURED`.
240
+
241
+ #### Verified-to-verified account linking (affects Google too)
242
+
243
+ Both `appleLogin` and `googleLogin` now refuse to silently merge a federated login into a
244
+ pre-existing account whose email is **unverified**. Instead they throw
245
+ `SmartlinksApiError` with `errorCode === 'ACCOUNT_EXISTS_UNVERIFIED'` (409) and
246
+ `err.details?.requiresEmailVerification === true`. Treat this as recoverable, not fatal:
247
+
248
+ ```ts
249
+ try {
250
+ const session = await authKit.appleLogin(clientId, appleIdentityToken);
251
+ } catch (err) {
252
+ if (err instanceof SmartlinksApiError && err.errorCode === 'ACCOUNT_EXISTS_UNVERIFIED') {
253
+ // "An account with this email exists but isn't verified. Sign in with your
254
+ // password (or reset it), then link Apple/Google from settings."
255
+ }
256
+ }
257
+ ```
258
+
259
+ > ⚠️ This is a behaviour change for `googleLogin`, which previously merged silently in
260
+ > this case. Handle the 409 for both methods.
261
+
262
+ ### Refresh tokens (native sessions)
263
+
264
+ Native/Capacitor hosts can hold long-lived sessions via refresh tokens. Opt in **once**
265
+ at startup so every request carries `X-Client-Platform: native`:
266
+
267
+ ```ts
268
+ initializeApi({ baseURL, platform: 'native' });
269
+ ```
270
+
271
+ With the opt-in active, the login endpoints (`login`, `register` in immediate mode,
272
+ `googleLogin`, `appleLogin`, `verifyPhoneCode`, `verifyMagicLink`,
273
+ `exchangeWhatsAppSession`) additionally return `refreshToken`, `refreshTokenExpiresAt`
274
+ (absolute, fixed, ms epoch), and a **short-lived** access `token`. Web clients are
275
+ unaffected and receive the unchanged response.
276
+
277
+ ```ts
278
+ // Later — exchange the refresh token for a fresh access token.
279
+ const r = await authKit.refreshToken(clientId, storedRefreshToken);
280
+ // r.refreshToken is ROTATED — persist it and discard the old one BEFORE the next call.
281
+ // The SDK already swapped in r.token as the active bearer for you.
282
+ persist(r.refreshToken, r.refreshTokenExpiresAt);
283
+
284
+ // On explicit sign-out — revokes the whole device family server-side + clears the bearer.
285
+ await authKit.logout(clientId, storedRefreshToken);
286
+ clearPersistedTokens();
287
+ ```
288
+
289
+ **Rotation rules the host MUST respect:**
290
+
291
+ 1. **Single-use + rotation.** Every `refreshToken()` returns a new token. Persist it and
292
+ overwrite the old one *before* the next call.
293
+ 2. **Serialize refreshes.** Fire a single in-flight refresh and queue callers behind it —
294
+ the SDK does **not** serialize for you, and racing two refreshes spends the same token.
295
+ 3. **Reuse = family death.** Replaying a consumed token throws
296
+ `SmartlinksApiError` with `errorCode === 'REFRESH_TOKEN_REUSE_DETECTED'`; treat as a
297
+ hard logout (clear storage, force re-login). `INVALID_REFRESH_TOKEN` (expired/revoked)
298
+ is handled the same way.
299
+ 4. **Absolute expiry is fixed.** `refreshTokenExpiresAt` never moves on rotation; once it
300
+ passes (default 90 days) the user must log in again.
301
+
302
+ > Resume-refresh scheduling and transparent refresh-on-401 are the responsibility of the
303
+ > host/auth-ui layer, not this SDK — the SDK only exposes the `refreshToken()` / `logout()`
304
+ > primitives and the `platform` opt-in.
305
+
306
+ ---
307
+
308
+ ## Step-up MFA (Phase 1)
309
+
310
+ Backend-only for now — **no admin-console UI and no challenge UI ship with this pass.**
311
+ Factors: **email OTP, SMS OTP, recovery codes**. WhatsApp OTP, TOTP, and passkeys as
312
+ *factors* are deferred to a later phase; don't build against them yet.
313
+
314
+ The step-up gate applies to **every login method that issues a session**:
315
+
316
+ | Method | Gated? |
317
+ |---|---|
318
+ | `login()` | ✅ |
319
+ | `googleLogin()` | ✅ |
320
+ | `appleLogin()` | ✅ — pass `opts.trustedDeviceToken` |
321
+ | `verifyPhoneCode()` | ✅ |
322
+ | `verifyMagicLink()` | ✅ |
323
+ | `exchangeWhatsAppSession()` | ✅ — **this is the WhatsApp method that needs a trusted-device token, not `verifyWhatsApp()`** |
324
+ | `verifyWhatsApp()` | ❌ — only confirms the code, never issues a session, so there's nothing to gate |
325
+ | `register()` | ❌ — a brand-new user has no enrolled factors to challenge against |
326
+ | `googleCodeLogin()` | ❌ — no `/auth/google-code` route exists server-side |
327
+
328
+ All six gated methods accept an optional `trustedDeviceToken` param (the last positional
329
+ argument, or `opts.trustedDeviceToken` for `appleLogin()`) — same purpose everywhere: skip
330
+ the challenge on a device the user already verified.
331
+
332
+ ### Handling `MFA_REQUIRED`
333
+
334
+ Every gated method's return type is unchanged. When the client's MFA policy requires a
335
+ step-up, the server returns 403 instead of a session, and the method throws — identically
336
+ for all six:
337
+
338
+ ```ts
339
+ import { authKit, SmartlinksApiError } from '@proveanything/smartlinks';
340
+
341
+ try {
342
+ const session = await authKit.login(clientId, email, password);
343
+ // logged in, no MFA required
344
+ } catch (err) {
345
+ if (err instanceof SmartlinksApiError && err.errorCode === 'MFA_REQUIRED') {
346
+ const { mfaSessionToken, availableFactors, preferredFactor, maskedDestinations } = err.details!;
347
+ // → route to a challenge UI; call authKit.mfaChallengeSend(clientId, mfaSessionToken, preferredFactor)
348
+ } else {
349
+ throw err;
350
+ }
351
+ }
352
+ ```
353
+
354
+ ### Completing the challenge
355
+
356
+ ```ts
357
+ // Send (or resend, or switch factor) a code on the same mfaSessionToken
358
+ const sent = await authKit.mfaChallengeSend(clientId, mfaSessionToken, 'sms');
359
+ // sent.destination is masked, e.g. "+1******1234"
360
+
361
+ // Verify the code — behaves like login() on success: bearer token is adopted automatically
362
+ const session = await authKit.mfaChallengeVerify(clientId, mfaSessionToken, '123456', /* trustDevice */ true, 'My Laptop');
363
+
364
+ // Or finalize with a single-use recovery code instead
365
+ const session = await authKit.mfaRecoveryCode(clientId, mfaSessionToken, recoveryCode);
366
+ ```
367
+
368
+ `mfaSessionToken` is short-lived (10 min) and single-use — burned on 5 failed attempts
369
+ (`MFA_TOO_MANY_ATTEMPTS`) or on success. Once burned/expired, there's no "resend within the
370
+ same session" — the caller must restart from `login()`.
371
+
372
+ ### Trusted devices — "don't ask again on this device"
373
+
374
+ There's no fingerprinting involved: a "recognized device" is purely "the caller presented a
375
+ valid, unexpired, unrevoked `trustedDeviceToken`". Handle it like the native refresh token
376
+ above — same secure storage, same persist-before-next-call discipline:
377
+
378
+ ```ts
379
+ // 1) Persist trustedDeviceToken from a successful challenge (trustDevice: true)
380
+ persistTrustedDeviceToken(session.trustedDeviceToken, session.trustedDeviceExpiresAt);
381
+
382
+ // 2) Send it on every subsequent call to ANY of the six gated methods — if still valid,
383
+ // the challenge is skipped entirely. It isn't tied to which method the user challenged
384
+ // through, so "remember this device" works no matter which login method they pick next.
385
+ const session = await authKit.login(clientId, email, password, storedTrustedDeviceToken);
386
+ const session = await authKit.googleLogin(clientId, idToken, storedTrustedDeviceToken);
387
+ const session = await authKit.exchangeWhatsAppSession(clientId, waToken, sessionKey, storedTrustedDeviceToken);
388
+ // ...and so on for appleLogin (via opts), verifyPhoneCode, verifyMagicLink.
389
+ // If it's revoked/expired, the server silently falls back to requiring a fresh challenge —
390
+ // the method just throws MFA_REQUIRED again, no special-case handling needed.
391
+
392
+ // 3) Let users audit/revoke devices from a Settings screen
393
+ const { devices } = await authKit.listTrustedDevices(clientId);
394
+ await authKit.revokeTrustedDevice(clientId, deviceId);
395
+ ```
396
+
397
+ ### Factor management (Settings → Security)
398
+
399
+ Authenticated (bearer token) — same `mfaSessionToken` + code mechanism as login challenges,
400
+ just against a `purpose: 'enroll'` challenge instead of `'login'`.
401
+
402
+ ```ts
403
+ // Enroll email (uses the account's existing email)
404
+ const { mfaSessionToken } = await authKit.enrollEmailMfa(clientId);
405
+ await authKit.confirmEmailMfa(clientId, mfaSessionToken, code);
406
+
407
+ // Enroll SMS
408
+ const { mfaSessionToken: smsToken } = await authKit.enrollSmsMfa(clientId, '+61400000000');
409
+ await authKit.confirmSmsMfa(clientId, smsToken, code);
410
+
411
+ // Recovery codes — plaintext, shown exactly once; nothing else in the API returns them again
412
+ const { recoveryCodes } = await authKit.generateMfaRecoveryCodes(clientId, password);
413
+
414
+ // Inspect / remove
415
+ const factors = await authKit.getMfaFactors(clientId);
416
+ // factors: { enrolledFactors: { email?, sms? }, recoveryCodesRemaining, mfaEnabledForClient }
417
+ await authKit.removeMfaFactor(clientId, 'sms', password);
418
+ ```
419
+
420
+ > Enrollment endpoints don't currently check the client's `mfa.mode` — enrolling is possible
421
+ > even when MFA is configured `'off'` for a client. Those factors simply won't be challenged
422
+ > at login until an admin turns the mode on (via the existing admin AuthKit config update,
423
+ > no new admin route in this pass).
424
+
425
+ ### MFA error codes
426
+
427
+ | `errorCode` | HTTP | Meaning / client action |
428
+ |---|---|---|
429
+ | `MFA_REQUIRED` | 403 | From any of the six gated login methods — see above |
430
+ | `INVALID_MFA_CODE` | 401 | Wrong code — let the user retry (attempts are capped) |
431
+ | `MFA_TOO_MANY_ATTEMPTS` | 429 | 5 wrong attempts — the challenge is burned; restart from `login()` |
432
+ | `MFA_FACTOR_NOT_ENROLLED` | 400 | Requested factor isn't enrolled — programming error if the UI only offers `availableFactors` |
433
+ | `MFA_SESSION_EXPIRED` | 401 | `mfaSessionToken` past its 10-minute TTL — restart from `login()` |
434
+ | `MFA_SESSION_INVALID` | 401 | Unknown/wrong-client/already-consumed token, or (on enroll confirm) mismatched user |
435
+ | `RECOVERY_CODE_INVALID` | 401 | Wrong or already-used recovery code |
436
+
437
+ ---
438
+
439
+ ## Profile management
440
+
441
+ ```ts
442
+ import { authKit } from '@proveanything/smartlinks';
443
+
444
+ // Get current user's profile
445
+ const profile = await authKit.getProfile(clientId);
446
+
447
+ // Update profile
448
+ const updatedProfile = await authKit.updateProfile(clientId, {
449
+ displayName: 'Alice B.',
450
+ avatarUrl: '...'
451
+ });
452
+
453
+ // The SDK automatically swaps in updatedProfile.token so future auth.verify()
454
+ // and authenticated calls use fresh displayName/photoURL claims immediately.
455
+
456
+ // Change password
457
+ await authKit.changePassword(clientId, 'currentPass', 'newPass');
458
+
459
+ // Change email (triggers verification)
460
+ await authKit.changeEmail(clientId, 'newemail@example.com', 'password', redirectUrl);
461
+
462
+ // Delete account
463
+ await authKit.deleteAccount(clientId, 'password', 'DELETE');
464
+ ```
465
+
466
+ `updateProfile` now returns a fresh bearer token together with the updated profile fields. The SDK replaces the in-memory bearer token automatically so token-backed identity reads stay current without an extra refresh step.
467
+
468
+ ---
469
+
470
+ ## Email verification
471
+
472
+ Auth Kit can send and verify email addresses after registration:
473
+
474
+ ```ts
475
+ await authKit.sendEmailVerification(clientId, {
476
+ userId,
477
+ email: 'user@example.com',
478
+ redirectUrl: 'https://myapp.example.com/auth/verified',
479
+ });
480
+
481
+ // On callback page
482
+ await authKit.verifyEmail(clientId, tokenFromUrl);
483
+ ```
484
+
485
+ ---
486
+
487
+ ## Password reset
488
+
489
+ ```ts
490
+ await authKit.requestPasswordReset(clientId, {
491
+ email: 'user@example.com',
492
+ redirectUrl: 'https://myapp.example.com/auth/reset',
493
+ clientName: 'My App',
494
+ });
495
+
496
+ // On reset page — verify token is still valid before showing the form
497
+ await authKit.verifyResetToken(clientId, tokenFromUrl);
498
+
499
+ // Complete reset
500
+ await authKit.completePasswordReset(clientId, tokenFromUrl, 'newSecurePassword');
501
+ ```
502
+
503
+ ---
504
+
505
+ ## Account security policy
506
+
507
+ Each collection can configure account-security rules. **The API enforces all of it**; your
508
+ login UI reads the policy for UX only (a live password checklist, idle sign-out). There are
509
+ two sides: an **admin** writes the policy, and the **login UI** reads the public subset.
510
+
511
+ **Read (login UI, public — no auth):**
512
+
513
+ ```ts
514
+ const config = await authKit.load(clientId);
515
+ const policy = config.security?.passwordPolicy; // min length, char classes, block-common
516
+ const session = config.security?.session; // inactivity + absolute timeouts, rememberMe
517
+ ```
518
+
519
+ `lockout` values are admin-only and never returned by `load`.
520
+
521
+ **Write (admin):** set the whole `security` block with `authKit.update` (or `authKit.create`).
522
+ The shape is {@link AuthKitSecurityConfig}; see {@link AuthKitConfigInput}.
523
+
524
+ ```ts
525
+ await authKit.update(collectionId, clientId, {
526
+ security: {
527
+ passwordPolicy: {
528
+ minLength: 8, // hard floor enforced server-side
529
+ blockCommonPasswords: true, // reject the common/breached list
530
+ requireUppercase: false, requireLowercase: false,
531
+ requireNumber: false, requireSymbol: false,
532
+ expiryDays: 0, // 0 = never expires
533
+ historyCount: 0, // 0 = reuse allowed
534
+ },
535
+ lockout: { // admin-only; enforced server-side
536
+ enabled: true, maxFailedAttempts: 5,
537
+ attemptWindowMinutes: 15, lockoutMinutes: 15,
538
+ notifyUserOnLockout: true,
539
+ },
540
+ session: {
541
+ inactivityTimeoutMinutes: 0, // 0 = disabled (client-enforced when set)
542
+ inactivityWarningSeconds: 60,
543
+ absoluteTimeoutHours: 0, // 0 = use token lifetime (server-enforced)
544
+ rememberMe: true,
545
+ },
546
+ },
547
+ });
548
+ ```
549
+
550
+ Defaults if a collection has never set a policy: 8-char minimum + block-common for everyone,
551
+ lockout disabled, no expiry/history, no idle/absolute timeout. Omit any field to take its default.
552
+
553
+ ### Password policy
554
+
555
+ `register`, `completePasswordReset`, and `changePassword` validate the new password
556
+ server-side and throw a `SmartlinksApiError` with a {@link PasswordPolicyErrorCode}:
557
+
558
+ | `errorCode` (400) | Meaning |
559
+ |---|---|
560
+ | `PASSWORD_TOO_SHORT` | below `minLength` |
561
+ | `PASSWORD_REQUIREMENTS_NOT_MET` | missing a required character class |
562
+ | `PASSWORD_TOO_COMMON` | on the common/breached list |
563
+ | `PASSWORD_RECENTLY_USED` | matched one of the last `historyCount` passwords |
564
+
565
+ Render a live checklist from `policy` so users see the rules before submitting.
566
+
567
+ ### Lockout
568
+
569
+ After too many failed logins the account is temporarily locked. `login` throws:
570
+
571
+ ```ts
572
+ try {
573
+ await authKit.login(clientId, email, password);
574
+ } catch (err) {
575
+ if (err.errorCode === 'ACCOUNT_TEMPORARILY_LOCKED') {
576
+ const mins = Math.ceil(err.details.retryAfterSeconds / 60);
577
+ show(`Too many attempts. Try again in ${mins} minute(s).`);
578
+ }
579
+ }
580
+ ```
581
+
582
+ Failed MFA challenges count toward the same lock. Locking responds identically for unknown
583
+ accounts (no enumeration).
584
+
585
+ ### Password expiry
586
+
587
+ If a password is older than `passwordPolicy.expiryDays`, a valid login is refused with
588
+ **403 `PASSWORD_EXPIRED`** carrying a short-lived `resetToken` — send the user straight into
589
+ the reset form to change it in place:
590
+
591
+ ```ts
592
+ catch (err) {
593
+ if (err.errorCode === 'PASSWORD_EXPIRED') {
594
+ await authKit.completePasswordReset(clientId, err.details.resetToken, newPassword);
595
+ }
596
+ }
597
+ ```
598
+
599
+ ### Session lifetime
600
+
601
+ - **Absolute timeout** (`session.absoluteTimeoutHours`) is enforced server-side on the native
602
+ refresh path: once the session is too old, `refreshToken` throws **401 `SESSION_EXPIRED`**
603
+ (see {@link RefreshErrorCode}) — clear storage and route to login. Web sessions use the
604
+ stateless bearer token and rely on inactivity sign-out below.
605
+ - **Inactivity timeout** (`session.inactivityTimeoutMinutes` / `inactivityWarningSeconds`) is
606
+ **client-enforced** — sign the user out after idle, warning first. Sync across tabs.
607
+ - **`session.rememberMe: false`** → don't persist tokens to durable storage; treat the session
608
+ as browser-scoped.
609
+
610
+ ---
611
+
612
+ ## Own logins (a site's own members)
613
+
614
+ By default an Auth Kit signs people in with their **SmartLinks account**: one account, the same
615
+ everywhere a site uses the shared login. A site that manages its own members (a club, a league, a
616
+ membership site) can instead have **its own logins**: its members are accounts of that site only. The
617
+ same email can be a separate account, with a different password, on another site, and signing in on
618
+ one site doesn't sign anyone in elsewhere.
619
+
620
+ ```ts
621
+ await authKit.setIdentity(collectionId, authKitId, { mode: 'own' })
622
+ // → { identity: { mode: 'own', tenantId }, message }
623
+ ```
624
+
625
+ - **One way.** A site with its own members can't go back to the shared login (they would lose their
626
+ accounts).
627
+ - **New sign-ups** after the switch go to the site's own members. People who signed up before keep their
628
+ SmartLinks account until they are moved across.
629
+ - **One per collection.** Only one Auth Kit can give a collection its own logins (`MEMBER_POOL_CONFLICT`,
630
+ 409, otherwise). You must administer every collection the kit serves (`PERMISSION_DENIED`, 403).
631
+ - **Tokens stay on their site.** A member's session works on that site's Auth Kit and collections only.
632
+ - Every flow above (password, magic link, phone, Google, Apple, LinkedIn, WhatsApp, SMS, MFA, profile,
633
+ resets) works the same way; nothing changes in the login UI or your code.
634
+ - `identity` is read-only on `create` / `update`; it is shown on the config (`authKit.get`).
635
+
636
+ ## Relationship to other parts of the SDK
637
+
638
+ | Concern | Where it lives |
639
+ |---------|---------------|
640
+ | End-user sign-in / register | `authKit` namespace (this doc) |
641
+ | Admin Bearer token auth | Platform shell — not set by your app |
642
+ | Per-user data storage | `userAppData` namespace — see [app-data-storage.md](app-data-storage.md) |
643
+ | User identity in analytics | `userId` field on `analytics` and `interactions` calls |
644
+
645
+ ---
646
+
647
+ ## Further reading
648
+
649
+ - [app-data-storage.md](app-data-storage.md) — storing user-specific data after login
650
+ - [app-manifest.md](app-manifest.md) — `app.admin.json` setup questions for configuring `clientId`