herald-auth-web 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,231 @@
1
+ # herald-auth-web
2
+
3
+ Official Herald browser JavaScript SDK for **third-party web integration**.
4
+
5
+ Framework-agnostic (React, Vue, or plain HTML), **zero runtime dependencies**
6
+ (native `fetch` + `WebCrypto` + `localStorage`). Wraps Herald's browser Bearer
7
+ authentication lifecycle — register, email verification, password reset, login
8
+ (with TOTP / passkey second factors and passwordless email-OTP), silent access
9
+ token refresh, logout, and status.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install herald-auth-web
15
+ ```
16
+
17
+ The package is **ESM-only** (`import`/`export`). Modern bundlers (Vite, webpack 5+,
18
+ Next.js, esbuild, Rollup) and ESM CDNs (esm.sh) consume it directly. It targets
19
+ the browser — there is no CommonJS build (Node/server use is a separate
20
+ server SDK's job).
21
+
22
+ ### CDN / `<script>` (no build step)
23
+
24
+ A minified IIFE bundle exposing a `Herald` global is published for third-party
25
+ pages that integrate via a script tag:
26
+
27
+ ```html
28
+ <script src="https://unpkg.com/herald-auth-web"></script>
29
+ <script>
30
+ const client = Herald.createHeraldClient({
31
+ baseUrl: 'https://auth.example.com',
32
+ realmId: '<your-realm>',
33
+ clientId: '<your-client-app>',
34
+ })
35
+ </script>
36
+ ```
37
+
38
+ The `unpkg`/`jsdelivr` package fields resolve to `dist/index.global.js`, so the
39
+ bare URL above works; `https://cdn.jsdelivr.net/npm/herald-auth-web` works too.
40
+
41
+ > Building from source requires regenerating the typed client from the Herald
42
+ > backend: `npm run generate-api` (needs `cargo` + the backend), then `npm run build`.
43
+
44
+
45
+ ## Quick start
46
+
47
+ ```ts
48
+ import { createHeraldClient, HeraldError } from 'herald-auth-web'
49
+
50
+ const client = createHeraldClient({
51
+ baseUrl: 'https://auth.example.com', // Herald API origin
52
+ realmId: '<your-realm>',
53
+ clientId: '<your-client-app>',
54
+ onSessionChange: (event) => {
55
+ if (event.type === 'session-expired') {
56
+ // redirect to your login page
57
+ }
58
+ },
59
+ })
60
+
61
+ // Register
62
+ await client.register({ email: 'user@example.com', password: '••••••••' })
63
+
64
+ // Email verification / password reset: the backend sends an email with a link
65
+ // that 302-redirects to your pre-registered Client App page. The SDK only
66
+ // triggers the send:
67
+ await client.triggerVerifyEmail({ email: 'user@example.com' })
68
+
69
+ // Login — multi-branch result (discriminate on `kind`)
70
+ const result = await client.login({ email: 'user@example.com', password: '••••••••' })
71
+ switch (result.kind) {
72
+ case 'success':
73
+ // logged in; result.session
74
+ break
75
+ case 'requires-second-factor': {
76
+ // result.secondFactors ⊆ ['totp', 'passkey']; result.tempToken
77
+ const final = await client.verifyTotp({ tempToken: result.tempToken, code: '123456' })
78
+ break
79
+ }
80
+ case 'consent-required':
81
+ // render result.agreements, then re-call login({ ..., agreements: result.agreements })
82
+ break
83
+ case 'oauth-redirect':
84
+ window.location.href = result.redirectTo
85
+ break
86
+ }
87
+
88
+ // Authenticated requests automatically inject Bearer + silently refresh on 401.
89
+ ```
90
+
91
+ ## Origin pre-registration (CORS)
92
+
93
+ Herald's CORS policy matches the request origin against the Client App's
94
+ `allowed_origins` **exactly**. Before the SDK can call the API from your page,
95
+ add your page origin (scheme + host + port, e.g. `https://app.example.com`) to
96
+ the Client App's `allowed_origins` in the Herald console.
97
+
98
+ A non-registered origin surfaces as a `HeraldError { kind: 'network' }` (the
99
+ browser cannot distinguish a CORS rejection from a generic network failure).
100
+
101
+ ## Turnstile
102
+
103
+ If the realm enforces Cloudflare Turnstile, pass the Turnstile token via the
104
+ `turnstileToken` field of each method payload.
105
+
106
+ ## Token storage model
107
+
108
+ - **Access token** — held **only in memory** (never persisted). A page reload
109
+ clears it; the SDK silently refreshes it on the next request.
110
+ - **Refresh token** — stored via a pluggable `TokenStorage` interface; the
111
+ **default is `localStorage`**. The backend rotates it on every refresh and
112
+ revokes the whole family on reuse detection.
113
+
114
+ Tradeoff: a refresh token in `localStorage` is readable by XSS. This matches the
115
+ Herald own-frontend risk posture and is mitigated by server-side rotation +
116
+ reuse detection + absolute TTL + short-lived access tokens. For higher security,
117
+ inject `memoryStorage()` (no persistence across reloads) or a custom adapter:
118
+
119
+ ```ts
120
+ import { createHeraldClient, memoryStorage } from 'herald-auth-web'
121
+
122
+ const client = createHeraldClient({
123
+ baseUrl: 'https://auth.example.com',
124
+ realmId: '<realm>',
125
+ clientId: '<client-app>',
126
+ storage: memoryStorage(),
127
+ })
128
+ ```
129
+
130
+ ## SSR / Node
131
+
132
+ `createHeraldClient` throws `HeraldError { kind: 'ssr-no-storage' }` when no
133
+ `storage` adapter is injected and `localStorage` is unavailable (e.g. SSR/Node).
134
+ Inject an explicit adapter in non-browser environments.
135
+
136
+ ## Passkey login
137
+
138
+ Passkey login is two steps with a browser WebAuthn assertion in between:
139
+
140
+ ```ts
141
+ import { performPasskeyAssertion } from 'herald-auth-web'
142
+
143
+ // 1FA passkey login
144
+ const begin = await client.passkey.loginBegin({})
145
+ const assertion = await performPasskeyAssertion(begin.options)
146
+ const result = await client.passkey.loginFinish({ authToken: begin.authToken, assertion })
147
+
148
+ // 2FA passkey login (after a `requires-second-factor` result):
149
+ // const begin = await client.passkey.loginBegin({ tempToken })
150
+ ```
151
+
152
+ Passkey RP isolation requires your page origin to match the Client App's
153
+ pre-registered origin.
154
+
155
+ ## Passwordless email-OTP login
156
+
157
+ Email-OTP is an independent **passwordless first factor** (not a second factor).
158
+ `send` resolves a discriminated result: the two 409 control-flow outcomes —
159
+ `consent_required` (auto-register consent gate; render `agreements` and re-send
160
+ with them) and `email_not_registered` (auto-register off) — arrive as
161
+ `{ kind: 'conflict' }` instead of throwing:
162
+
163
+ ```ts
164
+ const sent = await client.loginWithEmailOtp.send({ email: 'user@example.com' })
165
+ if (sent.kind === 'conflict' && sent.code === 'consent_required') {
166
+ // render sent.agreements (each entry carries the raw backend summary on
167
+ // `.raw` for display), then re-send with the accepted pairs:
168
+ await client.loginWithEmailOtp.send({
169
+ email: 'user@example.com',
170
+ agreements: sent.agreements.map(({ agreementType, versionId }) => ({ agreementType, versionId })),
171
+ })
172
+ }
173
+
174
+ // Verify applies the issued token set on success (same as login).
175
+ const result = await client.loginWithEmailOtp.verify({ email: 'user@example.com', code: '123456' })
176
+ ```
177
+
178
+ ## Error handling
179
+
180
+ Every method rejects with a `HeraldError`. Branch on the stable `kind`:
181
+
182
+ ```ts
183
+ try {
184
+ await client.login({ email, password })
185
+ } catch (e) {
186
+ if (e instanceof HeraldError) {
187
+ switch (e.kind) {
188
+ case 'unauthorized': // bad credentials
189
+ case 'rate-limited': // 429
190
+ case 'validation': // 400
191
+ case 'network': // fetch failed / CORS
192
+ // ...
193
+ }
194
+ }
195
+ }
196
+ ```
197
+
198
+ ## Out of scope
199
+
200
+ This SDK wraps only the `/login` direct-signed `CustomUserUi` credential class
201
+ (no PKCE → FirstParty), and the authentication lifecycle. Server-side resource
202
+ management, high-risk operations (password change, authenticator management,
203
+ account deletion), and framework-specific adapters are separate concerns.
204
+
205
+ ## First-party consumption (Herald's own frontend)
206
+
207
+ Herald's own frontend also consumes this SDK as its token engine
208
+ (DEC-js-sdk-013). On top of the third-party surface above it uses the additive
209
+ first-party bridge:
210
+
211
+ - `client.tokens.getAccessToken()` / `setTokens({ accessToken, refreshToken,
212
+ clientId? })` / `clear()` / `bindClientId(clientId)` — inspect / inject the
213
+ token family owned by the host app (e.g. after its own PKCE exchange or
214
+ switch-client, which stay in the host per the scope decision above);
215
+ - `client.refresh()` — the public single-flight refresh (shares its in-flight
216
+ promise with the 401 interceptor);
217
+ - `login` / `passkey.loginBegin` accept an optional OAuth context
218
+ (`oauthClientId`/`redirectUri`/`state`, or the `oauth` object) which is
219
+ passed through untouched; the backend answers with `redirectTo` and the
220
+ caller completes the exchange itself;
221
+ - `consent-required` results carry the raw agreement summary on each entry's
222
+ `raw` field for host apps that render the consent list.
223
+
224
+ ## Regenerating the client
225
+
226
+ The typed HTTP client is generated from the Herald backend OpenAPI spec:
227
+
228
+ ```bash
229
+ npm run generate-api # cargo export-openapi + @hey-api/openapi-ts
230
+ npm run build
231
+ ```