@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/dist/ai-tools.js +1 -1
- package/dist/api/authKit.d.ts +14 -1
- package/dist/api/authKit.js +15 -0
- package/dist/api/collection.d.ts +21 -11
- package/dist/api/collection.js +25 -13
- package/dist/api/tags.d.ts +8 -8
- package/dist/api/tags.js +10 -10
- package/dist/docs/API_SUMMARY.md +72 -21
- package/dist/docs/agent-tools.md +1 -1
- package/dist/docs/ai.md +6 -4
- package/dist/docs/app-manifest.md +1 -1
- package/dist/docs/auth-kit.md +650 -626
- package/dist/docs/connectors.md +1 -1
- package/dist/docs/server-functions.md +60 -4
- package/dist/index.d.ts +1 -1
- package/dist/openapi.yaml +139 -6
- package/dist/types/ai.d.ts +40 -2
- package/dist/types/appManifest.d.ts +10 -0
- package/dist/types/authKit.d.ts +21 -1
- package/dist/types/collection.d.ts +6 -4
- package/docs/API_SUMMARY.md +72 -21
- package/docs/agent-tools.md +1 -1
- package/docs/ai.md +6 -4
- package/docs/app-manifest.md +1 -1
- package/docs/auth-kit.md +650 -626
- package/docs/connectors.md +1 -1
- package/docs/server-functions.md +60 -4
- package/openapi.yaml +139 -6
- package/package.json +1 -1
package/dist/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
|
-
##
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
-
|
|
626
|
-
|
|
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`
|