@sparkvault/sdk-mobile 0.1.3
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 +186 -0
- package/dist/auth.d.ts +74 -0
- package/dist/auth.js +515 -0
- package/dist/auth.js.map +1 -0
- package/dist/billing.d.ts +21 -0
- package/dist/billing.js +10 -0
- package/dist/billing.js.map +1 -0
- package/dist/client.d.ts +27 -0
- package/dist/client.js +36 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.js +69 -0
- package/dist/config.js.map +1 -0
- package/dist/encoding.d.ts +7 -0
- package/dist/encoding.js +113 -0
- package/dist/encoding.js.map +1 -0
- package/dist/entropy.d.ts +18 -0
- package/dist/entropy.js +35 -0
- package/dist/entropy.js.map +1 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.js +72 -0
- package/dist/errors.js.map +1 -0
- package/dist/folders.d.ts +15 -0
- package/dist/folders.js +54 -0
- package/dist/folders.js.map +1 -0
- package/dist/health.d.ts +19 -0
- package/dist/health.js +60 -0
- package/dist/health.js.map +1 -0
- package/dist/http.d.ts +45 -0
- package/dist/http.js +351 -0
- package/dist/http.js.map +1 -0
- package/dist/identity-dialog.d.ts +19 -0
- package/dist/identity-dialog.js +656 -0
- package/dist/identity-dialog.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/ingots.d.ts +99 -0
- package/dist/ingots.js +365 -0
- package/dist/ingots.js.map +1 -0
- package/dist/mutex.d.ts +6 -0
- package/dist/mutex.js +20 -0
- package/dist/mutex.js.map +1 -0
- package/dist/push-tokens.d.ts +12 -0
- package/dist/push-tokens.js +15 -0
- package/dist/push-tokens.js.map +1 -0
- package/dist/sparks.d.ts +39 -0
- package/dist/sparks.js +32 -0
- package/dist/sparks.js.map +1 -0
- package/dist/tus.d.ts +24 -0
- package/dist/tus.js +202 -0
- package/dist/tus.js.map +1 -0
- package/dist/types.d.ts +406 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +5 -0
- package/dist/validation.js +45 -0
- package/dist/validation.js.map +1 -0
- package/dist/vaults.d.ts +94 -0
- package/dist/vaults.js +106 -0
- package/dist/vaults.js.map +1 -0
- package/package.json +58 -0
- package/src/auth.ts +707 -0
- package/src/billing.ts +30 -0
- package/src/client.ts +51 -0
- package/src/config.ts +123 -0
- package/src/encoding.ts +150 -0
- package/src/entropy.ts +56 -0
- package/src/errors.ts +110 -0
- package/src/folders.ts +76 -0
- package/src/health.ts +81 -0
- package/src/http.ts +429 -0
- package/src/identity-dialog.tsx +955 -0
- package/src/index.ts +103 -0
- package/src/ingots.ts +593 -0
- package/src/mutex.ts +26 -0
- package/src/push-tokens.ts +26 -0
- package/src/sparks.ts +73 -0
- package/src/tus.ts +280 -0
- package/src/types.ts +487 -0
- package/src/validation.ts +49 -0
- package/src/vaults.ts +271 -0
package/src/auth.ts
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import { identityUrl, type ResolvedMobileConfig } from './config.js';
|
|
2
|
+
import { base64UrlToBytes, base64UrlToUtf8, utf8ToBytes } from './encoding.js';
|
|
3
|
+
import { SparkVaultAuthenticationError, SparkVaultMobileError, SparkVaultValidationError } from './errors.js';
|
|
4
|
+
import type { MobileHttpClient } from './http.js';
|
|
5
|
+
import type {
|
|
6
|
+
ApiErrorBody,
|
|
7
|
+
CompleteSignupRequest,
|
|
8
|
+
CompleteSignupResponse,
|
|
9
|
+
IdentityConfig,
|
|
10
|
+
IdentityJwks,
|
|
11
|
+
IdentityTokenClaims,
|
|
12
|
+
IdentityType,
|
|
13
|
+
IdentityVerifyResult,
|
|
14
|
+
IdentitySessionResponse,
|
|
15
|
+
PasskeyAuthChallenge,
|
|
16
|
+
PasskeyCredential,
|
|
17
|
+
PasskeyRegisterChallenge,
|
|
18
|
+
PasskeySession,
|
|
19
|
+
PasskeyStatus,
|
|
20
|
+
PinVerifyRequest,
|
|
21
|
+
PinVerifyResponse,
|
|
22
|
+
RequestPinRequest,
|
|
23
|
+
RequestPinResponse,
|
|
24
|
+
SendTotpRequest,
|
|
25
|
+
SparkVaultAccount,
|
|
26
|
+
SparkVaultUser,
|
|
27
|
+
VerifyTotpRequest,
|
|
28
|
+
} from './types.js';
|
|
29
|
+
import nacl from 'tweetnacl';
|
|
30
|
+
|
|
31
|
+
interface IdentityVerificationResponse {
|
|
32
|
+
verified: boolean;
|
|
33
|
+
token?: string;
|
|
34
|
+
identity?: string;
|
|
35
|
+
identity_type?: IdentityType;
|
|
36
|
+
method?: string;
|
|
37
|
+
redirect?: string;
|
|
38
|
+
kindling?: string;
|
|
39
|
+
expires_at?: number;
|
|
40
|
+
retry_after?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface IdentityPasskeyCompletionResponse {
|
|
44
|
+
verified?: boolean;
|
|
45
|
+
token: string;
|
|
46
|
+
identity: string;
|
|
47
|
+
identity_type: IdentityType;
|
|
48
|
+
method: string;
|
|
49
|
+
redirect?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface JwtHeader {
|
|
53
|
+
alg: string;
|
|
54
|
+
kid?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const JWKS_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
58
|
+
|
|
59
|
+
export class MobileAuthClient {
|
|
60
|
+
private readonly config: ResolvedMobileConfig;
|
|
61
|
+
private readonly http: MobileHttpClient;
|
|
62
|
+
private configCache: Promise<IdentityConfig> | null = null;
|
|
63
|
+
private jwksCache: Promise<IdentityJwks> | null = null;
|
|
64
|
+
private jwksCachedAt = 0;
|
|
65
|
+
|
|
66
|
+
constructor(config: ResolvedMobileConfig, http: MobileHttpClient) {
|
|
67
|
+
this.config = config;
|
|
68
|
+
this.http = http;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getJwksUri(): string {
|
|
72
|
+
return identityUrl(this.config, '/.well-known/jwks.json');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async getConfig(): Promise<IdentityConfig> {
|
|
76
|
+
const configRequest = this.configCache ?? this.startConfigLoad();
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
return await configRequest;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
this.configCache = null;
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
preloadConfig(): void {
|
|
87
|
+
if (!this.configCache) {
|
|
88
|
+
this.startConfigLoad().catch(() => undefined);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
isConfigPreloaded(): boolean {
|
|
93
|
+
return this.configCache !== null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getJwks(options: { forceRefresh?: boolean } = {}): Promise<IdentityJwks> {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const expired = now - this.jwksCachedAt > JWKS_CACHE_TTL_MS;
|
|
99
|
+
if (!this.jwksCache || expired || options.forceRefresh) {
|
|
100
|
+
this.jwksCache = this.getIdentity<IdentityJwks>('/.well-known/jwks.json');
|
|
101
|
+
this.jwksCachedAt = now;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
return await this.jwksCache;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
this.jwksCache = null;
|
|
108
|
+
this.jwksCachedAt = 0;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async verifyIdentityToken(
|
|
114
|
+
token: string,
|
|
115
|
+
options: {
|
|
116
|
+
jwks?: IdentityJwks;
|
|
117
|
+
audience?: string;
|
|
118
|
+
clockSkewSeconds?: number;
|
|
119
|
+
} = {}
|
|
120
|
+
): Promise<IdentityTokenClaims> {
|
|
121
|
+
if (!token || typeof token !== 'string') {
|
|
122
|
+
throw new SparkVaultValidationError('Identity token is required', { field: 'token' });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const parts = token.split('.');
|
|
126
|
+
if (parts.length !== 3) {
|
|
127
|
+
throw new SparkVaultValidationError('Invalid identity token format');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const [headerPart, payloadPart, signaturePart] = parts;
|
|
131
|
+
let header: JwtHeader;
|
|
132
|
+
let claims: IdentityTokenClaims;
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
header = JSON.parse(base64UrlToUtf8(headerPart)) as JwtHeader;
|
|
136
|
+
const parsedClaims = JSON.parse(base64UrlToUtf8(payloadPart)) as unknown;
|
|
137
|
+
if (!this.isIdentityTokenClaims(parsedClaims)) {
|
|
138
|
+
throw new Error('Invalid claims');
|
|
139
|
+
}
|
|
140
|
+
claims = parsedClaims;
|
|
141
|
+
} catch {
|
|
142
|
+
throw new SparkVaultValidationError('Invalid identity token payload');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (header.alg !== 'EdDSA') {
|
|
146
|
+
throw new SparkVaultValidationError('Unsupported identity token algorithm', { alg: header.alg });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Verify the signature BEFORE any claim is acted upon. JWKS is refreshed
|
|
150
|
+
// once on a kid miss so Identity App key rotation propagates without a
|
|
151
|
+
// process restart.
|
|
152
|
+
const jwk = await this.resolveJwk(header.kid, options.jwks);
|
|
153
|
+
const signatureValid = nacl.sign.detached.verify(
|
|
154
|
+
utf8ToBytes(`${headerPart}.${payloadPart}`),
|
|
155
|
+
base64UrlToBytes(signaturePart),
|
|
156
|
+
base64UrlToBytes(jwk.x)
|
|
157
|
+
);
|
|
158
|
+
if (!signatureValid) {
|
|
159
|
+
throw new SparkVaultAuthenticationError('Identity token signature is invalid');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const now = Math.floor(Date.now() / 1000);
|
|
163
|
+
const clockSkewSeconds = options.clockSkewSeconds ?? 60;
|
|
164
|
+
if (claims.exp + clockSkewSeconds < now) {
|
|
165
|
+
throw new SparkVaultValidationError('Identity token has expired');
|
|
166
|
+
}
|
|
167
|
+
if (claims.iat - clockSkewSeconds > now) {
|
|
168
|
+
throw new SparkVaultValidationError('Identity token was issued in the future');
|
|
169
|
+
}
|
|
170
|
+
if (claims.nbf !== undefined && claims.nbf - clockSkewSeconds > now) {
|
|
171
|
+
throw new SparkVaultValidationError('Identity token is not valid yet');
|
|
172
|
+
}
|
|
173
|
+
if (typeof claims.nbf === 'number' && claims.nbf > now + clockSkewSeconds) {
|
|
174
|
+
throw new SparkVaultValidationError('Identity token is not yet valid');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const expectedIssuer = `${this.config.identityBaseUrl}/${this.config.identityAccountId}`;
|
|
178
|
+
if (claims.iss !== expectedIssuer) {
|
|
179
|
+
throw new SparkVaultValidationError('Invalid identity token issuer', {
|
|
180
|
+
expected: expectedIssuer,
|
|
181
|
+
received: claims.iss,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const expectedAudience = options.audience ?? this.config.identityAccountId;
|
|
186
|
+
if (claims.aud !== expectedAudience) {
|
|
187
|
+
throw new SparkVaultValidationError('Invalid identity token audience', {
|
|
188
|
+
expected: expectedAudience,
|
|
189
|
+
received: claims.aud,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return claims;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private async resolveJwk(kid: string | undefined, providedJwks?: IdentityJwks): Promise<{ x: string }> {
|
|
197
|
+
if (providedJwks) {
|
|
198
|
+
return this.selectJwk(providedJwks, kid);
|
|
199
|
+
}
|
|
200
|
+
const jwks = await this.getJwks();
|
|
201
|
+
const match = this.tryFindJwk(jwks, kid);
|
|
202
|
+
if (match) {
|
|
203
|
+
return match;
|
|
204
|
+
}
|
|
205
|
+
// Cache miss for the requested kid: Identity App may have rotated keys.
|
|
206
|
+
// Force-refresh the JWKS exactly once and retry before failing.
|
|
207
|
+
const refreshed = await this.getJwks({ forceRefresh: true });
|
|
208
|
+
return this.selectJwk(refreshed, kid);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private tryFindJwk(jwks: IdentityJwks, kid?: string): { x: string } | null {
|
|
212
|
+
const key = kid
|
|
213
|
+
? jwks.keys.find(candidate => candidate.kid === kid)
|
|
214
|
+
: jwks.keys.find(candidate => candidate.kty === 'OKP' && candidate.crv === 'Ed25519');
|
|
215
|
+
if (!key) return null;
|
|
216
|
+
if (key.kty !== 'OKP' || key.crv !== 'Ed25519' || typeof key.x !== 'string') {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
return key;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async sendTotp(request: SendTotpRequest): Promise<RequestPinResponse> {
|
|
223
|
+
const identity = this.normalizeIdentity(request.identity, request.identityType);
|
|
224
|
+
const result = await this.postIdentity<{
|
|
225
|
+
kindling: string;
|
|
226
|
+
expires_at?: number;
|
|
227
|
+
retry_after?: number;
|
|
228
|
+
}>('/totp/send', {
|
|
229
|
+
recipient: identity,
|
|
230
|
+
method: request.method,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
kindling: result.kindling,
|
|
235
|
+
expires_at: result.expires_at ?? Math.floor(Date.now() / 1000) + 900,
|
|
236
|
+
retry_after: result.retry_after,
|
|
237
|
+
is_signup: false,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async requestPin(request: RequestPinRequest): Promise<RequestPinResponse> {
|
|
242
|
+
return this.sendTotp({
|
|
243
|
+
identity: request.email,
|
|
244
|
+
identityType: 'email',
|
|
245
|
+
method: 'email',
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async verifyTotp(request: VerifyTotpRequest): Promise<IdentityVerifyResult> {
|
|
250
|
+
const verifyResult = await this.verifyTotpWithRetryErrors(request);
|
|
251
|
+
if (!verifyResult.verified || !verifyResult.token) {
|
|
252
|
+
if (verifyResult.kindling) {
|
|
253
|
+
const error = new SparkVaultMobileError('Invalid code. Please try again.', {
|
|
254
|
+
code: 400,
|
|
255
|
+
statusCode: 400,
|
|
256
|
+
});
|
|
257
|
+
(error as SparkVaultMobileError & { data?: unknown }).data = {
|
|
258
|
+
kindling: verifyResult.kindling,
|
|
259
|
+
expires_at: verifyResult.expires_at,
|
|
260
|
+
};
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
throw new SparkVaultMobileError('Verification failed. Please request a new code.', {
|
|
265
|
+
code: 400,
|
|
266
|
+
statusCode: 400,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return this.toIdentityVerifyResult(verifyResult.token, {
|
|
271
|
+
identity: verifyResult.identity ?? request.recipient,
|
|
272
|
+
identityType: verifyResult.identity_type,
|
|
273
|
+
method: verifyResult.method,
|
|
274
|
+
redirect: verifyResult.redirect,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async verifyPin(request: PinVerifyRequest): Promise<PinVerifyResponse> {
|
|
279
|
+
const verifyResult = await this.verifyTotpWithRetryErrors(request);
|
|
280
|
+
|
|
281
|
+
if (!verifyResult.verified || !verifyResult.token) {
|
|
282
|
+
if (verifyResult.kindling) {
|
|
283
|
+
const error = new SparkVaultMobileError('Invalid code. Please try again.', {
|
|
284
|
+
code: 400,
|
|
285
|
+
statusCode: 400,
|
|
286
|
+
});
|
|
287
|
+
(error as SparkVaultMobileError & { data?: unknown }).data = {
|
|
288
|
+
kindling: verifyResult.kindling,
|
|
289
|
+
expires_at: verifyResult.expires_at,
|
|
290
|
+
};
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
throw new SparkVaultMobileError('Verification failed. Please request a new code.', {
|
|
295
|
+
code: 400,
|
|
296
|
+
statusCode: 400,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return this.exchangeIdentityToken(verifyResult.token);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async completeSignup(
|
|
304
|
+
signupToken: string,
|
|
305
|
+
request: CompleteSignupRequest
|
|
306
|
+
): Promise<CompleteSignupResponse> {
|
|
307
|
+
const response = await this.http.post<CompleteSignupResponse>(
|
|
308
|
+
'/auth/complete-signup',
|
|
309
|
+
request,
|
|
310
|
+
{
|
|
311
|
+
skipAuth: true,
|
|
312
|
+
headers: { Authorization: `Bearer ${signupToken}` },
|
|
313
|
+
}
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
await this.storeSession(response.data);
|
|
317
|
+
return response.data;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async signup(params: {
|
|
321
|
+
signup_token: string;
|
|
322
|
+
organization_name: string;
|
|
323
|
+
full_name?: string;
|
|
324
|
+
}): Promise<CompleteSignupResponse> {
|
|
325
|
+
return this.completeSignup(params.signup_token, {
|
|
326
|
+
organization_name: params.organization_name,
|
|
327
|
+
full_name: params.full_name,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async checkPasskeyStatus(identity: string, identityType?: IdentityType): Promise<{ hasPasskey: boolean }> {
|
|
332
|
+
try {
|
|
333
|
+
const result = await this.postIdentity<{ has_passkey: boolean }>(
|
|
334
|
+
'/passkey/check',
|
|
335
|
+
this.identityLookupBody(identity, identityType)
|
|
336
|
+
);
|
|
337
|
+
return { hasPasskey: Boolean(result.has_passkey) };
|
|
338
|
+
} catch {
|
|
339
|
+
return { hasPasskey: false };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async getPasskeyAuthOptions(identity: string, identityType?: IdentityType): Promise<PasskeyAuthChallenge> {
|
|
344
|
+
return this.postIdentity<PasskeyAuthChallenge>('/passkey/verify', this.identityLookupBody(identity, identityType));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async completePasskeyAuthentication(
|
|
348
|
+
credential: PasskeyCredential,
|
|
349
|
+
session: PasskeySession
|
|
350
|
+
): Promise<IdentityVerifyResult> {
|
|
351
|
+
const result = await this.postIdentity<IdentityPasskeyCompletionResponse>(
|
|
352
|
+
'/passkey/verify/complete',
|
|
353
|
+
{ session, credential }
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
if (result.verified === false || !result.token) {
|
|
357
|
+
throw new SparkVaultAuthenticationError('Passkey verification failed');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return this.toIdentityVerifyResult(result.token, {
|
|
361
|
+
identity: result.identity,
|
|
362
|
+
identityType: result.identity_type,
|
|
363
|
+
method: result.method,
|
|
364
|
+
redirect: result.redirect,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async verifyPasskeyAuth(
|
|
369
|
+
credential: PasskeyCredential,
|
|
370
|
+
session: PasskeySession
|
|
371
|
+
): Promise<PinVerifyResponse> {
|
|
372
|
+
const result = await this.completePasskeyAuthentication(credential, session);
|
|
373
|
+
return this.exchangeIdentityToken(result.token);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async getPasskeyRegisterOptions(params: { email?: string; deviceName?: string } = {}): Promise<PasskeyRegisterChallenge> {
|
|
377
|
+
const email = params.email ?? await this.getStoredUserEmail();
|
|
378
|
+
if (!email) {
|
|
379
|
+
throw new SparkVaultAuthenticationError('You must be signed in to register a passkey');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return this.postIdentity<PasskeyRegisterChallenge>('/passkey/register', {
|
|
383
|
+
email: email.trim().toLowerCase(),
|
|
384
|
+
device_name: params.deviceName ?? this.config.userAgent,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async registerPasskey(
|
|
389
|
+
credential: PasskeyCredential,
|
|
390
|
+
session: PasskeySession
|
|
391
|
+
): Promise<{ success: boolean; credential_id: string }> {
|
|
392
|
+
const result = await this.postIdentity<IdentityPasskeyCompletionResponse>(
|
|
393
|
+
'/passkey/register/complete',
|
|
394
|
+
{ session, credential }
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const success = result.verified !== false;
|
|
398
|
+
|
|
399
|
+
if (success && this.config.tokenStorage.getUser && this.config.tokenStorage.setUser) {
|
|
400
|
+
const user = await this.config.tokenStorage.getUser<SparkVaultUser>();
|
|
401
|
+
if (user) {
|
|
402
|
+
await this.config.tokenStorage.setUser<SparkVaultUser>({ ...user, has_passkey: true });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return { success, credential_id: credential.id };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async getMyPasskeyStatus(): Promise<PasskeyStatus> {
|
|
410
|
+
const email = await this.getStoredUserEmail();
|
|
411
|
+
if (!email) {
|
|
412
|
+
return { has_passkey: false };
|
|
413
|
+
}
|
|
414
|
+
const { hasPasskey } = await this.checkPasskeyStatus(email, 'email');
|
|
415
|
+
return { has_passkey: hasPasskey };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async getProfile(): Promise<SparkVaultUser> {
|
|
419
|
+
const response = await this.http.get<SparkVaultUser>('/profile');
|
|
420
|
+
if (this.config.tokenStorage.setUser) {
|
|
421
|
+
await this.config.tokenStorage.setUser(response.data);
|
|
422
|
+
}
|
|
423
|
+
return response.data;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async updateProfile(updates: { name?: string }): Promise<SparkVaultUser> {
|
|
427
|
+
const response = await this.http.put<SparkVaultUser>('/profile', updates);
|
|
428
|
+
if (this.config.tokenStorage.setUser) {
|
|
429
|
+
await this.config.tokenStorage.setUser(response.data);
|
|
430
|
+
}
|
|
431
|
+
return response.data;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async getAccount(): Promise<SparkVaultAccount> {
|
|
435
|
+
const response = await this.http.get<SparkVaultAccount>('/account');
|
|
436
|
+
if (this.config.tokenStorage.setAccount) {
|
|
437
|
+
await this.config.tokenStorage.setAccount(response.data);
|
|
438
|
+
}
|
|
439
|
+
return response.data;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async revokeRefreshToken(refreshToken?: string): Promise<void> {
|
|
443
|
+
const token = refreshToken ?? await this.config.tokenStorage.getRefreshToken();
|
|
444
|
+
if (!token) return;
|
|
445
|
+
|
|
446
|
+
await this.http.post('/auth/logout', { refresh_token: token }).catch(err => {
|
|
447
|
+
this.config.logger.warn('Logout API call failed', { error: String(err) });
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async logout(options: { revoke?: boolean; clearTokens?: boolean } = {}): Promise<void> {
|
|
452
|
+
if (options.revoke !== false) {
|
|
453
|
+
await this.revokeRefreshToken();
|
|
454
|
+
}
|
|
455
|
+
if (options.clearTokens !== false) {
|
|
456
|
+
await this.config.tokenStorage.clearTokens();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async exchangeIdentityToken(identityToken: string): Promise<PinVerifyResponse> {
|
|
461
|
+
const sessionResponse = await this.http.post<IdentitySessionResponse>(
|
|
462
|
+
'/auth/identity/verify',
|
|
463
|
+
{ token: identityToken },
|
|
464
|
+
{ skipAuth: true }
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
const session = sessionResponse.data;
|
|
468
|
+
if (session.user) {
|
|
469
|
+
await this.storeSession(session);
|
|
470
|
+
return {
|
|
471
|
+
access_token: session.access_token,
|
|
472
|
+
refresh_token: session.refresh_token,
|
|
473
|
+
token_type: session.token_type ?? 'Bearer',
|
|
474
|
+
expires_in: session.expires_in ?? 3600,
|
|
475
|
+
user: session.user,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
verified: true,
|
|
481
|
+
needs_signup_info: true,
|
|
482
|
+
signup_token: session.access_token,
|
|
483
|
+
expires_at: Math.floor(Date.now() / 1000) + (session.expires_in ?? 3600),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private async storeSession(session: {
|
|
488
|
+
access_token: string;
|
|
489
|
+
refresh_token: string;
|
|
490
|
+
user?: SparkVaultUser | null;
|
|
491
|
+
account?: SparkVaultAccount | null;
|
|
492
|
+
}): Promise<void> {
|
|
493
|
+
if (!session.access_token || !session.refresh_token) {
|
|
494
|
+
throw new SparkVaultValidationError('Invalid session response from SparkVault');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (this.config.tokenStorage.setTokens) {
|
|
498
|
+
await this.config.tokenStorage.setTokens(session.access_token, session.refresh_token);
|
|
499
|
+
} else {
|
|
500
|
+
await Promise.all([
|
|
501
|
+
this.config.tokenStorage.setAccessToken(session.access_token),
|
|
502
|
+
this.config.tokenStorage.setRefreshToken(session.refresh_token),
|
|
503
|
+
]);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const writes: Promise<void>[] = [];
|
|
507
|
+
if (session.user && this.config.tokenStorage.setUser) {
|
|
508
|
+
writes.push(this.config.tokenStorage.setUser(session.user));
|
|
509
|
+
}
|
|
510
|
+
if (session.account && this.config.tokenStorage.setAccount) {
|
|
511
|
+
writes.push(this.config.tokenStorage.setAccount(session.account));
|
|
512
|
+
}
|
|
513
|
+
await Promise.all(writes);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private async verifyTotpWithRetryErrors(request: VerifyTotpRequest): Promise<IdentityVerificationResponse> {
|
|
517
|
+
try {
|
|
518
|
+
return await this.postIdentity<IdentityVerificationResponse>('/totp/verify', {
|
|
519
|
+
kindling: request.kindling,
|
|
520
|
+
pin: request.pin,
|
|
521
|
+
recipient: request.recipient.trim().toLowerCase(),
|
|
522
|
+
});
|
|
523
|
+
} catch (err) {
|
|
524
|
+
const apiError = err as SparkVaultMobileError & {
|
|
525
|
+
data?: { kindling?: string; expires_at?: number };
|
|
526
|
+
};
|
|
527
|
+
const message = apiError.message || 'Verification failed';
|
|
528
|
+
const code = apiError.statusCode ?? apiError.code;
|
|
529
|
+
|
|
530
|
+
if (
|
|
531
|
+
code === 403 ||
|
|
532
|
+
code === 410 ||
|
|
533
|
+
message.toLowerCase().includes('burned') ||
|
|
534
|
+
message.toLowerCase().includes('expired')
|
|
535
|
+
) {
|
|
536
|
+
throw new SparkVaultMobileError('Verification session expired. Please request a new code.', {
|
|
537
|
+
code: code || 410,
|
|
538
|
+
statusCode: typeof code === 'number' ? code : 410,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
throw apiError;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private startConfigLoad(): Promise<IdentityConfig> {
|
|
547
|
+
const request = this.getIdentity<IdentityConfig>('/config').catch((error: unknown) => {
|
|
548
|
+
this.configCache = null;
|
|
549
|
+
throw error;
|
|
550
|
+
});
|
|
551
|
+
this.configCache = request;
|
|
552
|
+
return request;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private toIdentityVerifyResult(
|
|
556
|
+
token: string,
|
|
557
|
+
details: {
|
|
558
|
+
identity?: string;
|
|
559
|
+
identityType?: IdentityType;
|
|
560
|
+
method?: string;
|
|
561
|
+
redirect?: string;
|
|
562
|
+
}
|
|
563
|
+
): IdentityVerifyResult {
|
|
564
|
+
const claims = this.decodeIdentityTokenClaims(token);
|
|
565
|
+
return {
|
|
566
|
+
token,
|
|
567
|
+
identity: details.identity ?? claims.identity,
|
|
568
|
+
identityType: details.identityType ?? claims.identity_type,
|
|
569
|
+
method: details.method ?? claims.method,
|
|
570
|
+
redirect: details.redirect,
|
|
571
|
+
jwksUri: this.getJwksUri(),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private decodeIdentityTokenClaims(token: string): IdentityTokenClaims {
|
|
576
|
+
const parts = token.split('.');
|
|
577
|
+
if (parts.length !== 3) {
|
|
578
|
+
throw new SparkVaultValidationError('Invalid identity token format');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
try {
|
|
582
|
+
const payload = JSON.parse(base64UrlToUtf8(parts[1])) as unknown;
|
|
583
|
+
if (!this.isIdentityTokenClaims(payload)) {
|
|
584
|
+
throw new Error('Invalid claims');
|
|
585
|
+
}
|
|
586
|
+
return payload;
|
|
587
|
+
} catch {
|
|
588
|
+
throw new SparkVaultValidationError('Invalid identity token payload');
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private async getIdentity<T>(path: string): Promise<T> {
|
|
593
|
+
const response = await this.config.fetch(identityUrl(this.config, path), {
|
|
594
|
+
method: 'GET',
|
|
595
|
+
headers: this.identityHeaders(),
|
|
596
|
+
timeoutMs: this.config.timeoutMs,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
if (!response.ok) {
|
|
600
|
+
const errorBody = await response.json().catch(() => ({} as ApiErrorBody));
|
|
601
|
+
const message = errorBody.error?.message ?? errorBody.message ?? 'Identity request failed';
|
|
602
|
+
throw new SparkVaultMobileError(message, {
|
|
603
|
+
code: errorBody.error?.code ?? response.status,
|
|
604
|
+
statusCode: response.status,
|
|
605
|
+
details: errorBody.error?.details,
|
|
606
|
+
meta: errorBody.meta,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const data = await response.json();
|
|
611
|
+
return (data.data ?? data) as T;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private async postIdentity<T>(
|
|
615
|
+
path: string,
|
|
616
|
+
body: Record<string, unknown>,
|
|
617
|
+
options: { bearerToken?: string } = {}
|
|
618
|
+
): Promise<T> {
|
|
619
|
+
const headers = this.identityHeaders(options.bearerToken);
|
|
620
|
+
const response = await this.config.fetch(identityUrl(this.config, path), {
|
|
621
|
+
method: 'POST',
|
|
622
|
+
headers,
|
|
623
|
+
body: JSON.stringify(body),
|
|
624
|
+
timeoutMs: this.config.timeoutMs,
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
if (!response.ok) {
|
|
628
|
+
const errorBody = await response.json().catch(() => ({} as ApiErrorBody));
|
|
629
|
+
const message = errorBody.error?.message ?? errorBody.message ?? 'Identity verification failed';
|
|
630
|
+
const error = new SparkVaultMobileError(message, {
|
|
631
|
+
code: errorBody.error?.code ?? response.status,
|
|
632
|
+
statusCode: response.status,
|
|
633
|
+
details: errorBody.error?.details,
|
|
634
|
+
meta: errorBody.meta,
|
|
635
|
+
});
|
|
636
|
+
(error as SparkVaultMobileError & { data?: unknown }).data = errorBody.data ?? errorBody;
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const data = await response.json();
|
|
641
|
+
return (data.data ?? data) as T;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private identityHeaders(bearerToken?: string): Record<string, string> {
|
|
645
|
+
const headers: Record<string, string> = {
|
|
646
|
+
'Content-Type': 'application/json',
|
|
647
|
+
Accept: 'application/json',
|
|
648
|
+
'User-Agent': this.config.userAgent,
|
|
649
|
+
};
|
|
650
|
+
if (bearerToken) {
|
|
651
|
+
headers.Authorization = `Bearer ${bearerToken}`;
|
|
652
|
+
}
|
|
653
|
+
return headers;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private identityLookupBody(identity: string, identityType?: IdentityType): Record<string, string> {
|
|
657
|
+
const inferredType = identityType ?? (identity.includes('@') ? 'email' : 'phone');
|
|
658
|
+
const normalized = this.normalizeIdentity(identity, inferredType);
|
|
659
|
+
return inferredType === 'phone' ? { phone: normalized } : { email: normalized };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private normalizeIdentity(identity: string, identityType: IdentityType): string {
|
|
663
|
+
const trimmed = identity.trim();
|
|
664
|
+
if (!trimmed) {
|
|
665
|
+
throw new SparkVaultValidationError('Identity is required', { field: 'identity' });
|
|
666
|
+
}
|
|
667
|
+
return identityType === 'email' ? trimmed.toLowerCase() : trimmed;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private selectJwk(jwks: IdentityJwks, kid?: string): { x: string } {
|
|
671
|
+
const key = kid
|
|
672
|
+
? jwks.keys.find(candidate => candidate.kid === kid)
|
|
673
|
+
: jwks.keys.find(candidate => candidate.kty === 'OKP' && candidate.crv === 'Ed25519');
|
|
674
|
+
|
|
675
|
+
if (!key) {
|
|
676
|
+
throw new SparkVaultValidationError('No matching Identity JWKS key was found', { kid });
|
|
677
|
+
}
|
|
678
|
+
if (key.kty !== 'OKP' || key.crv !== 'Ed25519' || typeof key.x !== 'string') {
|
|
679
|
+
throw new SparkVaultValidationError('Identity JWKS key is not an Ed25519 public key');
|
|
680
|
+
}
|
|
681
|
+
return key;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private isIdentityTokenClaims(value: unknown): value is IdentityTokenClaims {
|
|
685
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
686
|
+
const claims = value as Record<string, unknown>;
|
|
687
|
+
return (
|
|
688
|
+
typeof claims.iss === 'string' &&
|
|
689
|
+
typeof claims.sub === 'string' &&
|
|
690
|
+
typeof claims.aud === 'string' &&
|
|
691
|
+
typeof claims.exp === 'number' &&
|
|
692
|
+
typeof claims.iat === 'number' &&
|
|
693
|
+
(claims.nbf === undefined || typeof claims.nbf === 'number') &&
|
|
694
|
+
(claims.jti === undefined || typeof claims.jti === 'string') &&
|
|
695
|
+
typeof claims.identity === 'string' &&
|
|
696
|
+
(claims.identity_type === 'email' || claims.identity_type === 'phone') &&
|
|
697
|
+
typeof claims.verified_at === 'number' &&
|
|
698
|
+
typeof claims.method === 'string'
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private async getStoredUserEmail(): Promise<string | null> {
|
|
703
|
+
if (!this.config.tokenStorage.getUser) return null;
|
|
704
|
+
const user = await this.config.tokenStorage.getUser<SparkVaultUser>();
|
|
705
|
+
return user?.email ?? null;
|
|
706
|
+
}
|
|
707
|
+
}
|