@oxyhq/core 11.0.0 → 12.0.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.
Files changed (34) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/index.js +13 -6
  3. package/dist/cjs/mixins/OxyServices.auth.js +65 -74
  4. package/dist/cjs/mixins/OxyServices.identity.js +16 -12
  5. package/dist/cjs/session/accountDialogController.js +2 -55
  6. package/dist/cjs/utils/officialOrigins.js +6 -0
  7. package/dist/cjs/utils/webauthnOrigin.js +51 -0
  8. package/dist/esm/.tsbuildinfo +1 -1
  9. package/dist/esm/index.js +10 -5
  10. package/dist/esm/mixins/OxyServices.auth.js +65 -74
  11. package/dist/esm/mixins/OxyServices.identity.js +16 -12
  12. package/dist/esm/session/accountDialogController.js +2 -55
  13. package/dist/esm/utils/officialOrigins.js +6 -1
  14. package/dist/esm/utils/webauthnOrigin.js +48 -0
  15. package/dist/types/.tsbuildinfo +1 -1
  16. package/dist/types/index.d.ts +3 -2
  17. package/dist/types/mixins/OxyServices.auth.d.ts +50 -36
  18. package/dist/types/mixins/OxyServices.identity.d.ts +13 -9
  19. package/dist/types/session/accountDialogController.d.ts +4 -34
  20. package/dist/types/utils/officialOrigins.d.ts +6 -0
  21. package/dist/types/utils/webauthnOrigin.d.ts +32 -0
  22. package/package.json +2 -2
  23. package/src/index.ts +11 -4
  24. package/src/mixins/OxyServices.auth.ts +95 -100
  25. package/src/mixins/OxyServices.identity.ts +19 -15
  26. package/src/mixins/__tests__/OxyServices.identity.test.ts +26 -14
  27. package/src/mixins/__tests__/webauthnAuth.test.ts +206 -0
  28. package/src/session/__tests__/accountDialogController.test.ts +0 -66
  29. package/src/session/accountDialogController.ts +4 -78
  30. package/src/utils/__tests__/officialOrigins.test.ts +14 -0
  31. package/src/utils/__tests__/webauthnOrigin.test.ts +83 -0
  32. package/src/utils/officialOrigins.ts +6 -1
  33. package/src/utils/webauthnOrigin.ts +52 -0
  34. package/src/mixins/__tests__/passwordSignIn.test.ts +0 -115
@@ -10,7 +10,7 @@
10
10
  * ```ts
11
11
  * import { OxyServices, oxyClient } from '@oxyhq/core';
12
12
  *
13
- * const user = await oxyClient.signIn(publicKey);
13
+ * const user = await oxyClient.getCurrentUser();
14
14
  * ```
15
15
  *
16
16
  * Every export below is NOMINAL — no `export *`, no barrels, no compat shims.
@@ -82,11 +82,12 @@ export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccou
82
82
  export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
83
83
  export { registrableApex } from './utils/registrableApex';
84
84
  export { CENTRAL_IDP_APEX } from './utils/authWebUrl';
85
+ export { isOxyRpOrigin } from './utils/webauthnOrigin';
85
86
  export { runColdBoot } from './utils/coldBoot';
86
87
  export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
87
88
  export { buildOAuthAuthorizeUrl, computeCodeChallenge, generateOAuthState, generatePkcePair, DEFAULT_OAUTH_SCOPE, OXY_AUTHORIZE_URL, OXY_OAUTH_STATE_STORAGE_KEY, OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, OXY_SILENT_OAUTH_ATTEMPTED_KEY, OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY, normalizeOAuthRedirectUri, persistOAuthHandshake, readOAuthHandshake, clearOAuthHandshake, } from './utils/oauthPkce';
88
89
  export type { PkcePair, BuildOAuthAuthorizeUrlParams } from './utils/oauthPkce';
89
- export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from './utils/officialOrigins';
90
+ export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isLoopbackOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from './utils/officialOrigins';
90
91
  export { syncHubAfterSignIn, redeemHubTicketOnHub, } from './session/hubSync';
91
92
  export type { SyncHubAfterSignInOptions } from './session/hubSync';
92
93
  export { SessionClient } from './session/SessionClient';
@@ -451,52 +451,66 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
451
451
  message: string;
452
452
  }>;
453
453
  /**
454
- * Register a new user with email/username and password
455
- */
456
- signUp(username: string, email: string, password: string, deviceName?: string, deviceFingerprint?: any): Promise<SessionLoginResponse>;
457
- /**
458
- * Sign in with email or username and password
459
- */
460
- signIn(identifier: string, password: string, deviceName?: string, deviceFingerprint?: any): Promise<SessionLoginResponse>;
454
+ * Begin a WebAuthn / passkey REGISTRATION ceremony. Requests the
455
+ * `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
456
+ * .create()` (or `@simplewebauthn/browser`'s `startRegistration`) needs.
457
+ *
458
+ * With a bearer token planted this links a passkey to the signed-in account
459
+ * (`username` ignored); without one it is a prospective signup and `username`
460
+ * is the desired handle. The returned options are OPAQUE — Oxy does not own
461
+ * their shape (the browser / `@simplewebauthn` does), so they pass through
462
+ * as `unknown` for the caller to hand straight to the ceremony.
463
+ */
464
+ webauthnRegisterOptions(username?: string): Promise<unknown>;
465
+ /**
466
+ * Finish a WebAuthn / passkey REGISTRATION ceremony. Forwards the opaque
467
+ * browser `RegistrationResponseJSON` (`response`) alongside the Oxy envelope
468
+ * (desired `username` for signup + the device-session naming fields).
469
+ *
470
+ * Two server branches, disambiguated by the response shape:
471
+ * - **Signup** (no bearer): the account is created and a session minted —
472
+ * the response carries `sessionId`, is the SAME {@link LoginResult}
473
+ * contract as `POST /auth/verify`, and its access token is planted here.
474
+ * - **Link** (bearer present): the passkey is attached to the signed-in
475
+ * account and the server returns `{ success, message }` with no session,
476
+ * which is returned verbatim (no token planting).
477
+ */
478
+ webauthnRegisterVerify(response: unknown, envelope?: {
479
+ username?: string;
480
+ deviceName?: string;
481
+ deviceFingerprint?: string;
482
+ deviceId?: string;
483
+ }): Promise<{
484
+ success: true;
485
+ message: string;
486
+ } | LoginResult>;
461
487
  /**
462
- * Convenience helper for email sign-in
488
+ * Begin a WebAuthn / passkey AUTHENTICATION ceremony. Requests the
489
+ * `PublicKeyCredentialRequestOptions` the browser's `navigator.credentials
490
+ * .get()` (or `@simplewebauthn/browser`'s `startAuthentication`) needs.
491
+ *
492
+ * When `username` is present the server scopes `allowCredentials` to that
493
+ * user's passkeys (username-first); when omitted it returns an empty
494
+ * allow-list for the usernameless / discoverable-credential flow. The
495
+ * returned options are OPAQUE and pass through as `unknown`.
463
496
  */
464
- signInWithEmail(email: string, password: string, deviceName?: string, deviceFingerprint?: any): Promise<SessionLoginResponse>;
497
+ webauthnLoginOptions(username?: string): Promise<unknown>;
465
498
  /**
466
- * Device-first password sign-in. Unlike the legacy {@link signIn} (which
467
- * assumes a one-step session and is kept intact for existing callers until
468
- * the F4 cutover), this returns the FULL `POST /auth/login` contract — the
469
- * discriminated {@link LoginResult}: either a 2FA challenge
470
- * (`{ twoFactorRequired, loginToken }`) to complete via
471
- * {@link completeTwoFactorSignIn}, or a session arm.
472
- *
473
- * On the session arm, a returned access token is planted immediately
474
- * (mirroring {@link verifyChallenge}), so the caller has an authenticated
475
- * client without a second round-trip. The response's `deviceId` +
476
- * `deviceSecret` are the zero-cookie restore credential the caller persists.
499
+ * Finish a WebAuthn / passkey AUTHENTICATION ceremony. Forwards the opaque
500
+ * browser `AuthenticationResponseJSON` (`response`) alongside the
501
+ * device-session envelope. Resolves to the SAME {@link LoginResult} contract
502
+ * as `POST /auth/verify`; the access token is planted immediately, and the
503
+ * response's `deviceId` + `deviceSecret` are the zero-cookie restore
504
+ * credential.
477
505
  */
478
- passwordSignIn(identifier: string, password: string, options?: {
506
+ webauthnLoginVerify(response: unknown, envelope?: {
479
507
  deviceName?: string;
480
508
  deviceFingerprint?: string;
481
509
  deviceId?: string;
482
510
  }): Promise<LoginResult>;
483
- /**
484
- * Complete a 2FA-gated sign-in started by {@link passwordSignIn}. Presents
485
- * the short-lived `loginToken` with either a TOTP `token` or a `backupCode`
486
- * to `POST /security/2fa/verify-login`, which must resolve to the session
487
- * arm of {@link LoginResult} (a second 2FA challenge here is a protocol
488
- * error). A returned access token is planted immediately.
489
- */
490
- completeTwoFactorSignIn(params: {
491
- loginToken: string;
492
- token?: string;
493
- backupCode?: string;
494
- deviceName?: string;
495
- deviceId?: string;
496
- }): Promise<LoginSessionResult>;
497
511
  /**
498
512
  * Exchange an OAuth authorization code (returned to the RP redirect URI
499
- * after password sign-in at auth.oxy.so) for a device-first session.
513
+ * after sign-in at auth.oxy.so) for a device-first session.
500
514
  * Public first-party clients use PKCE (`codeVerifier`); the access token is
501
515
  * planted immediately on success.
502
516
  */
@@ -33,7 +33,7 @@ import type { OxyServicesBase } from '../OxyServices.base';
33
33
  */
34
34
  export type IdentityRecordType = OxySignedRecordType;
35
35
  /** Auth-method types that can be unlinked via {@link OxyServicesIdentityMixin}. */
36
- export type UnlinkableAuthMethodType = 'identity' | 'password' | 'google' | 'apple' | 'github';
36
+ export type UnlinkableAuthMethodType = 'identity' | 'webauthn';
37
37
  /**
38
38
  * Result of a link/unlink auth-method mutation (`POST /auth/link`,
39
39
  * `DELETE /auth/link/:type`).
@@ -106,14 +106,6 @@ export declare function OxyServicesIdentityMixin<T extends typeof OxyServicesBas
106
106
  * exact bytes the server reconstructs and verifies.
107
107
  */
108
108
  linkIdentityKey(): Promise<LinkAuthMethodResult>;
109
- /**
110
- * Link password authentication to the current account. Adds a `password`
111
- * auth method (does not remove existing methods).
112
- *
113
- * @param email - The email to associate with password auth.
114
- * @param password - The new password (server enforces strength rules).
115
- */
116
- linkPassword(email: string, password: string): Promise<LinkAuthMethodResult>;
117
109
  /**
118
110
  * Unlink an authentication method from the current account. The server
119
111
  * refuses to remove the last remaining method (the account would become
@@ -122,6 +114,18 @@ export declare function OxyServicesIdentityMixin<T extends typeof OxyServicesBas
122
114
  * @param type - The auth-method type to remove.
123
115
  */
124
116
  unlinkAuthMethod(type: UnlinkableAuthMethodType): Promise<LinkAuthMethodResult>;
117
+ /**
118
+ * Remove ONE passkey (WebAuthn credential) from the current account.
119
+ *
120
+ * Passkeys are per-credential, so unlike {@link unlinkAuthMethod} (which
121
+ * removes an auth method by type) this targets a specific credential id.
122
+ * The server refuses to remove the last remaining auth method (the account
123
+ * would become inaccessible) and deletes the stored `WebauthnCredential`.
124
+ *
125
+ * @param credentialId - The passkey's public credential id
126
+ * (`AuthMethodEntry.credentialId`).
127
+ */
128
+ removePasskey(credentialId: string): Promise<LinkAuthMethodResult>;
125
129
  /**
126
130
  * Sign a record with the on-device identity key, WITHOUT publishing it.
127
131
  * The subject is the current user's DID. NATIVE-ONLY (requires a stored
@@ -21,9 +21,8 @@
21
21
  * `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
22
22
  * via `startCommonsSignIn` → poll → `claimSessionByToken`).
23
23
  *
24
- * It deliberately owns NO password/2FA logic those live at the IdP
25
- * (auth.oxy.so). {@link AccountDialogController.openPasswordAtOxyAuth} only
26
- * builds the hand-off URL; device-first convergence syncs the session back.
24
+ * Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff
25
+ * password, social login, and 2FA were removed ecosystem-wide.
27
26
  */
28
27
  import type { OxyServices } from '../OxyServices';
29
28
  import type { SessionLoginResponse, MinimalUserData } from '../models/session';
@@ -96,14 +95,6 @@ export interface AccountDialogControllerOptions {
96
95
  commitSession?: (session: SessionLoginResponse) => Promise<void>;
97
96
  /** Notified after a completed sign-in (bearer planted + session committed). */
98
97
  onSignedIn?: (user: MinimalUserData) => void;
99
- /** Central IdP apex for `openPasswordAtOxyAuth` (defaults to `CENTRAL_IDP_APEX`). */
100
- idpApex?: string;
101
- /**
102
- * Registered OAuth redirect URI for this RP (exact match against
103
- * `Application.redirectUris`). When set, wins over `returnUrl` /
104
- * `location.origin` normalization in {@link openPasswordAtOxyAuth}.
105
- */
106
- authRedirectUri?: string | null;
107
98
  /**
108
99
  * QR device-flow FALLBACK poll interval in ms (default 12000). The primary
109
100
  * approval signal is the `/auth-session` socket's `auth_update` event (instant);
@@ -119,8 +110,8 @@ export interface AccountDialogControllerOptions {
119
110
  */
120
111
  socketFactory?: SocketIOFactory;
121
112
  /**
122
- * Optional URL opener. When provided, `openPasswordAtOxyAuth` invokes it with
123
- * the built URL in addition to returning it (web: `location.assign`; native:
113
+ * Optional URL opener. When provided, the controller invokes it to deep-link
114
+ * the Commons app for the QR handoff (web: `location.assign`; native:
124
115
  * `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
125
116
  */
126
117
  openUrl?: (url: string) => void;
@@ -143,8 +134,6 @@ export declare class AccountDialogController {
143
134
  private readonly locale?;
144
135
  private readonly commitSession?;
145
136
  private readonly onSignedIn?;
146
- private readonly idpApex;
147
- private readonly authRedirectUri;
148
137
  private readonly pollIntervalMs;
149
138
  private readonly openUrl?;
150
139
  private readonly canOpenApp?;
@@ -277,25 +266,6 @@ export declare class AccountDialogController {
277
266
  private maybeOpenCommons;
278
267
  /** Tear down the active sign-in device flow (timers + socket + token) and reset to idle. */
279
268
  cancelSignIn(): void;
280
- /**
281
- * Build (and, when an `openUrl` handler was supplied, open) the auth.oxy.so
282
- * password sign-in URL. Password + 2FA are NOT in the SDK — they live at the
283
- * IdP; this only hands off. Device-first: after login at the IdP the device
284
- * session converges and the caller is woken via the device socket /
285
- * `BroadcastChannel`, so the URL only needs to point at the IdP sign-in with
286
- * the right return.
287
- *
288
- * @param params.returnUrl - Where the IdP returns after login. Defaults to the
289
- * current document URL on web (`globalThis.location.href`); pass explicitly
290
- * on native (no `location`).
291
- * @param params.state - Optional opaque state echoed back on return.
292
- * @returns The absolute auth.oxy.so sign-in URL.
293
- */
294
- openPasswordAtOxyAuth(params?: {
295
- returnUrl?: string;
296
- state?: string;
297
- redirectUri?: string;
298
- }): Promise<string>;
299
269
  private scheduleNextPoll;
300
270
  /**
301
271
  * Run one status check + (on approval) claim. Triggered by the fallback timer
@@ -5,6 +5,12 @@
5
5
  export declare function buildIdpHubOrigin(): string;
6
6
  /** Whether the current web origin is the central IdP hub (`auth.oxy.so`). */
7
7
  export declare function isIdpHubOrigin(): boolean;
8
+ /**
9
+ * Whether an origin is a loopback / local-dev origin (`localhost`, `127.0.0.1`,
10
+ * or `[::1]` on any port, http or https). Local dev must never be bounced to a
11
+ * hosted IdP for cross-origin session restore.
12
+ */
13
+ export declare function isLoopbackOrigin(origin: string): boolean;
8
14
  /** Whether an origin belongs to the official Oxy web ecosystem. */
9
15
  export declare function isOfficialWebOrigin(origin: string): boolean;
10
16
  /** Normalize and validate a return URL against official origins. Returns origin only. */
@@ -0,0 +1,32 @@
1
+ /**
2
+ * WebAuthn relying-party origin guard (client side).
3
+ *
4
+ * The passkey ceremonies (`OxyServices.webauthn*`) are only meaningful when the
5
+ * page is served from a first-party Oxy web origin: a credential minted with
6
+ * `WEBAUTHN_RP_ID=oxy.so` can only be created/asserted from `oxy.so`, one of its
7
+ * subdomains, or a loopback dev server. This is the browser-side mirror of the
8
+ * server's `isOxyApexOrigin` (`packages/api/src/utils/origin.ts`), which forms
9
+ * the server's `expectedOrigin` allow-set — consumers use it to decide whether to
10
+ * even offer the passkey UI on the current page.
11
+ *
12
+ * It reads `globalThis.location` directly (no argument) because that is the only
13
+ * origin the browser will let a WebAuthn ceremony run against. On native / SSR /
14
+ * any environment without a DOM `location`, it returns `false` (there is no
15
+ * relying-party origin, so passkeys are not applicable).
16
+ */
17
+ /**
18
+ * True iff the current page's host is a first-party Oxy relying-party origin:
19
+ * `oxy.so`, any `*.oxy.so` subdomain, or a loopback dev host
20
+ * (`localhost` / `127.0.0.1` / `[::1]`).
21
+ *
22
+ * Fails closed: no `location` (native/SSR), a non-string/empty hostname, or a
23
+ * host that merely ends in the literal `oxy.so` without the dot boundary
24
+ * (`evil-oxy.so`, `oxy.so.evil.com`) all return `false`.
25
+ *
26
+ * @example
27
+ * isOxyRpOrigin() // true on https://accounts.oxy.so
28
+ * isOxyRpOrigin() // true on http://localhost:8081
29
+ * isOxyRpOrigin() // false on https://evil.com
30
+ * isOxyRpOrigin() // false in a React Native / SSR context (no location)
31
+ */
32
+ export declare function isOxyRpOrigin(): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "11.0.0",
3
+ "version": "12.0.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -109,7 +109,7 @@
109
109
  }
110
110
  },
111
111
  "dependencies": {
112
- "@oxyhq/contracts": "^0.14.1",
112
+ "@oxyhq/contracts": "^0.15.0",
113
113
  "@oxyhq/protocol": "^0.1.5",
114
114
  "bip39": "^3.1.0",
115
115
  "buffer": "^6.0.3",
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * ```ts
11
11
  * import { OxyServices, oxyClient } from '@oxyhq/core';
12
12
  *
13
- * const user = await oxyClient.signIn(publicKey);
13
+ * const user = await oxyClient.getCurrentUser();
14
14
  * ```
15
15
  *
16
16
  * Every export below is NOMINAL — no `export *`, no barrels, no compat shims.
@@ -517,6 +517,11 @@ export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
517
517
  export { registrableApex } from './utils/registrableApex';
518
518
  export { CENTRAL_IDP_APEX } from './utils/authWebUrl';
519
519
 
520
+ // WebAuthn relying-party origin guard (client side). Mirrors the server's
521
+ // `isOxyApexOrigin` so consumers can decide whether to offer passkey UI on the
522
+ // current page (first-party Oxy origin / loopback only).
523
+ export { isOxyRpOrigin } from './utils/webauthnOrigin';
524
+
520
525
  export { runColdBoot } from './utils/coldBoot';
521
526
  export type {
522
527
  ColdBootStep,
@@ -553,6 +558,7 @@ export {
553
558
  buildIdpHubOrigin,
554
559
  buildHubSyncUrl,
555
560
  isIdpHubOrigin,
561
+ isLoopbackOrigin,
556
562
  isOfficialWebOrigin,
557
563
  isAllowedDeviceJoinOrigin,
558
564
  normalizeOfficialReturnOrigin,
@@ -605,9 +611,10 @@ export type {
605
611
 
606
612
  // Headless controller for the unified account dialog. Framework-agnostic
607
613
  // state machine + subscribe/getSnapshot store (bind via `useSyncExternalStore`)
608
- // — no password/2FA logic (that lives at the IdP; `openPasswordAtOxyAuth` only
609
- // hands off). Reuses `SessionClient.switchAccount` / `oxyServices.switchToAccount`
610
- // for the uniform switch and the existing device-flow methods for sign-in.
614
+ // — sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff;
615
+ // password, social login, and 2FA were removed ecosystem-wide. Reuses
616
+ // `SessionClient.switchAccount` / `oxyServices.switchToAccount` for the uniform
617
+ // switch and the existing device-flow methods for sign-in.
611
618
  export {
612
619
  AccountDialogController,
613
620
  createAccountDialogController,
@@ -1059,133 +1059,128 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1059
1059
  }
1060
1060
 
1061
1061
  /**
1062
- * Register a new user with email/username and password
1062
+ * Begin a WebAuthn / passkey REGISTRATION ceremony. Requests the
1063
+ * `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
1064
+ * .create()` (or `@simplewebauthn/browser`'s `startRegistration`) needs.
1065
+ *
1066
+ * With a bearer token planted this links a passkey to the signed-in account
1067
+ * (`username` ignored); without one it is a prospective signup and `username`
1068
+ * is the desired handle. The returned options are OPAQUE — Oxy does not own
1069
+ * their shape (the browser / `@simplewebauthn` does), so they pass through
1070
+ * as `unknown` for the caller to hand straight to the ceremony.
1063
1071
  */
1064
- async signUp(
1065
- username: string,
1066
- email: string,
1067
- password: string,
1068
- deviceName?: string,
1069
- deviceFingerprint?: any
1070
- ): Promise<SessionLoginResponse> {
1072
+ async webauthnRegisterOptions(username?: string): Promise<unknown> {
1071
1073
  try {
1072
- const session = await this.makeRequest<SessionLoginResponse>('POST', '/auth/signup', {
1073
- username,
1074
- email,
1075
- password,
1076
- deviceName,
1077
- deviceFingerprint,
1078
- }, { cache: false });
1079
- return {
1080
- ...session,
1081
- user: normalizeUserIdentity(session.user),
1082
- };
1074
+ return await this.makeRequest<unknown>(
1075
+ 'POST',
1076
+ '/auth/webauthn/register/options',
1077
+ { ...(username !== undefined ? { username } : {}) },
1078
+ { cache: false },
1079
+ );
1083
1080
  } catch (error) {
1084
1081
  throw this.handleError(error);
1085
1082
  }
1086
1083
  }
1087
1084
 
1088
1085
  /**
1089
- * Sign in with email or username and password
1086
+ * Finish a WebAuthn / passkey REGISTRATION ceremony. Forwards the opaque
1087
+ * browser `RegistrationResponseJSON` (`response`) alongside the Oxy envelope
1088
+ * (desired `username` for signup + the device-session naming fields).
1089
+ *
1090
+ * Two server branches, disambiguated by the response shape:
1091
+ * - **Signup** (no bearer): the account is created and a session minted —
1092
+ * the response carries `sessionId`, is the SAME {@link LoginResult}
1093
+ * contract as `POST /auth/verify`, and its access token is planted here.
1094
+ * - **Link** (bearer present): the passkey is attached to the signed-in
1095
+ * account and the server returns `{ success, message }` with no session,
1096
+ * which is returned verbatim (no token planting).
1090
1097
  */
1091
- async signIn(
1092
- identifier: string,
1093
- password: string,
1094
- deviceName?: string,
1095
- deviceFingerprint?: any
1096
- ): Promise<SessionLoginResponse> {
1098
+ async webauthnRegisterVerify(
1099
+ response: unknown,
1100
+ envelope: {
1101
+ username?: string;
1102
+ deviceName?: string;
1103
+ deviceFingerprint?: string;
1104
+ deviceId?: string;
1105
+ } = {},
1106
+ ): Promise<{ success: true; message: string } | LoginResult> {
1097
1107
  try {
1098
- const session = await this.makeRequest<SessionLoginResponse>('POST', '/auth/login', {
1099
- identifier,
1100
- password,
1101
- deviceName,
1102
- deviceFingerprint,
1103
- }, { cache: false });
1104
- return {
1105
- ...session,
1106
- user: normalizeUserIdentity(session.user),
1107
- };
1108
+ const res = await this.makeRequest<unknown>(
1109
+ 'POST',
1110
+ '/auth/webauthn/register/verify',
1111
+ { response, ...envelope },
1112
+ { cache: false },
1113
+ );
1114
+ if (res && typeof res === 'object') {
1115
+ const record = res as Record<string, unknown>;
1116
+ // Signup branch: mints a session (LoginSessionResult, carries
1117
+ // `sessionId`). Parse against the login contract and plant the token.
1118
+ if ('sessionId' in record) {
1119
+ const parsed = safeParseContract(loginResultSchema, record);
1120
+ if (!parsed) {
1121
+ throw new Error('auth/webauthn/register/verify returned an unexpected response shape');
1122
+ }
1123
+ if (parsed.accessToken) {
1124
+ this.setTokens(parsed.accessToken);
1125
+ }
1126
+ return parsed;
1127
+ }
1128
+ // Link branch: passkey attached to the signed-in account, no session.
1129
+ if (record.success === true && typeof record.message === 'string') {
1130
+ return { success: true, message: record.message };
1131
+ }
1132
+ }
1133
+ throw new Error('auth/webauthn/register/verify returned an unexpected response shape');
1108
1134
  } catch (error) {
1109
1135
  throw this.handleError(error);
1110
1136
  }
1111
1137
  }
1112
1138
 
1113
1139
  /**
1114
- * Convenience helper for email sign-in
1115
- */
1116
- async signInWithEmail(
1117
- email: string,
1118
- password: string,
1119
- deviceName?: string,
1120
- deviceFingerprint?: any
1121
- ): Promise<SessionLoginResponse> {
1122
- return this.signIn(email, password, deviceName, deviceFingerprint);
1123
- }
1124
-
1125
- /**
1126
- * Device-first password sign-in. Unlike the legacy {@link signIn} (which
1127
- * assumes a one-step session and is kept intact for existing callers until
1128
- * the F4 cutover), this returns the FULL `POST /auth/login` contract — the
1129
- * discriminated {@link LoginResult}: either a 2FA challenge
1130
- * (`{ twoFactorRequired, loginToken }`) to complete via
1131
- * {@link completeTwoFactorSignIn}, or a session arm.
1140
+ * Begin a WebAuthn / passkey AUTHENTICATION ceremony. Requests the
1141
+ * `PublicKeyCredentialRequestOptions` the browser's `navigator.credentials
1142
+ * .get()` (or `@simplewebauthn/browser`'s `startAuthentication`) needs.
1132
1143
  *
1133
- * On the session arm, a returned access token is planted immediately
1134
- * (mirroring {@link verifyChallenge}), so the caller has an authenticated
1135
- * client without a second round-trip. The response's `deviceId` +
1136
- * `deviceSecret` are the zero-cookie restore credential the caller persists.
1144
+ * When `username` is present the server scopes `allowCredentials` to that
1145
+ * user's passkeys (username-first); when omitted it returns an empty
1146
+ * allow-list for the usernameless / discoverable-credential flow. The
1147
+ * returned options are OPAQUE and pass through as `unknown`.
1137
1148
  */
1138
- async passwordSignIn(
1139
- identifier: string,
1140
- password: string,
1141
- options: { deviceName?: string; deviceFingerprint?: string; deviceId?: string } = {},
1142
- ): Promise<LoginResult> {
1149
+ async webauthnLoginOptions(username?: string): Promise<unknown> {
1143
1150
  try {
1144
- const res = await this.makeRequest<unknown>('POST', '/auth/login', {
1145
- identifier,
1146
- password,
1147
- deviceName: options.deviceName,
1148
- deviceFingerprint: options.deviceFingerprint,
1149
- ...(options.deviceId ? { deviceId: options.deviceId } : {}),
1150
- }, { cache: false });
1151
- const parsed = safeParseContract(loginResultSchema, res);
1152
- if (!parsed) {
1153
- throw new Error('auth/login returned an unexpected response shape');
1154
- }
1155
- if (!('twoFactorRequired' in parsed) && parsed.accessToken) {
1156
- this.setTokens(parsed.accessToken);
1157
- }
1158
- return parsed;
1151
+ return await this.makeRequest<unknown>(
1152
+ 'POST',
1153
+ '/auth/webauthn/login/options',
1154
+ { ...(username !== undefined ? { username } : {}) },
1155
+ { cache: false },
1156
+ );
1159
1157
  } catch (error) {
1160
1158
  throw this.handleError(error);
1161
1159
  }
1162
1160
  }
1163
1161
 
1164
1162
  /**
1165
- * Complete a 2FA-gated sign-in started by {@link passwordSignIn}. Presents
1166
- * the short-lived `loginToken` with either a TOTP `token` or a `backupCode`
1167
- * to `POST /security/2fa/verify-login`, which must resolve to the session
1168
- * arm of {@link LoginResult} (a second 2FA challenge here is a protocol
1169
- * error). A returned access token is planted immediately.
1163
+ * Finish a WebAuthn / passkey AUTHENTICATION ceremony. Forwards the opaque
1164
+ * browser `AuthenticationResponseJSON` (`response`) alongside the
1165
+ * device-session envelope. Resolves to the SAME {@link LoginResult} contract
1166
+ * as `POST /auth/verify`; the access token is planted immediately, and the
1167
+ * response's `deviceId` + `deviceSecret` are the zero-cookie restore
1168
+ * credential.
1170
1169
  */
1171
- async completeTwoFactorSignIn(params: {
1172
- loginToken: string;
1173
- token?: string;
1174
- backupCode?: string;
1175
- deviceName?: string;
1176
- deviceId?: string;
1177
- }): Promise<LoginSessionResult> {
1170
+ async webauthnLoginVerify(
1171
+ response: unknown,
1172
+ envelope: { deviceName?: string; deviceFingerprint?: string; deviceId?: string } = {},
1173
+ ): Promise<LoginResult> {
1178
1174
  try {
1179
- const res = await this.makeRequest<unknown>('POST', '/security/2fa/verify-login', {
1180
- loginToken: params.loginToken,
1181
- token: params.token,
1182
- backupCode: params.backupCode,
1183
- deviceName: params.deviceName,
1184
- ...(params.deviceId ? { deviceId: params.deviceId } : {}),
1185
- }, { cache: false });
1175
+ const res = await this.makeRequest<unknown>(
1176
+ 'POST',
1177
+ '/auth/webauthn/login/verify',
1178
+ { response, ...envelope },
1179
+ { cache: false },
1180
+ );
1186
1181
  const parsed = safeParseContract(loginResultSchema, res);
1187
- if (!parsed || 'twoFactorRequired' in parsed) {
1188
- throw new Error('security/2fa/verify-login returned an unexpected response shape');
1182
+ if (!parsed) {
1183
+ throw new Error('auth/webauthn/login/verify returned an unexpected response shape');
1189
1184
  }
1190
1185
  if (parsed.accessToken) {
1191
1186
  this.setTokens(parsed.accessToken);
@@ -1198,7 +1193,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1198
1193
 
1199
1194
  /**
1200
1195
  * Exchange an OAuth authorization code (returned to the RP redirect URI
1201
- * after password sign-in at auth.oxy.so) for a device-first session.
1196
+ * after sign-in at auth.oxy.so) for a device-first session.
1202
1197
  * Public first-party clients use PKCE (`codeVerifier`); the access token is
1203
1198
  * planted immediately on success.
1204
1199
  */