@ovixa/auth-client 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ 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.3.0] - 2026-01-30
9
+
10
+ ### Added
11
+
12
+ - `auth.webauthn.listPasskeys({ accessToken })` - List all passkeys for the authenticated user
13
+ - `auth.webauthn.deletePasskey({ accessToken, credentialId })` - Delete a specific passkey
14
+
15
+ ### Changed
16
+
17
+ - **BREAKING**: Moved WebAuthn methods to `auth.webauthn` namespace
18
+ - `auth.getPasskeyRegistrationOptions()` → `auth.webauthn.getRegistrationOptions()`
19
+ - `auth.verifyPasskeyRegistration()` → `auth.webauthn.verifyRegistration()`
20
+ - `auth.getPasskeyAuthenticationOptions()` → `auth.webauthn.getAuthenticationOptions()`
21
+ - `auth.verifyPasskeyAuthentication()` → `auth.webauthn.authenticate()`
22
+
23
+ ## [0.2.0] - 2026-01-29
24
+
25
+ ### Added
26
+
27
+ - **WebAuthn/Passkey authentication support** - Passwordless authentication using FIDO2
28
+ - `auth.getPasskeyRegistrationOptions({ accessToken })` - Get options to register a new passkey
29
+ - `auth.verifyPasskeyRegistration({ accessToken, registration, deviceName? })` - Complete passkey registration
30
+ - `auth.getPasskeyAuthenticationOptions({ email? })` - Get options to authenticate with passkey
31
+ - `auth.verifyPasskeyAuthentication({ authentication })` - Authenticate and receive tokens
32
+
8
33
  ## [0.1.3] - 2026-01-29
9
34
 
10
35
  ### 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
@@ -243,6 +244,109 @@ try {
243
244
  }
244
245
  ```
245
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
+
246
350
  ### OAuth
247
351
 
248
352
  #### `getOAuthUrl(options)`
@@ -683,6 +683,37 @@ var OvixaAuth = class {
683
683
  }
684
684
  return new OvixaAuthAdmin(this.config);
685
685
  }
686
+ /**
687
+ * Get the WebAuthn API interface for passkey operations.
688
+ *
689
+ * The WebAuthn namespace provides methods for registering and authenticating
690
+ * with passkeys, as well as managing existing passkeys.
691
+ *
692
+ * @returns OvixaAuthWebAuthn interface for passkey operations
693
+ *
694
+ * @example
695
+ * ```typescript
696
+ * const auth = new OvixaAuth({
697
+ * authUrl: 'https://auth.ovixa.io',
698
+ * realmId: 'your-realm',
699
+ * });
700
+ *
701
+ * // Register a passkey
702
+ * const options = await auth.webauthn.getRegistrationOptions({ accessToken });
703
+ *
704
+ * // Authenticate with passkey
705
+ * const authOptions = await auth.webauthn.getAuthenticationOptions();
706
+ *
707
+ * // List passkeys
708
+ * const { passkeys } = await auth.webauthn.listPasskeys({ accessToken });
709
+ *
710
+ * // Delete a passkey
711
+ * await auth.webauthn.deletePasskey({ accessToken, credentialId });
712
+ * ```
713
+ */
714
+ get webauthn() {
715
+ return new OvixaAuthWebAuthn(this.config);
716
+ }
686
717
  };
687
718
  var OvixaAuthAdmin = class {
688
719
  config;
@@ -773,9 +804,382 @@ var OvixaAuthAdmin = class {
773
804
  }
774
805
  }
775
806
  };
807
+ var OvixaAuthWebAuthn = class {
808
+ config;
809
+ constructor(config) {
810
+ this.config = config;
811
+ }
812
+ /**
813
+ * Get registration options for creating a new passkey.
814
+ *
815
+ * The user must be logged in to register a passkey. Use the returned options
816
+ * with navigator.credentials.create() to create the credential.
817
+ *
818
+ * @param options - Registration options with access token
819
+ * @returns Registration options to pass to navigator.credentials.create()
820
+ * @throws {OvixaAuthError} If request fails
821
+ */
822
+ async getRegistrationOptions(options) {
823
+ const url = `${this.config.authUrl}/webauthn/register/options`;
824
+ try {
825
+ const response = await fetch(url, {
826
+ method: "POST",
827
+ headers: {
828
+ "Content-Type": "application/json",
829
+ Authorization: `Bearer ${options.accessToken}`
830
+ },
831
+ body: JSON.stringify({
832
+ realm_id: this.config.realmId
833
+ })
834
+ });
835
+ if (!response.ok) {
836
+ await this.handleErrorResponse(response);
837
+ }
838
+ return await response.json();
839
+ } catch (error) {
840
+ if (error instanceof OvixaAuthError) {
841
+ throw error;
842
+ }
843
+ if (error instanceof Error) {
844
+ throw new OvixaAuthError(`Network error: ${error.message}`, "NETWORK_ERROR");
845
+ }
846
+ throw new OvixaAuthError("Request failed", "REQUEST_FAILED");
847
+ }
848
+ }
849
+ /**
850
+ * Verify a passkey registration and store the credential.
851
+ *
852
+ * Call this after navigator.credentials.create() succeeds to complete the
853
+ * registration on the server.
854
+ *
855
+ * @param options - Verification options with registration response
856
+ * @returns Registration response with credential ID
857
+ * @throws {OvixaAuthError} If verification fails
858
+ */
859
+ async verifyRegistration(options) {
860
+ const url = `${this.config.authUrl}/webauthn/register/verify`;
861
+ const body = {
862
+ realm_id: this.config.realmId,
863
+ registration: options.registration
864
+ };
865
+ if (options.deviceName) {
866
+ body.device_name = options.deviceName;
867
+ }
868
+ try {
869
+ const response = await fetch(url, {
870
+ method: "POST",
871
+ headers: {
872
+ "Content-Type": "application/json",
873
+ Authorization: `Bearer ${options.accessToken}`
874
+ },
875
+ body: JSON.stringify(body)
876
+ });
877
+ if (!response.ok) {
878
+ await this.handleErrorResponse(response);
879
+ }
880
+ return await response.json();
881
+ } catch (error) {
882
+ if (error instanceof OvixaAuthError) {
883
+ throw error;
884
+ }
885
+ if (error instanceof Error) {
886
+ throw new OvixaAuthError(`Network error: ${error.message}`, "NETWORK_ERROR");
887
+ }
888
+ throw new OvixaAuthError("Request failed", "REQUEST_FAILED");
889
+ }
890
+ }
891
+ /**
892
+ * Get authentication options for signing in with a passkey.
893
+ *
894
+ * Use the returned options with navigator.credentials.get() to authenticate.
895
+ *
896
+ * @param options - Optional email for allowCredentials hint
897
+ * @returns Authentication options to pass to navigator.credentials.get()
898
+ * @throws {OvixaAuthError} If request fails
899
+ */
900
+ async getAuthenticationOptions(options) {
901
+ const url = `${this.config.authUrl}/webauthn/authenticate/options`;
902
+ const body = {
903
+ realm_id: this.config.realmId
904
+ };
905
+ if (options?.email) {
906
+ body.email = options.email;
907
+ }
908
+ try {
909
+ const response = await fetch(url, {
910
+ method: "POST",
911
+ headers: {
912
+ "Content-Type": "application/json"
913
+ },
914
+ body: JSON.stringify(body)
915
+ });
916
+ if (!response.ok) {
917
+ await this.handleErrorResponse(response);
918
+ }
919
+ return await response.json();
920
+ } catch (error) {
921
+ if (error instanceof OvixaAuthError) {
922
+ throw error;
923
+ }
924
+ if (error instanceof Error) {
925
+ throw new OvixaAuthError(`Network error: ${error.message}`, "NETWORK_ERROR");
926
+ }
927
+ throw new OvixaAuthError("Request failed", "REQUEST_FAILED");
928
+ }
929
+ }
930
+ /**
931
+ * Verify a passkey authentication and get tokens.
932
+ *
933
+ * Call this after navigator.credentials.get() succeeds to complete the
934
+ * authentication and receive access/refresh tokens.
935
+ *
936
+ * @param options - Verification options with authentication response
937
+ * @returns Token response with access and refresh tokens
938
+ * @throws {OvixaAuthError} If verification fails
939
+ */
940
+ async authenticate(options) {
941
+ const url = `${this.config.authUrl}/webauthn/authenticate/verify`;
942
+ const body = {
943
+ realm_id: this.config.realmId,
944
+ authentication: options.authentication
945
+ };
946
+ try {
947
+ const response = await fetch(url, {
948
+ method: "POST",
949
+ headers: {
950
+ "Content-Type": "application/json"
951
+ },
952
+ body: JSON.stringify(body)
953
+ });
954
+ if (!response.ok) {
955
+ await this.handleErrorResponse(response);
956
+ }
957
+ return await response.json();
958
+ } catch (error) {
959
+ if (error instanceof OvixaAuthError) {
960
+ throw error;
961
+ }
962
+ if (error instanceof Error) {
963
+ throw new OvixaAuthError(`Network error: ${error.message}`, "NETWORK_ERROR");
964
+ }
965
+ throw new OvixaAuthError("Request failed", "REQUEST_FAILED");
966
+ }
967
+ }
968
+ /**
969
+ * List all passkeys for the authenticated user.
970
+ *
971
+ * @param options - Options with access token
972
+ * @returns List of passkeys
973
+ * @throws {OvixaAuthError} If request fails
974
+ *
975
+ * @example
976
+ * ```typescript
977
+ * const { passkeys } = await auth.webauthn.listPasskeys({ accessToken });
978
+ * for (const passkey of passkeys) {
979
+ * console.log(passkey.device_name, passkey.created_at);
980
+ * }
981
+ * ```
982
+ */
983
+ async listPasskeys(options) {
984
+ const url = `${this.config.authUrl}/webauthn/passkeys`;
985
+ try {
986
+ const response = await fetch(url, {
987
+ method: "POST",
988
+ headers: {
989
+ "Content-Type": "application/json",
990
+ Authorization: `Bearer ${options.accessToken}`
991
+ },
992
+ body: JSON.stringify({
993
+ realm_id: this.config.realmId
994
+ })
995
+ });
996
+ if (!response.ok) {
997
+ await this.handleErrorResponse(response);
998
+ }
999
+ return await response.json();
1000
+ } catch (error) {
1001
+ if (error instanceof OvixaAuthError) {
1002
+ throw error;
1003
+ }
1004
+ if (error instanceof Error) {
1005
+ throw new OvixaAuthError(`Network error: ${error.message}`, "NETWORK_ERROR");
1006
+ }
1007
+ throw new OvixaAuthError("Request failed", "REQUEST_FAILED");
1008
+ }
1009
+ }
1010
+ /**
1011
+ * Delete a passkey for the authenticated user.
1012
+ *
1013
+ * @param options - Options with access token and credential ID
1014
+ * @returns Success response
1015
+ * @throws {OvixaAuthError} If deletion fails
1016
+ *
1017
+ * @example
1018
+ * ```typescript
1019
+ * await auth.webauthn.deletePasskey({
1020
+ * accessToken,
1021
+ * credentialId: 'credential-id-to-delete',
1022
+ * });
1023
+ * ```
1024
+ */
1025
+ async deletePasskey(options) {
1026
+ const url = `${this.config.authUrl}/webauthn/passkeys/${encodeURIComponent(options.credentialId)}`;
1027
+ try {
1028
+ const response = await fetch(url, {
1029
+ method: "DELETE",
1030
+ headers: {
1031
+ "Content-Type": "application/json",
1032
+ Authorization: `Bearer ${options.accessToken}`
1033
+ },
1034
+ body: JSON.stringify({
1035
+ realm_id: this.config.realmId
1036
+ })
1037
+ });
1038
+ if (!response.ok) {
1039
+ await this.handleErrorResponse(response);
1040
+ }
1041
+ return await response.json();
1042
+ } catch (error) {
1043
+ if (error instanceof OvixaAuthError) {
1044
+ throw error;
1045
+ }
1046
+ if (error instanceof Error) {
1047
+ throw new OvixaAuthError(`Network error: ${error.message}`, "NETWORK_ERROR");
1048
+ }
1049
+ throw new OvixaAuthError("Request failed", "REQUEST_FAILED");
1050
+ }
1051
+ }
1052
+ /**
1053
+ * Handle error response from the server.
1054
+ * @throws {OvixaAuthError} Always throws with appropriate error details
1055
+ */
1056
+ async handleErrorResponse(response) {
1057
+ const errorBody = await response.json().catch(() => ({}));
1058
+ const errorData = errorBody;
1059
+ let errorCode;
1060
+ let errorMessage;
1061
+ if (typeof errorData.error === "object" && errorData.error) {
1062
+ errorCode = errorData.error.code || this.mapHttpStatusToErrorCode(response.status);
1063
+ errorMessage = errorData.error.message || "Request failed";
1064
+ } else {
1065
+ errorCode = this.mapHttpStatusToErrorCode(response.status);
1066
+ errorMessage = typeof errorData.error === "string" ? errorData.error : "Request failed";
1067
+ }
1068
+ throw new OvixaAuthError(errorMessage, errorCode, response.status);
1069
+ }
1070
+ /**
1071
+ * Map HTTP status codes to error codes.
1072
+ */
1073
+ mapHttpStatusToErrorCode(status) {
1074
+ switch (status) {
1075
+ case 400:
1076
+ return "BAD_REQUEST";
1077
+ case 401:
1078
+ return "UNAUTHORIZED";
1079
+ case 403:
1080
+ return "FORBIDDEN";
1081
+ case 404:
1082
+ return "NOT_FOUND";
1083
+ case 429:
1084
+ return "RATE_LIMITED";
1085
+ case 500:
1086
+ case 502:
1087
+ case 503:
1088
+ return "SERVER_ERROR";
1089
+ default:
1090
+ return "UNKNOWN_ERROR";
1091
+ }
1092
+ }
1093
+ };
1094
+ function arrayBufferToBase64Url(buffer) {
1095
+ const bytes = new Uint8Array(buffer);
1096
+ let binary = "";
1097
+ for (const byte of bytes) {
1098
+ binary += String.fromCharCode(byte);
1099
+ }
1100
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1101
+ }
1102
+ function base64UrlToArrayBuffer(base64url) {
1103
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
1104
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
1105
+ const binary = atob(padded);
1106
+ const bytes = new Uint8Array(binary.length);
1107
+ for (let i = 0; i < binary.length; i++) {
1108
+ bytes[i] = binary.charCodeAt(i);
1109
+ }
1110
+ return bytes.buffer;
1111
+ }
1112
+ function prepareRegistrationOptions(options) {
1113
+ return {
1114
+ challenge: base64UrlToArrayBuffer(options.challenge),
1115
+ rp: options.rp,
1116
+ user: {
1117
+ id: new TextEncoder().encode(options.user.id),
1118
+ name: options.user.name,
1119
+ displayName: options.user.displayName
1120
+ },
1121
+ pubKeyCredParams: options.pubKeyCredParams,
1122
+ excludeCredentials: options.excludeCredentials.map((cred) => ({
1123
+ id: base64UrlToArrayBuffer(cred.id),
1124
+ type: cred.type,
1125
+ transports: cred.transports
1126
+ })),
1127
+ authenticatorSelection: options.authenticatorSelection,
1128
+ timeout: options.timeout,
1129
+ attestation: options.attestation
1130
+ };
1131
+ }
1132
+ function prepareAuthenticationOptions(options) {
1133
+ return {
1134
+ challenge: base64UrlToArrayBuffer(options.challenge),
1135
+ rpId: options.rpId,
1136
+ allowCredentials: options.allowCredentials.map((cred) => ({
1137
+ id: base64UrlToArrayBuffer(cred.id),
1138
+ type: cred.type,
1139
+ transports: cred.transports
1140
+ })),
1141
+ userVerification: options.userVerification,
1142
+ timeout: options.timeout
1143
+ };
1144
+ }
1145
+ function formatRegistrationResponse(credential) {
1146
+ const response = credential.response;
1147
+ return {
1148
+ id: credential.id,
1149
+ rawId: arrayBufferToBase64Url(credential.rawId),
1150
+ type: "public-key",
1151
+ response: {
1152
+ clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
1153
+ attestationObject: arrayBufferToBase64Url(response.attestationObject),
1154
+ transports: response.getTransports?.()
1155
+ },
1156
+ authenticatorAttachment: credential.authenticatorAttachment
1157
+ };
1158
+ }
1159
+ function formatAuthenticationResponse(credential) {
1160
+ const response = credential.response;
1161
+ return {
1162
+ id: credential.id,
1163
+ rawId: arrayBufferToBase64Url(credential.rawId),
1164
+ type: "public-key",
1165
+ response: {
1166
+ clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
1167
+ authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
1168
+ signature: arrayBufferToBase64Url(response.signature),
1169
+ userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null
1170
+ },
1171
+ authenticatorAttachment: credential.authenticatorAttachment
1172
+ };
1173
+ }
776
1174
 
777
1175
  export {
778
1176
  OvixaAuthError,
779
- OvixaAuth
1177
+ OvixaAuth,
1178
+ arrayBufferToBase64Url,
1179
+ base64UrlToArrayBuffer,
1180
+ prepareRegistrationOptions,
1181
+ prepareAuthenticationOptions,
1182
+ formatRegistrationResponse,
1183
+ formatAuthenticationResponse
780
1184
  };
781
- //# sourceMappingURL=chunk-CUACHOKT.js.map
1185
+ //# sourceMappingURL=chunk-5QEKSWV4.js.map