@oxyhq/core 11.0.1 → 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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +7 -5
- package/dist/cjs/mixins/OxyServices.auth.js +6 -117
- package/dist/cjs/mixins/OxyServices.identity.js +16 -12
- package/dist/cjs/session/accountDialogController.js +2 -55
- package/dist/cjs/utils/officialOrigins.js +6 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +6 -5
- package/dist/esm/mixins/OxyServices.auth.js +6 -117
- package/dist/esm/mixins/OxyServices.identity.js +16 -12
- package/dist/esm/session/accountDialogController.js +2 -55
- package/dist/esm/utils/officialOrigins.js +6 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -48
- package/dist/types/mixins/OxyServices.identity.d.ts +13 -9
- package/dist/types/session/accountDialogController.d.ts +4 -34
- package/dist/types/utils/officialOrigins.d.ts +6 -0
- package/package.json +2 -2
- package/src/index.ts +6 -4
- package/src/mixins/OxyServices.auth.ts +6 -144
- package/src/mixins/OxyServices.identity.ts +19 -15
- package/src/mixins/__tests__/OxyServices.identity.test.ts +26 -14
- package/src/mixins/__tests__/webauthnAuth.test.ts +1 -1
- package/src/session/__tests__/accountDialogController.test.ts +0 -66
- package/src/session/accountDialogController.ts +4 -78
- package/src/utils/__tests__/officialOrigins.test.ts +14 -0
- package/src/utils/officialOrigins.ts +6 -1
- package/src/mixins/__tests__/passwordSignIn.test.ts +0 -115
package/dist/types/index.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* ```ts
|
|
11
11
|
* import { OxyServices, oxyClient } from '@oxyhq/core';
|
|
12
12
|
*
|
|
13
|
-
* const user = await oxyClient.
|
|
13
|
+
* const user = await oxyClient.getCurrentUser();
|
|
14
14
|
* ```
|
|
15
15
|
*
|
|
16
16
|
* Every export below is NOMINAL — no `export *`, no barrels, no compat shims.
|
|
@@ -87,7 +87,7 @@ export { runColdBoot } from './utils/coldBoot';
|
|
|
87
87
|
export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
|
|
88
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';
|
|
89
89
|
export type { PkcePair, BuildOAuthAuthorizeUrlParams } from './utils/oauthPkce';
|
|
90
|
-
export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from './utils/officialOrigins';
|
|
90
|
+
export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isLoopbackOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from './utils/officialOrigins';
|
|
91
91
|
export { syncHubAfterSignIn, redeemHubTicketOnHub, } from './session/hubSync';
|
|
92
92
|
export type { SyncHubAfterSignInOptions } from './session/hubSync';
|
|
93
93
|
export { SessionClient } from './session/SessionClient';
|
|
@@ -450,50 +450,6 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
450
450
|
available: boolean;
|
|
451
451
|
message: string;
|
|
452
452
|
}>;
|
|
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>;
|
|
461
|
-
/**
|
|
462
|
-
* Convenience helper for email sign-in
|
|
463
|
-
*/
|
|
464
|
-
signInWithEmail(email: string, password: string, deviceName?: string, deviceFingerprint?: any): Promise<SessionLoginResponse>;
|
|
465
|
-
/**
|
|
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.
|
|
477
|
-
*/
|
|
478
|
-
passwordSignIn(identifier: string, password: string, options?: {
|
|
479
|
-
deviceName?: string;
|
|
480
|
-
deviceFingerprint?: string;
|
|
481
|
-
deviceId?: string;
|
|
482
|
-
}): 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
453
|
/**
|
|
498
454
|
* Begin a WebAuthn / passkey REGISTRATION ceremony. Requests the
|
|
499
455
|
* `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
|
|
@@ -543,9 +499,9 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
543
499
|
* Finish a WebAuthn / passkey AUTHENTICATION ceremony. Forwards the opaque
|
|
544
500
|
* browser `AuthenticationResponseJSON` (`response`) alongside the
|
|
545
501
|
* device-session envelope. Resolves to the SAME {@link LoginResult} contract
|
|
546
|
-
* as `POST /auth/verify`;
|
|
547
|
-
*
|
|
548
|
-
*
|
|
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.
|
|
549
505
|
*/
|
|
550
506
|
webauthnLoginVerify(response: unknown, envelope?: {
|
|
551
507
|
deviceName?: string;
|
|
@@ -554,7 +510,7 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
554
510
|
}): Promise<LoginResult>;
|
|
555
511
|
/**
|
|
556
512
|
* Exchange an OAuth authorization code (returned to the RP redirect URI
|
|
557
|
-
* after
|
|
513
|
+
* after sign-in at auth.oxy.so) for a device-first session.
|
|
558
514
|
* Public first-party clients use PKCE (`codeVerifier`); the access token is
|
|
559
515
|
* planted immediately on success.
|
|
560
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' | '
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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,
|
|
123
|
-
* the
|
|
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. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "
|
|
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.
|
|
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.
|
|
13
|
+
* const user = await oxyClient.getCurrentUser();
|
|
14
14
|
* ```
|
|
15
15
|
*
|
|
16
16
|
* Every export below is NOMINAL — no `export *`, no barrels, no compat shims.
|
|
@@ -558,6 +558,7 @@ export {
|
|
|
558
558
|
buildIdpHubOrigin,
|
|
559
559
|
buildHubSyncUrl,
|
|
560
560
|
isIdpHubOrigin,
|
|
561
|
+
isLoopbackOrigin,
|
|
561
562
|
isOfficialWebOrigin,
|
|
562
563
|
isAllowedDeviceJoinOrigin,
|
|
563
564
|
normalizeOfficialReturnOrigin,
|
|
@@ -610,9 +611,10 @@ export type {
|
|
|
610
611
|
|
|
611
612
|
// Headless controller for the unified account dialog. Framework-agnostic
|
|
612
613
|
// state machine + subscribe/getSnapshot store (bind via `useSyncExternalStore`)
|
|
613
|
-
// —
|
|
614
|
-
//
|
|
615
|
-
//
|
|
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.
|
|
616
618
|
export {
|
|
617
619
|
AccountDialogController,
|
|
618
620
|
createAccountDialogController,
|
|
@@ -1058,144 +1058,6 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1058
1058
|
}
|
|
1059
1059
|
}
|
|
1060
1060
|
|
|
1061
|
-
/**
|
|
1062
|
-
* Register a new user with email/username and password
|
|
1063
|
-
*/
|
|
1064
|
-
async signUp(
|
|
1065
|
-
username: string,
|
|
1066
|
-
email: string,
|
|
1067
|
-
password: string,
|
|
1068
|
-
deviceName?: string,
|
|
1069
|
-
deviceFingerprint?: any
|
|
1070
|
-
): Promise<SessionLoginResponse> {
|
|
1071
|
-
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
|
-
};
|
|
1083
|
-
} catch (error) {
|
|
1084
|
-
throw this.handleError(error);
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
/**
|
|
1089
|
-
* Sign in with email or username and password
|
|
1090
|
-
*/
|
|
1091
|
-
async signIn(
|
|
1092
|
-
identifier: string,
|
|
1093
|
-
password: string,
|
|
1094
|
-
deviceName?: string,
|
|
1095
|
-
deviceFingerprint?: any
|
|
1096
|
-
): Promise<SessionLoginResponse> {
|
|
1097
|
-
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
|
-
} catch (error) {
|
|
1109
|
-
throw this.handleError(error);
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
/**
|
|
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.
|
|
1132
|
-
*
|
|
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.
|
|
1137
|
-
*/
|
|
1138
|
-
async passwordSignIn(
|
|
1139
|
-
identifier: string,
|
|
1140
|
-
password: string,
|
|
1141
|
-
options: { deviceName?: string; deviceFingerprint?: string; deviceId?: string } = {},
|
|
1142
|
-
): Promise<LoginResult> {
|
|
1143
|
-
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;
|
|
1159
|
-
} catch (error) {
|
|
1160
|
-
throw this.handleError(error);
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
/**
|
|
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.
|
|
1170
|
-
*/
|
|
1171
|
-
async completeTwoFactorSignIn(params: {
|
|
1172
|
-
loginToken: string;
|
|
1173
|
-
token?: string;
|
|
1174
|
-
backupCode?: string;
|
|
1175
|
-
deviceName?: string;
|
|
1176
|
-
deviceId?: string;
|
|
1177
|
-
}): Promise<LoginSessionResult> {
|
|
1178
|
-
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 });
|
|
1186
|
-
const parsed = safeParseContract(loginResultSchema, res);
|
|
1187
|
-
if (!parsed || 'twoFactorRequired' in parsed) {
|
|
1188
|
-
throw new Error('security/2fa/verify-login returned an unexpected response shape');
|
|
1189
|
-
}
|
|
1190
|
-
if (parsed.accessToken) {
|
|
1191
|
-
this.setTokens(parsed.accessToken);
|
|
1192
|
-
}
|
|
1193
|
-
return parsed;
|
|
1194
|
-
} catch (error) {
|
|
1195
|
-
throw this.handleError(error);
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
1061
|
/**
|
|
1200
1062
|
* Begin a WebAuthn / passkey REGISTRATION ceremony. Requests the
|
|
1201
1063
|
* `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
|
|
@@ -1258,7 +1120,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1258
1120
|
if (!parsed) {
|
|
1259
1121
|
throw new Error('auth/webauthn/register/verify returned an unexpected response shape');
|
|
1260
1122
|
}
|
|
1261
|
-
if (
|
|
1123
|
+
if (parsed.accessToken) {
|
|
1262
1124
|
this.setTokens(parsed.accessToken);
|
|
1263
1125
|
}
|
|
1264
1126
|
return parsed;
|
|
@@ -1301,9 +1163,9 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1301
1163
|
* Finish a WebAuthn / passkey AUTHENTICATION ceremony. Forwards the opaque
|
|
1302
1164
|
* browser `AuthenticationResponseJSON` (`response`) alongside the
|
|
1303
1165
|
* device-session envelope. Resolves to the SAME {@link LoginResult} contract
|
|
1304
|
-
* as `POST /auth/verify`;
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
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.
|
|
1307
1169
|
*/
|
|
1308
1170
|
async webauthnLoginVerify(
|
|
1309
1171
|
response: unknown,
|
|
@@ -1320,7 +1182,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1320
1182
|
if (!parsed) {
|
|
1321
1183
|
throw new Error('auth/webauthn/login/verify returned an unexpected response shape');
|
|
1322
1184
|
}
|
|
1323
|
-
if (
|
|
1185
|
+
if (parsed.accessToken) {
|
|
1324
1186
|
this.setTokens(parsed.accessToken);
|
|
1325
1187
|
}
|
|
1326
1188
|
return parsed;
|
|
@@ -1331,7 +1193,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1331
1193
|
|
|
1332
1194
|
/**
|
|
1333
1195
|
* Exchange an OAuth authorization code (returned to the RP redirect URI
|
|
1334
|
-
* after
|
|
1196
|
+
* after sign-in at auth.oxy.so) for a device-first session.
|
|
1335
1197
|
* Public first-party clients use PKCE (`codeVerifier`); the access token is
|
|
1336
1198
|
* planted immediately on success.
|
|
1337
1199
|
*/
|
|
@@ -53,7 +53,7 @@ const OXY_IDENTITY_APEX = 'oxy.so';
|
|
|
53
53
|
export type IdentityRecordType = OxySignedRecordType;
|
|
54
54
|
|
|
55
55
|
/** Auth-method types that can be unlinked via {@link OxyServicesIdentityMixin}. */
|
|
56
|
-
export type UnlinkableAuthMethodType = 'identity' | '
|
|
56
|
+
export type UnlinkableAuthMethodType = 'identity' | 'webauthn';
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Result of a link/unlink auth-method mutation (`POST /auth/link`,
|
|
@@ -207,18 +207,18 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
210
|
+
* Unlink an authentication method from the current account. The server
|
|
211
|
+
* refuses to remove the last remaining method (the account would become
|
|
212
|
+
* inaccessible). Unlinking `identity` downgrades the account to custodial.
|
|
212
213
|
*
|
|
213
|
-
* @param
|
|
214
|
-
* @param password - The new password (server enforces strength rules).
|
|
214
|
+
* @param type - The auth-method type to remove.
|
|
215
215
|
*/
|
|
216
|
-
async
|
|
216
|
+
async unlinkAuthMethod(type: UnlinkableAuthMethodType): Promise<LinkAuthMethodResult> {
|
|
217
217
|
try {
|
|
218
218
|
const result = await this.makeRequest<LinkAuthMethodResult>(
|
|
219
|
-
'
|
|
220
|
-
|
|
221
|
-
|
|
219
|
+
'DELETE',
|
|
220
|
+
`/auth/link/${encodeURIComponent(type)}`,
|
|
221
|
+
undefined,
|
|
222
222
|
{ cache: false },
|
|
223
223
|
);
|
|
224
224
|
this._invalidateIdentityCaches(this.getCurrentUserId());
|
|
@@ -229,17 +229,21 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
/**
|
|
232
|
-
*
|
|
233
|
-
* refuses to remove the last remaining method (the account would become
|
|
234
|
-
* inaccessible). Unlinking `identity` downgrades the account to custodial.
|
|
232
|
+
* Remove ONE passkey (WebAuthn credential) from the current account.
|
|
235
233
|
*
|
|
236
|
-
*
|
|
234
|
+
* Passkeys are per-credential, so unlike {@link unlinkAuthMethod} (which
|
|
235
|
+
* removes an auth method by type) this targets a specific credential id.
|
|
236
|
+
* The server refuses to remove the last remaining auth method (the account
|
|
237
|
+
* would become inaccessible) and deletes the stored `WebauthnCredential`.
|
|
238
|
+
*
|
|
239
|
+
* @param credentialId - The passkey's public credential id
|
|
240
|
+
* (`AuthMethodEntry.credentialId`).
|
|
237
241
|
*/
|
|
238
|
-
async
|
|
242
|
+
async removePasskey(credentialId: string): Promise<LinkAuthMethodResult> {
|
|
239
243
|
try {
|
|
240
244
|
const result = await this.makeRequest<LinkAuthMethodResult>(
|
|
241
245
|
'DELETE',
|
|
242
|
-
`/auth/link/${encodeURIComponent(
|
|
246
|
+
`/auth/link/webauthn/${encodeURIComponent(credentialId)}`,
|
|
243
247
|
undefined,
|
|
244
248
|
{ cache: false },
|
|
245
249
|
);
|
|
@@ -186,37 +186,49 @@ describe('OxyServices.identity', () => {
|
|
|
186
186
|
});
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
describe('
|
|
190
|
-
it('
|
|
191
|
-
makeRequestSpy.mockResolvedValue({ success: true, message: '
|
|
189
|
+
describe('unlinkAuthMethod', () => {
|
|
190
|
+
it('DELETEs /auth/link/:type and sweeps cache', async () => {
|
|
191
|
+
makeRequestSpy.mockResolvedValue({ success: true, message: 'identity auth unlinked successfully' });
|
|
192
192
|
|
|
193
|
-
await oxy.
|
|
193
|
+
await oxy.unlinkAuthMethod('identity');
|
|
194
194
|
|
|
195
195
|
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
196
|
-
'
|
|
197
|
-
'/auth/link',
|
|
198
|
-
|
|
196
|
+
'DELETE',
|
|
197
|
+
'/auth/link/identity',
|
|
198
|
+
undefined,
|
|
199
199
|
expect.objectContaining({ cache: false }),
|
|
200
200
|
);
|
|
201
|
-
expect(
|
|
202
|
-
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/auth/methods');
|
|
201
|
+
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
|
|
203
202
|
});
|
|
204
203
|
});
|
|
205
204
|
|
|
206
|
-
describe('
|
|
207
|
-
it('DELETEs /auth/link/:
|
|
208
|
-
makeRequestSpy.mockResolvedValue({ success: true, message: '
|
|
205
|
+
describe('removePasskey', () => {
|
|
206
|
+
it('DELETEs /auth/link/webauthn/:credentialID and sweeps cache', async () => {
|
|
207
|
+
makeRequestSpy.mockResolvedValue({ success: true, message: 'Passkey unlinked successfully' });
|
|
209
208
|
|
|
210
|
-
await oxy.
|
|
209
|
+
await oxy.removePasskey('cred-abc');
|
|
211
210
|
|
|
212
211
|
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
213
212
|
'DELETE',
|
|
214
|
-
'/auth/link/
|
|
213
|
+
'/auth/link/webauthn/cred-abc',
|
|
215
214
|
undefined,
|
|
216
215
|
expect.objectContaining({ cache: false }),
|
|
217
216
|
);
|
|
218
217
|
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
|
|
219
218
|
});
|
|
219
|
+
|
|
220
|
+
it('URL-encodes the credential id', async () => {
|
|
221
|
+
makeRequestSpy.mockResolvedValue({ success: true, message: 'Passkey unlinked successfully' });
|
|
222
|
+
|
|
223
|
+
await oxy.removePasskey('a/b+c=');
|
|
224
|
+
|
|
225
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
226
|
+
'DELETE',
|
|
227
|
+
'/auth/link/webauthn/a%2Fb%2Bc%3D',
|
|
228
|
+
undefined,
|
|
229
|
+
expect.objectContaining({ cache: false }),
|
|
230
|
+
);
|
|
231
|
+
});
|
|
220
232
|
});
|
|
221
233
|
|
|
222
234
|
describe('signRecord (client-only)', () => {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* (`webauthnRegisterOptions` / `webauthnRegisterVerify` /
|
|
4
4
|
* `webauthnLoginOptions` / `webauthnLoginVerify`).
|
|
5
5
|
*
|
|
6
|
-
* Stubs `makeRequest` (HTTP-mock style
|
|
6
|
+
* Stubs `makeRequest` (HTTP-mock style) and
|
|
7
7
|
* asserts: each method hits the right endpoint with the right body; the opaque
|
|
8
8
|
* ceremony `response` is forwarded verbatim; the login/verify + register/verify
|
|
9
9
|
* (signup) paths parse the login contract and plant the access token on the
|