@bentoforge/umami-iam 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -70,10 +70,32 @@ export declare class UmamiClient {
70
70
  registerPasskey(): Promise<{
71
71
  credentialId: string;
72
72
  }>;
73
- /** Passwordless login with a passkey via `navigator.credentials.get`; stores the token. Pass
74
- * `api` to mint the token for a product API directly (default: umami); the session keeps that
75
- * audience across refreshes. */
76
- loginWithPasskey(username: string, api?: string): Promise<void>;
73
+ /** Passwordless login with a passkey via `navigator.credentials.get`; stores the token.
74
+ *
75
+ * Omit `username` for a discoverable login: the challenge then carries no allow-list and the
76
+ * authenticator picks both the credential and, with it, the user. Pass `api` to mint the token
77
+ * for a product API directly (default: umami); the session keeps that audience across
78
+ * refreshes. */
79
+ loginWithPasskey(username?: string, api?: string): Promise<void>;
80
+ /** Passkey autofill (WebAuthn conditional mediation).
81
+ *
82
+ * Offers the passkey inside the browser's own autofill UI for the username field instead of
83
+ * behind a button. This is the only way to have a passkey surface alongside — and above — a
84
+ * saved password, because how a password manager ranks its own suggestions is not something a
85
+ * page can influence.
86
+ *
87
+ * Resolves once the user picks a passkey, which may be never: call it and leave the promise
88
+ * pending for the lifetime of the login page, aborting via `signal` when it unmounts. Rejects
89
+ * with `AbortError` in that case. Resolves to `false` without starting anything when the
90
+ * browser has no conditional mediation, so the caller can keep the explicit button.
91
+ *
92
+ * Requires a field marked `autocomplete="username webauthn"` to attach to. */
93
+ loginWithPasskeyAutofill(options?: {
94
+ signal?: AbortSignal;
95
+ api?: string;
96
+ }): Promise<boolean>;
97
+ /** The shared start → `navigator.credentials.get` → finish ceremony. */
98
+ private passkeyCeremony;
77
99
  /** Exchanges an `umk_…` API key for a short-lived token (stores it). Server-side/BFF use.
78
100
  * `api` selects the target API when the key allows more than one (see `docs/AUDIENCES.md`). */
79
101
  exchangeApiKey(apiKey: string, api?: string): Promise<ExchangeResponse>;
package/dist/client.js CHANGED
@@ -191,14 +191,50 @@ export class UmamiClient {
191
191
  }),
192
192
  });
193
193
  }
194
- /** Passwordless login with a passkey via `navigator.credentials.get`; stores the token. Pass
195
- * `api` to mint the token for a product API directly (default: umami); the session keeps that
196
- * audience across refreshes. */
194
+ /** Passwordless login with a passkey via `navigator.credentials.get`; stores the token.
195
+ *
196
+ * Omit `username` for a discoverable login: the challenge then carries no allow-list and the
197
+ * authenticator picks both the credential and, with it, the user. Pass `api` to mint the token
198
+ * for a product API directly (default: umami); the session keeps that audience across
199
+ * refreshes. */
197
200
  async loginWithPasskey(username, api) {
198
- const start = await this.request("/auth/webauthn/login/start", { method: "POST", body: JSON.stringify({ username }) }, false);
201
+ await this.passkeyCeremony(username, api);
202
+ }
203
+ /** Passkey autofill (WebAuthn conditional mediation).
204
+ *
205
+ * Offers the passkey inside the browser's own autofill UI for the username field instead of
206
+ * behind a button. This is the only way to have a passkey surface alongside — and above — a
207
+ * saved password, because how a password manager ranks its own suggestions is not something a
208
+ * page can influence.
209
+ *
210
+ * Resolves once the user picks a passkey, which may be never: call it and leave the promise
211
+ * pending for the lifetime of the login page, aborting via `signal` when it unmounts. Rejects
212
+ * with `AbortError` in that case. Resolves to `false` without starting anything when the
213
+ * browser has no conditional mediation, so the caller can keep the explicit button.
214
+ *
215
+ * Requires a field marked `autocomplete="username webauthn"` to attach to. */
216
+ async loginWithPasskeyAutofill(options) {
217
+ const available = await globalThis.PublicKeyCredential?.isConditionalMediationAvailable?.();
218
+ if (!available)
219
+ return false;
220
+ await this.passkeyCeremony(undefined, options?.api, {
221
+ mediation: "conditional",
222
+ signal: options?.signal,
223
+ });
224
+ return true;
225
+ }
226
+ /** The shared start → `navigator.credentials.get` → finish ceremony. */
227
+ async passkeyCeremony(username, api, get) {
228
+ const start = await this.request("/auth/webauthn/login/start",
229
+ // An absent username is what selects the discoverable flow server-side, so send the key
230
+ // only when there is one — `{ username: undefined }` would serialize it away anyway, but
231
+ // being explicit keeps the two flows visibly distinct.
232
+ { method: "POST", body: JSON.stringify(username ? { username } : {}) }, false);
199
233
  const publicKey = toRequestOptions(start.options.publicKey);
200
234
  const credential = (await navigator.credentials.get({
201
235
  publicKey,
236
+ mediation: get?.mediation,
237
+ signal: get?.signal,
202
238
  }));
203
239
  if (!credential)
204
240
  throw new Error("Passkey login was cancelled");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bentoforge/umami-iam",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Typed client SDK for the umami micro-IAM service",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/client.ts CHANGED
@@ -266,18 +266,61 @@ export class UmamiClient {
266
266
  });
267
267
  }
268
268
 
269
- /** Passwordless login with a passkey via `navigator.credentials.get`; stores the token. Pass
270
- * `api` to mint the token for a product API directly (default: umami); the session keeps that
271
- * audience across refreshes. */
272
- async loginWithPasskey(username: string, api?: string): Promise<void> {
269
+ /** Passwordless login with a passkey via `navigator.credentials.get`; stores the token.
270
+ *
271
+ * Omit `username` for a discoverable login: the challenge then carries no allow-list and the
272
+ * authenticator picks both the credential and, with it, the user. Pass `api` to mint the token
273
+ * for a product API directly (default: umami); the session keeps that audience across
274
+ * refreshes. */
275
+ async loginWithPasskey(username?: string, api?: string): Promise<void> {
276
+ await this.passkeyCeremony(username, api);
277
+ }
278
+
279
+ /** Passkey autofill (WebAuthn conditional mediation).
280
+ *
281
+ * Offers the passkey inside the browser's own autofill UI for the username field instead of
282
+ * behind a button. This is the only way to have a passkey surface alongside — and above — a
283
+ * saved password, because how a password manager ranks its own suggestions is not something a
284
+ * page can influence.
285
+ *
286
+ * Resolves once the user picks a passkey, which may be never: call it and leave the promise
287
+ * pending for the lifetime of the login page, aborting via `signal` when it unmounts. Rejects
288
+ * with `AbortError` in that case. Resolves to `false` without starting anything when the
289
+ * browser has no conditional mediation, so the caller can keep the explicit button.
290
+ *
291
+ * Requires a field marked `autocomplete="username webauthn"` to attach to. */
292
+ async loginWithPasskeyAutofill(options?: {
293
+ signal?: AbortSignal;
294
+ api?: string;
295
+ }): Promise<boolean> {
296
+ const available = await globalThis.PublicKeyCredential?.isConditionalMediationAvailable?.();
297
+ if (!available) return false;
298
+ await this.passkeyCeremony(undefined, options?.api, {
299
+ mediation: "conditional",
300
+ signal: options?.signal,
301
+ });
302
+ return true;
303
+ }
304
+
305
+ /** The shared start → `navigator.credentials.get` → finish ceremony. */
306
+ private async passkeyCeremony(
307
+ username: string | undefined,
308
+ api: string | undefined,
309
+ get?: { mediation?: CredentialMediationRequirement; signal?: AbortSignal },
310
+ ): Promise<void> {
273
311
  const start = await this.request<{ ceremonyId: string; options: any }>(
274
312
  "/auth/webauthn/login/start",
275
- { method: "POST", body: JSON.stringify({ username }) },
313
+ // An absent username is what selects the discoverable flow server-side, so send the key
314
+ // only when there is one — `{ username: undefined }` would serialize it away anyway, but
315
+ // being explicit keeps the two flows visibly distinct.
316
+ { method: "POST", body: JSON.stringify(username ? { username } : {}) },
276
317
  false,
277
318
  );
278
319
  const publicKey = toRequestOptions(start.options.publicKey);
279
320
  const credential = (await navigator.credentials.get({
280
321
  publicKey,
322
+ mediation: get?.mediation,
323
+ signal: get?.signal,
281
324
  })) as PublicKeyCredential | null;
282
325
  if (!credential) throw new Error("Passkey login was cancelled");
283
326
  const data = await this.request<TokenResponse>(