@ovixa/auth-client 0.1.2 → 0.2.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/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [0.2.0] - 2026-01-29
9
+
10
+ ### Added
11
+
12
+ - **WebAuthn/Passkey authentication support** - Passwordless authentication using FIDO2
13
+ - `auth.webauthn.getRegistrationOptions(realmId)` - Get options to register a new passkey
14
+ - `auth.webauthn.verifyRegistration(realmId, registration)` - Complete passkey registration
15
+ - `auth.webauthn.getAuthenticationOptions(realmId, email?)` - Get options to authenticate with passkey
16
+ - `auth.webauthn.authenticate(realmId, authentication)` - Authenticate and receive tokens
17
+ - `auth.webauthn.getCredentials(realmId)` - List user's registered passkeys
18
+ - `auth.webauthn.deleteCredential(realmId, credentialId)` - Remove a passkey
19
+ - `auth.webauthn.updateCredentialName(realmId, credentialId, name)` - Rename a passkey
20
+
21
+ ## [0.1.3] - 2026-01-29
22
+
23
+ ### Added
24
+
25
+ - `auth.exchangeOAuthCode(code)` - Exchange OAuth authorization code for tokens, completing the OAuth 2.0 Authorization Code flow
26
+
27
+ ### Fixed
28
+
29
+ - OAuth flow now works correctly with the auth server's secure authorization code pattern
30
+
8
31
  ## [0.1.2] - 2026-01-28
9
32
 
10
33
  ### Added
package/README.md CHANGED
@@ -5,6 +5,7 @@ Client SDK for the Ovixa Auth service. Provides authentication, token verificati
5
5
  ## Features
6
6
 
7
7
  - Email/password authentication (signup, login, password reset)
8
+ - **Passkey (WebAuthn) authentication** - phishing-resistant passwordless sign-in
8
9
  - OAuth integration (Google, GitHub)
9
10
  - JWT verification with JWKS caching
10
11
  - Automatic token refresh
@@ -141,6 +142,15 @@ await auth.forgotPassword({
141
142
  });
142
143
  ```
143
144
 
145
+ ### Email Branding
146
+
147
+ Verification and password reset emails automatically display your realm's `display_name` as the brand name. To customize the branding in emails:
148
+
149
+ 1. Set `display_name` when creating your realm
150
+ 2. Emails will show your brand (e.g., "Linkdrop") instead of "Ovixa"
151
+
152
+ If no `display_name` is set, emails default to "Ovixa".
153
+
144
154
  #### `resetPassword(options)`
145
155
 
146
156
  Set a new password using a reset token.
@@ -234,6 +244,109 @@ try {
234
244
  }
235
245
  ```
236
246
 
247
+ ### Passkeys (WebAuthn)
248
+
249
+ Passkeys provide phishing-resistant, passwordless authentication using FIDO2/WebAuthn.
250
+
251
+ #### `webauthn.getRegistrationOptions(options)`
252
+
253
+ Get options for registering a new passkey. **Requires authentication.**
254
+
255
+ ```typescript
256
+ const options = await auth.webauthn.getRegistrationOptions({
257
+ accessToken: 'user-access-token',
258
+ });
259
+ // Returns PublicKeyCredentialCreationOptions for navigator.credentials.create()
260
+ ```
261
+
262
+ #### `webauthn.verifyRegistration(options)`
263
+
264
+ Verify and store a new passkey registration. **Requires authentication.**
265
+
266
+ ```typescript
267
+ const result = await auth.webauthn.verifyRegistration({
268
+ accessToken: 'user-access-token',
269
+ registration: credentialResponse, // From navigator.credentials.create()
270
+ deviceName: 'My MacBook', // Optional friendly name
271
+ });
272
+ // Returns: { success: true, credential_id: string, device_name?: string }
273
+ ```
274
+
275
+ #### `webauthn.getAuthenticationOptions(options)`
276
+
277
+ Get options for signing in with a passkey. Does not require authentication.
278
+
279
+ ```typescript
280
+ const options = await auth.webauthn.getAuthenticationOptions({
281
+ email: 'user@example.com', // Optional - provides allowCredentials hint
282
+ });
283
+ // Returns PublicKeyCredentialRequestOptions for navigator.credentials.get()
284
+ ```
285
+
286
+ #### `webauthn.authenticate(options)`
287
+
288
+ Verify passkey authentication and receive tokens.
289
+
290
+ ```typescript
291
+ const tokens = await auth.webauthn.authenticate({
292
+ authentication: credentialResponse, // From navigator.credentials.get()
293
+ });
294
+ // Returns: { access_token, refresh_token, token_type, expires_in }
295
+ ```
296
+
297
+ #### Complete Passkey Flow Example
298
+
299
+ ```typescript
300
+ import { OvixaAuth } from '@ovixa/auth-client';
301
+
302
+ const auth = new OvixaAuth({
303
+ authUrl: 'https://auth.ovixa.io',
304
+ realmId: 'your-realm-id',
305
+ });
306
+
307
+ // Register a passkey (user must be logged in)
308
+ async function registerPasskey(accessToken: string) {
309
+ // 1. Get registration options from server
310
+ const options = await auth.webauthn.getRegistrationOptions({ accessToken });
311
+
312
+ // 2. Create credential using browser API
313
+ const credential = await navigator.credentials.create({
314
+ publicKey: options,
315
+ });
316
+
317
+ if (!credential) throw new Error('Registration cancelled');
318
+
319
+ // 3. Verify with server
320
+ const result = await auth.webauthn.verifyRegistration({
321
+ accessToken,
322
+ registration: credential as PublicKeyCredential,
323
+ deviceName: 'My Device',
324
+ });
325
+
326
+ return result;
327
+ }
328
+
329
+ // Sign in with passkey
330
+ async function signInWithPasskey(email?: string) {
331
+ // 1. Get authentication options
332
+ const options = await auth.webauthn.getAuthenticationOptions({ email });
333
+
334
+ // 2. Get credential using browser API
335
+ const credential = await navigator.credentials.get({
336
+ publicKey: options,
337
+ });
338
+
339
+ if (!credential) throw new Error('Authentication cancelled');
340
+
341
+ // 3. Verify and get tokens
342
+ const tokens = await auth.webauthn.authenticate({
343
+ authentication: credential as PublicKeyCredential,
344
+ });
345
+
346
+ return tokens;
347
+ }
348
+ ```
349
+
237
350
  ### OAuth
238
351
 
239
352
  #### `getOAuthUrl(options)`
@@ -415,7 +415,8 @@ var OvixaAuth = class {
415
415
  * Generate an OAuth authorization URL.
416
416
  *
417
417
  * Redirect the user to this URL to start the OAuth flow. After authentication,
418
- * the user will be redirected back to your `redirectUri` with tokens.
418
+ * the user will be redirected back to your `redirectUri` with an authorization code.
419
+ * Use `exchangeOAuthCode()` to exchange the code for tokens.
419
420
  *
420
421
  * @param options - OAuth URL options
421
422
  * @returns The full OAuth authorization URL
@@ -429,6 +430,10 @@ var OvixaAuth = class {
429
430
  *
430
431
  * // Redirect user to start OAuth flow
431
432
  * window.location.href = googleAuthUrl;
433
+ *
434
+ * // In your callback handler:
435
+ * // const code = new URLSearchParams(window.location.search).get('code');
436
+ * // const tokens = await auth.exchangeOAuthCode(code);
432
437
  * ```
433
438
  */
434
439
  getOAuthUrl(options) {
@@ -437,6 +442,329 @@ var OvixaAuth = class {
437
442
  url.searchParams.set("redirect_uri", options.redirectUri);
438
443
  return url.toString();
439
444
  }
445
+ // ============================================================
446
+ // WebAuthn / Passkey Methods
447
+ // ============================================================
448
+ /**
449
+ * Get registration options for creating a new passkey.
450
+ *
451
+ * The user must be logged in to register a passkey. Use the returned options
452
+ * with navigator.credentials.create() to create the credential.
453
+ *
454
+ * @param options - Registration options with access token
455
+ * @returns Registration options to pass to navigator.credentials.create()
456
+ * @throws {OvixaAuthError} If request fails
457
+ *
458
+ * @example
459
+ * ```typescript
460
+ * // User must be logged in
461
+ * const options = await auth.getPasskeyRegistrationOptions({
462
+ * accessToken: session.accessToken,
463
+ * });
464
+ *
465
+ * // Create the credential using the browser API
466
+ * const credential = await navigator.credentials.create({
467
+ * publicKey: {
468
+ * ...options,
469
+ * challenge: base64UrlToArrayBuffer(options.challenge),
470
+ * user: {
471
+ * ...options.user,
472
+ * id: new TextEncoder().encode(options.user.id),
473
+ * },
474
+ * excludeCredentials: options.excludeCredentials.map(c => ({
475
+ * ...c,
476
+ * id: base64UrlToArrayBuffer(c.id),
477
+ * })),
478
+ * },
479
+ * });
480
+ *
481
+ * // Verify the registration with the server
482
+ * await auth.verifyPasskeyRegistration({
483
+ * accessToken: session.accessToken,
484
+ * registration: formatRegistrationResponse(credential),
485
+ * deviceName: 'My MacBook',
486
+ * });
487
+ * ```
488
+ */
489
+ async getPasskeyRegistrationOptions(options) {
490
+ const url = `${this.config.authUrl}/webauthn/register/options`;
491
+ try {
492
+ const response = await fetch(url, {
493
+ method: "POST",
494
+ headers: {
495
+ "Content-Type": "application/json",
496
+ Authorization: `Bearer ${options.accessToken}`
497
+ },
498
+ body: JSON.stringify({
499
+ realm_id: this.config.realmId
500
+ })
501
+ });
502
+ if (!response.ok) {
503
+ await this.handleErrorResponse(response);
504
+ }
505
+ return await response.json();
506
+ } catch (error) {
507
+ if (error instanceof OvixaAuthError) {
508
+ throw error;
509
+ }
510
+ if (error instanceof Error) {
511
+ throw new OvixaAuthError(`Network error: ${error.message}`, "NETWORK_ERROR");
512
+ }
513
+ throw new OvixaAuthError("Request failed", "REQUEST_FAILED");
514
+ }
515
+ }
516
+ /**
517
+ * Verify a passkey registration and store the credential.
518
+ *
519
+ * Call this after navigator.credentials.create() succeeds to complete the
520
+ * registration on the server.
521
+ *
522
+ * @param options - Verification options with registration response
523
+ * @returns Registration response with credential ID
524
+ * @throws {OvixaAuthError} If verification fails
525
+ *
526
+ * @example
527
+ * ```typescript
528
+ * const result = await auth.verifyPasskeyRegistration({
529
+ * accessToken: session.accessToken,
530
+ * registration: {
531
+ * id: credential.id,
532
+ * rawId: arrayBufferToBase64Url(credential.rawId),
533
+ * type: 'public-key',
534
+ * response: {
535
+ * clientDataJSON: arrayBufferToBase64Url(credential.response.clientDataJSON),
536
+ * attestationObject: arrayBufferToBase64Url(credential.response.attestationObject),
537
+ * transports: credential.response.getTransports?.(),
538
+ * },
539
+ * },
540
+ * deviceName: 'My MacBook',
541
+ * });
542
+ *
543
+ * console.log('Passkey registered:', result.credential_id);
544
+ * ```
545
+ */
546
+ async verifyPasskeyRegistration(options) {
547
+ const url = `${this.config.authUrl}/webauthn/register/verify`;
548
+ const body = {
549
+ realm_id: this.config.realmId,
550
+ registration: options.registration
551
+ };
552
+ if (options.deviceName) {
553
+ body.device_name = options.deviceName;
554
+ }
555
+ try {
556
+ const response = await fetch(url, {
557
+ method: "POST",
558
+ headers: {
559
+ "Content-Type": "application/json",
560
+ Authorization: `Bearer ${options.accessToken}`
561
+ },
562
+ body: JSON.stringify(body)
563
+ });
564
+ if (!response.ok) {
565
+ await this.handleErrorResponse(response);
566
+ }
567
+ return await response.json();
568
+ } catch (error) {
569
+ if (error instanceof OvixaAuthError) {
570
+ throw error;
571
+ }
572
+ if (error instanceof Error) {
573
+ throw new OvixaAuthError(`Network error: ${error.message}`, "NETWORK_ERROR");
574
+ }
575
+ throw new OvixaAuthError("Request failed", "REQUEST_FAILED");
576
+ }
577
+ }
578
+ /**
579
+ * Get authentication options for signing in with a passkey.
580
+ *
581
+ * Use the returned options with navigator.credentials.get() to authenticate.
582
+ *
583
+ * @param options - Optional email for allowCredentials hint
584
+ * @returns Authentication options to pass to navigator.credentials.get()
585
+ * @throws {OvixaAuthError} If request fails
586
+ *
587
+ * @example
588
+ * ```typescript
589
+ * // Option 1: Discoverable credentials (no email needed)
590
+ * const options = await auth.getPasskeyAuthenticationOptions();
591
+ *
592
+ * // Option 2: Provide email for allowCredentials hint
593
+ * const options = await auth.getPasskeyAuthenticationOptions({
594
+ * email: 'user@example.com',
595
+ * });
596
+ *
597
+ * // Authenticate using the browser API
598
+ * const credential = await navigator.credentials.get({
599
+ * publicKey: {
600
+ * ...options,
601
+ * challenge: base64UrlToArrayBuffer(options.challenge),
602
+ * allowCredentials: options.allowCredentials.map(c => ({
603
+ * ...c,
604
+ * id: base64UrlToArrayBuffer(c.id),
605
+ * })),
606
+ * },
607
+ * });
608
+ *
609
+ * // Verify and get tokens
610
+ * const tokens = await auth.verifyPasskeyAuthentication({
611
+ * authentication: formatAuthenticationResponse(credential),
612
+ * });
613
+ * ```
614
+ */
615
+ async getPasskeyAuthenticationOptions(options) {
616
+ const url = `${this.config.authUrl}/webauthn/authenticate/options`;
617
+ const body = {
618
+ realm_id: this.config.realmId
619
+ };
620
+ if (options?.email) {
621
+ body.email = options.email;
622
+ }
623
+ try {
624
+ const response = await fetch(url, {
625
+ method: "POST",
626
+ headers: {
627
+ "Content-Type": "application/json"
628
+ },
629
+ body: JSON.stringify(body)
630
+ });
631
+ if (!response.ok) {
632
+ await this.handleErrorResponse(response);
633
+ }
634
+ return await response.json();
635
+ } catch (error) {
636
+ if (error instanceof OvixaAuthError) {
637
+ throw error;
638
+ }
639
+ if (error instanceof Error) {
640
+ throw new OvixaAuthError(`Network error: ${error.message}`, "NETWORK_ERROR");
641
+ }
642
+ throw new OvixaAuthError("Request failed", "REQUEST_FAILED");
643
+ }
644
+ }
645
+ /**
646
+ * Verify a passkey authentication and get tokens.
647
+ *
648
+ * Call this after navigator.credentials.get() succeeds to complete the
649
+ * authentication and receive access/refresh tokens.
650
+ *
651
+ * @param options - Verification options with authentication response
652
+ * @returns Token response with access and refresh tokens
653
+ * @throws {OvixaAuthError} If verification fails
654
+ *
655
+ * @example
656
+ * ```typescript
657
+ * const tokens = await auth.verifyPasskeyAuthentication({
658
+ * authentication: {
659
+ * id: credential.id,
660
+ * rawId: arrayBufferToBase64Url(credential.rawId),
661
+ * type: 'public-key',
662
+ * response: {
663
+ * clientDataJSON: arrayBufferToBase64Url(credential.response.clientDataJSON),
664
+ * authenticatorData: arrayBufferToBase64Url(credential.response.authenticatorData),
665
+ * signature: arrayBufferToBase64Url(credential.response.signature),
666
+ * userHandle: credential.response.userHandle
667
+ * ? arrayBufferToBase64Url(credential.response.userHandle)
668
+ * : null,
669
+ * },
670
+ * },
671
+ * });
672
+ *
673
+ * // Use the tokens
674
+ * console.log('Logged in!', tokens.access_token);
675
+ * ```
676
+ */
677
+ async verifyPasskeyAuthentication(options) {
678
+ const url = `${this.config.authUrl}/webauthn/authenticate/verify`;
679
+ const body = {
680
+ realm_id: this.config.realmId,
681
+ authentication: options.authentication
682
+ };
683
+ try {
684
+ const response = await fetch(url, {
685
+ method: "POST",
686
+ headers: {
687
+ "Content-Type": "application/json"
688
+ },
689
+ body: JSON.stringify(body)
690
+ });
691
+ if (!response.ok) {
692
+ await this.handleErrorResponse(response);
693
+ }
694
+ return await response.json();
695
+ } catch (error) {
696
+ if (error instanceof OvixaAuthError) {
697
+ throw error;
698
+ }
699
+ if (error instanceof Error) {
700
+ throw new OvixaAuthError(`Network error: ${error.message}`, "NETWORK_ERROR");
701
+ }
702
+ throw new OvixaAuthError("Request failed", "REQUEST_FAILED");
703
+ }
704
+ }
705
+ /**
706
+ * Handle error response from the server.
707
+ * @throws {OvixaAuthError} Always throws with appropriate error details
708
+ */
709
+ async handleErrorResponse(response) {
710
+ const errorBody = await response.json().catch(() => ({}));
711
+ const errorData = errorBody;
712
+ let errorCode;
713
+ let errorMessage;
714
+ if (typeof errorData.error === "object" && errorData.error) {
715
+ errorCode = errorData.error.code || this.mapHttpStatusToErrorCode(response.status);
716
+ errorMessage = errorData.error.message || "Request failed";
717
+ } else {
718
+ errorCode = this.mapHttpStatusToErrorCode(response.status);
719
+ errorMessage = typeof errorData.error === "string" ? errorData.error : "Request failed";
720
+ }
721
+ throw new OvixaAuthError(errorMessage, errorCode, response.status);
722
+ }
723
+ /**
724
+ * Exchange an OAuth authorization code for tokens.
725
+ *
726
+ * After the OAuth flow completes, the user is redirected back to your app with
727
+ * an authorization code in the URL query params. Use this method to exchange
728
+ * that code for access and refresh tokens.
729
+ *
730
+ * Note: Authorization codes expire after 60 seconds.
731
+ *
732
+ * @param code - The authorization code from the OAuth callback URL
733
+ * @returns Token response with access and refresh tokens
734
+ * @throws {OvixaAuthError} If the exchange fails
735
+ *
736
+ * @example
737
+ * ```typescript
738
+ * // In your OAuth callback handler
739
+ * const code = new URLSearchParams(window.location.search).get('code');
740
+ *
741
+ * if (code) {
742
+ * try {
743
+ * const tokens = await auth.exchangeOAuthCode(code);
744
+ * console.log('OAuth successful!', tokens.access_token);
745
+ *
746
+ * // Check if this is a new user (first-time OAuth login)
747
+ * if (tokens.is_new_user) {
748
+ * // Show onboarding flow
749
+ * }
750
+ * } catch (error) {
751
+ * if (error instanceof OvixaAuthError) {
752
+ * if (error.code === 'INVALID_CODE') {
753
+ * console.error('Invalid or expired authorization code');
754
+ * }
755
+ * }
756
+ * }
757
+ * }
758
+ * ```
759
+ */
760
+ async exchangeOAuthCode(code) {
761
+ const url = `${this.config.authUrl}/oauth/token`;
762
+ const body = {
763
+ code,
764
+ realm_id: this.config.realmId
765
+ };
766
+ return this.makeRequest(url, body);
767
+ }
440
768
  /**
441
769
  * Transform a token response into an AuthResult with user and session data.
442
770
  *
@@ -723,9 +1051,95 @@ var OvixaAuthAdmin = class {
723
1051
  }
724
1052
  }
725
1053
  };
1054
+ function arrayBufferToBase64Url(buffer) {
1055
+ const bytes = new Uint8Array(buffer);
1056
+ let binary = "";
1057
+ for (const byte of bytes) {
1058
+ binary += String.fromCharCode(byte);
1059
+ }
1060
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1061
+ }
1062
+ function base64UrlToArrayBuffer(base64url) {
1063
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
1064
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
1065
+ const binary = atob(padded);
1066
+ const bytes = new Uint8Array(binary.length);
1067
+ for (let i = 0; i < binary.length; i++) {
1068
+ bytes[i] = binary.charCodeAt(i);
1069
+ }
1070
+ return bytes.buffer;
1071
+ }
1072
+ function prepareRegistrationOptions(options) {
1073
+ return {
1074
+ challenge: base64UrlToArrayBuffer(options.challenge),
1075
+ rp: options.rp,
1076
+ user: {
1077
+ id: new TextEncoder().encode(options.user.id),
1078
+ name: options.user.name,
1079
+ displayName: options.user.displayName
1080
+ },
1081
+ pubKeyCredParams: options.pubKeyCredParams,
1082
+ excludeCredentials: options.excludeCredentials.map((cred) => ({
1083
+ id: base64UrlToArrayBuffer(cred.id),
1084
+ type: cred.type,
1085
+ transports: cred.transports
1086
+ })),
1087
+ authenticatorSelection: options.authenticatorSelection,
1088
+ timeout: options.timeout,
1089
+ attestation: options.attestation
1090
+ };
1091
+ }
1092
+ function prepareAuthenticationOptions(options) {
1093
+ return {
1094
+ challenge: base64UrlToArrayBuffer(options.challenge),
1095
+ rpId: options.rpId,
1096
+ allowCredentials: options.allowCredentials.map((cred) => ({
1097
+ id: base64UrlToArrayBuffer(cred.id),
1098
+ type: cred.type,
1099
+ transports: cred.transports
1100
+ })),
1101
+ userVerification: options.userVerification,
1102
+ timeout: options.timeout
1103
+ };
1104
+ }
1105
+ function formatRegistrationResponse(credential) {
1106
+ const response = credential.response;
1107
+ return {
1108
+ id: credential.id,
1109
+ rawId: arrayBufferToBase64Url(credential.rawId),
1110
+ type: "public-key",
1111
+ response: {
1112
+ clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
1113
+ attestationObject: arrayBufferToBase64Url(response.attestationObject),
1114
+ transports: response.getTransports?.()
1115
+ },
1116
+ authenticatorAttachment: credential.authenticatorAttachment
1117
+ };
1118
+ }
1119
+ function formatAuthenticationResponse(credential) {
1120
+ const response = credential.response;
1121
+ return {
1122
+ id: credential.id,
1123
+ rawId: arrayBufferToBase64Url(credential.rawId),
1124
+ type: "public-key",
1125
+ response: {
1126
+ clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
1127
+ authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
1128
+ signature: arrayBufferToBase64Url(response.signature),
1129
+ userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null
1130
+ },
1131
+ authenticatorAttachment: credential.authenticatorAttachment
1132
+ };
1133
+ }
726
1134
 
727
1135
  export {
728
1136
  OvixaAuthError,
729
- OvixaAuth
1137
+ OvixaAuth,
1138
+ arrayBufferToBase64Url,
1139
+ base64UrlToArrayBuffer,
1140
+ prepareRegistrationOptions,
1141
+ prepareAuthenticationOptions,
1142
+ formatRegistrationResponse,
1143
+ formatAuthenticationResponse
730
1144
  };
731
- //# sourceMappingURL=chunk-IIOWUPWH.js.map
1145
+ //# sourceMappingURL=chunk-5ZWKDQQM.js.map