@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +52 -47
  2. package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
  3. package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
  4. package/dist/index.cjs +645 -150
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -9
  7. package/dist/index.d.ts +27 -9
  8. package/dist/index.js +644 -150
  9. package/dist/index.js.map +1 -1
  10. package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
  11. package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
  12. package/dist/react.d.cts +2 -2
  13. package/dist/react.d.ts +2 -2
  14. package/dist/server.cjs +2880 -1667
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +810 -169
  17. package/dist/server.d.ts +810 -169
  18. package/dist/server.js +2848 -1646
  19. package/dist/server.js.map +1 -1
  20. package/dist/svelte.cjs +2 -2
  21. package/dist/svelte.cjs.map +1 -1
  22. package/dist/svelte.d.cts +2 -2
  23. package/dist/svelte.d.ts +2 -2
  24. package/dist/svelte.js +2 -2
  25. package/dist/svelte.js.map +1 -1
  26. package/dist/vue.d.cts +1 -1
  27. package/dist/vue.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/admin/admin-api.ts +327 -0
  30. package/src/admin/audit-log.ts +324 -0
  31. package/src/admin/webhooks.ts +576 -0
  32. package/src/bindings/create-auth-session.ts +184 -0
  33. package/src/bindings/create-org-session.ts +130 -0
  34. package/src/client/auth-client.ts +1592 -0
  35. package/src/client/auth-sync.ts +213 -0
  36. package/src/client/device-session.ts +104 -0
  37. package/src/client/org-client.ts +399 -0
  38. package/src/client/quickstart.ts +108 -0
  39. package/src/client/storage.ts +94 -0
  40. package/src/device/device-identity.ts +330 -0
  41. package/src/device/device-store.ts +379 -0
  42. package/src/encryption/auto-lock.ts +170 -0
  43. package/src/encryption/database-encryption.ts +265 -0
  44. package/src/encryption/key-derivation.ts +149 -0
  45. package/src/encryption/operation-encryptor.ts +361 -0
  46. package/src/index.ts +132 -0
  47. package/src/mfa/totp.ts +826 -0
  48. package/src/org/org-routes.ts +758 -0
  49. package/src/org/org-store.ts +490 -0
  50. package/src/org/org-types.ts +230 -0
  51. package/src/passkey/passkey-client.ts +597 -0
  52. package/src/passkey/passkey-server.ts +779 -0
  53. package/src/postgres/ensure-schema.ts +65 -0
  54. package/src/provider/adapter.ts +246 -0
  55. package/src/provider/built-in/auth-routes.ts +1313 -0
  56. package/src/provider/built-in/email-verification.ts +303 -0
  57. package/src/provider/built-in/password-hash.ts +118 -0
  58. package/src/provider/built-in/password-reset.ts +416 -0
  59. package/src/provider/built-in/postgres-user-store.ts +365 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +335 -0
  62. package/src/provider/built-in/sync-scopes.ts +85 -0
  63. package/src/provider/built-in/user-store.ts +465 -0
  64. package/src/provider/external/clerk-adapter.ts +157 -0
  65. package/src/provider/external/external-jwt-provider.ts +491 -0
  66. package/src/provider/external/supabase-adapter.ts +163 -0
  67. package/src/provider/oauth/linked-identity-store.ts +108 -0
  68. package/src/provider/oauth/oauth-flow.ts +550 -0
  69. package/src/provider/oauth/oauth-types.ts +184 -0
  70. package/src/provider/oauth/postgres-oauth-store.ts +296 -0
  71. package/src/provider/oauth/sqlite-oauth-store.ts +285 -0
  72. package/src/rbac/rbac-engine.ts +323 -0
  73. package/src/rbac/rbac-types.ts +210 -0
  74. package/src/rbac/scope-resolver.ts +140 -0
  75. package/src/react/AuthProvider.tsx +97 -0
  76. package/src/react/OrgProvider.tsx +41 -0
  77. package/src/react/auth-context.ts +26 -0
  78. package/src/react/hooks.ts +110 -0
  79. package/src/react/org-hooks.ts +214 -0
  80. package/src/react.ts +26 -0
  81. package/src/server.ts +338 -0
  82. package/src/session/session.ts +401 -0
  83. package/src/svelte/auth-context.ts +50 -0
  84. package/src/svelte/org-context.ts +32 -0
  85. package/src/svelte/org-hooks.ts +201 -0
  86. package/src/svelte/use-auth.ts +115 -0
  87. package/src/svelte.ts +25 -0
  88. package/src/tokens/encrypted-token-store.ts +360 -0
  89. package/src/tokens/jwt.ts +236 -0
  90. package/src/tokens/postgres-token-revocation-store.ts +140 -0
  91. package/src/tokens/sqlite-token-revocation-store.ts +121 -0
  92. package/src/tokens/token-manager.ts +821 -0
  93. package/src/tokens/token-store.ts +192 -0
  94. package/src/types.ts +394 -0
  95. package/src/vue/auth-context.ts +10 -0
  96. package/src/vue/auth-provider-types.ts +5 -0
  97. package/src/vue/auth-provider.ts +76 -0
  98. package/src/vue/org-hooks.ts +193 -0
  99. package/src/vue/org-provider.ts +49 -0
  100. package/src/vue/use-auth.ts +139 -0
  101. package/src/vue.ts +10 -0
@@ -0,0 +1,108 @@
1
+ import type { DeviceKeyStore } from '../device/device-store'
2
+ import { AuthClient, type AuthClientConfig, type AuthTokenStorage } from './auth-client'
3
+ import type { AuthDeviceIdentityProvider } from './device-session'
4
+ import { createPersistentDeviceIdentity } from './device-session'
5
+ import {
6
+ type AuthKeyValueStorage,
7
+ createAuthTokenStorage,
8
+ createWebStorageAuthTokenStorage,
9
+ } from './storage'
10
+
11
+ export interface CreateKoraAuthOptions
12
+ extends Omit<AuthClientConfig, 'storage' | 'deviceIdentity'> {
13
+ /**
14
+ * Complete token storage adapter. Use this for fully custom runtimes.
15
+ * If omitted, `credentialStore` is adapted automatically.
16
+ */
17
+ storage?: AuthTokenStorage
18
+ /**
19
+ * Runtime credential store used for tokens and stable device ID.
20
+ * Examples: Tauri secure storage, Expo SecureStore, iOS Keychain, Android Keystore.
21
+ */
22
+ credentialStore?: AuthKeyValueStorage
23
+ /**
24
+ * Explicit device identity provider. Set to `false` to disable automatic
25
+ * device binding during sign-up/sign-in.
26
+ */
27
+ deviceIdentity?: AuthDeviceIdentityProvider | false
28
+ /**
29
+ * Device key store for runtimes without IndexedDB, such as React Native.
30
+ */
31
+ deviceKeyStore?: DeviceKeyStore
32
+ }
33
+
34
+ /**
35
+ * Create a production-shaped Kora auth client with minimal setup.
36
+ *
37
+ * Defaults:
38
+ * - browser/Tauri WebView: localStorage for tokens, IndexedDB for device keys
39
+ * - desktop/mobile: pass `credentialStore` and optionally `deviceKeyStore`
40
+ * - automatic device identity is enabled when a persistent device ID store exists
41
+ */
42
+ export function createKoraAuth(options: CreateKoraAuthOptions): AuthClient {
43
+ const storage =
44
+ options.storage ??
45
+ (options.credentialStore
46
+ ? createAuthTokenStorage({
47
+ store: options.credentialStore,
48
+ prefix: options.storageKey,
49
+ })
50
+ : tryCreateDefaultTokenStorage(options.storageKey))
51
+
52
+ const deviceIdentity =
53
+ options.deviceIdentity === false
54
+ ? undefined
55
+ : (options.deviceIdentity ??
56
+ tryCreateDefaultDeviceIdentity(options.credentialStore, options.deviceKeyStore))
57
+
58
+ return new AuthClient({
59
+ serverUrl: options.serverUrl,
60
+ storageKey: options.storageKey,
61
+ storage,
62
+ fetch: options.fetch,
63
+ deviceIdentity,
64
+ requestTimeoutMs: options.requestTimeoutMs,
65
+ maxOfflineGraceMs: options.maxOfflineGraceMs,
66
+ refreshBackoff: options.refreshBackoff,
67
+ })
68
+ }
69
+
70
+ function tryCreateDefaultTokenStorage(
71
+ storageKey: string | undefined,
72
+ ): AuthTokenStorage | undefined {
73
+ const storage = tryGetBrowserStorage()
74
+ return storage ? createWebStorageAuthTokenStorage(storage, storageKey) : undefined
75
+ }
76
+
77
+ function tryCreateDefaultDeviceIdentity(
78
+ credentialStore: AuthKeyValueStorage | undefined,
79
+ deviceKeyStore: DeviceKeyStore | undefined,
80
+ ): AuthDeviceIdentityProvider | undefined {
81
+ const storage = credentialStore ?? tryGetBrowserStorage()
82
+ if (!storage) {
83
+ return undefined
84
+ }
85
+
86
+ try {
87
+ return createPersistentDeviceIdentity({
88
+ storage,
89
+ keyStore: deviceKeyStore,
90
+ })
91
+ } catch {
92
+ return undefined
93
+ }
94
+ }
95
+
96
+ function tryGetBrowserStorage(): Storage | null {
97
+ try {
98
+ if (typeof globalThis.localStorage === 'undefined') {
99
+ return null
100
+ }
101
+ const key = '__kora_auth_quickstart_test__'
102
+ globalThis.localStorage.setItem(key, '1')
103
+ globalThis.localStorage.removeItem(key)
104
+ return globalThis.localStorage
105
+ } catch {
106
+ return null
107
+ }
108
+ }
@@ -0,0 +1,94 @@
1
+ import type { AuthTokenStorage } from './auth-client'
2
+
3
+ type MaybePromise<T> = T | Promise<T>
4
+
5
+ /**
6
+ * Minimal key-value credential storage interface used by Kora auth adapters.
7
+ *
8
+ * This intentionally matches the shape of secure stores across runtimes:
9
+ * browser Storage, Tauri secure storage plugins, Expo SecureStore, iOS Keychain,
10
+ * Android Keystore wrappers, and encrypted desktop stores.
11
+ */
12
+ export interface AuthKeyValueStorage {
13
+ getItem(key: string): MaybePromise<string | null>
14
+ setItem(key: string, value: string): MaybePromise<void>
15
+ removeItem(key: string): MaybePromise<void>
16
+ }
17
+
18
+ export interface AuthTokenStorageOptions {
19
+ /** Backing credential store. */
20
+ store: AuthKeyValueStorage
21
+ /** Storage key prefix. Defaults to `kora_auth`. */
22
+ prefix?: string
23
+ }
24
+
25
+ /**
26
+ * Creates an `AuthTokenStorage` adapter from a runtime key-value store.
27
+ *
28
+ * Use this with platform credential stores instead of wiring `AuthClient`
29
+ * directly to browser localStorage in desktop and mobile apps.
30
+ */
31
+ export function createAuthTokenStorage(options: AuthTokenStorageOptions): AuthTokenStorage {
32
+ const prefix = options.prefix ?? 'kora_auth'
33
+ const accessKey = `${prefix}_access_token`
34
+ const refreshKey = `${prefix}_refresh_token`
35
+ const store = options.store
36
+
37
+ return {
38
+ getAccessToken: () => store.getItem(accessKey),
39
+ getRefreshToken: () => store.getItem(refreshKey),
40
+ async setTokens(accessToken: string, refreshToken: string): Promise<void> {
41
+ await store.setItem(accessKey, accessToken)
42
+ await store.setItem(refreshKey, refreshToken)
43
+ },
44
+ async clear(): Promise<void> {
45
+ await store.removeItem(accessKey)
46
+ await store.removeItem(refreshKey)
47
+ },
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Creates an in-memory token storage adapter.
53
+ *
54
+ * Useful for tests, demos, and SSR. Production desktop and mobile apps should
55
+ * prefer a secure platform-backed store.
56
+ */
57
+ export function createMemoryAuthTokenStorage(): AuthTokenStorage {
58
+ let accessToken: string | null = null
59
+ let refreshToken: string | null = null
60
+
61
+ return {
62
+ getAccessToken: () => accessToken,
63
+ getRefreshToken: () => refreshToken,
64
+ setTokens(access: string, refresh: string): void {
65
+ accessToken = access
66
+ refreshToken = refresh
67
+ },
68
+ clear(): void {
69
+ accessToken = null
70
+ refreshToken = null
71
+ },
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Adapts Web Storage-compatible APIs such as `localStorage` or `sessionStorage`.
77
+ */
78
+ export function createWebStorageAuthTokenStorage(
79
+ storage: Storage,
80
+ prefix?: string,
81
+ ): AuthTokenStorage {
82
+ return createAuthTokenStorage({
83
+ prefix,
84
+ store: {
85
+ getItem: (key) => storage.getItem(key),
86
+ setItem: (key, value) => {
87
+ storage.setItem(key, value)
88
+ },
89
+ removeItem: (key) => {
90
+ storage.removeItem(key)
91
+ },
92
+ },
93
+ })
94
+ }
@@ -0,0 +1,330 @@
1
+ import { KoraError } from '@korajs/core'
2
+
3
+ // --- Auth-specific errors ---
4
+
5
+ /**
6
+ * Thrown when the Web Crypto API is not available in the current environment.
7
+ * This can happen in older Node.js versions or SSR environments without crypto support.
8
+ */
9
+ export class CryptoUnavailableError extends KoraError {
10
+ constructor() {
11
+ super(
12
+ 'Web Crypto API (crypto.subtle) is not available in this environment. ' +
13
+ 'Device identity requires crypto.subtle, which is available in modern browsers and Node.js 20+. ' +
14
+ 'If running in SSR, ensure your runtime provides the Web Crypto API.',
15
+ 'CRYPTO_UNAVAILABLE',
16
+ )
17
+ this.name = 'CryptoUnavailableError'
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Thrown when a device identity operation fails (key generation, signing, verification).
23
+ */
24
+ export class DeviceIdentityError extends KoraError {
25
+ constructor(message: string, context?: Record<string, unknown>) {
26
+ super(message, 'DEVICE_IDENTITY_ERROR', context)
27
+ this.name = 'DeviceIdentityError'
28
+ }
29
+ }
30
+
31
+ // --- Encoding helpers ---
32
+
33
+ /**
34
+ * Encodes an ArrayBuffer as a base64url string (no padding).
35
+ *
36
+ * @param buffer - The binary data to encode
37
+ * @returns A base64url-encoded string without padding characters
38
+ */
39
+ export function toBase64Url(buffer: ArrayBuffer): string {
40
+ const bytes = new Uint8Array(buffer)
41
+ let binary = ''
42
+ for (let i = 0; i < bytes.length; i++) {
43
+ binary += String.fromCharCode(bytes[i] as number)
44
+ }
45
+ // Standard base64, then convert to base64url (no padding)
46
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
47
+ }
48
+
49
+ /**
50
+ * Decodes a base64url string (no padding) into a Uint8Array.
51
+ *
52
+ * @param str - A base64url-encoded string (with or without padding)
53
+ * @returns The decoded binary data as a Uint8Array
54
+ */
55
+ export function fromBase64Url(str: string): Uint8Array {
56
+ // Convert base64url back to standard base64
57
+ let base64 = str.replace(/-/g, '+').replace(/_/g, '/')
58
+ // Add padding if necessary
59
+ const paddingNeeded = (4 - (base64.length % 4)) % 4
60
+ base64 += '='.repeat(paddingNeeded)
61
+
62
+ const binary = atob(base64)
63
+ const bytes = new Uint8Array(binary.length)
64
+ for (let i = 0; i < binary.length; i++) {
65
+ bytes[i] = binary.charCodeAt(i)
66
+ }
67
+ return bytes
68
+ }
69
+
70
+ // --- Internal helpers ---
71
+
72
+ /**
73
+ * Asserts that `crypto.subtle` is available, throwing a clear error if not.
74
+ */
75
+ function assertCryptoAvailable(): void {
76
+ if (typeof globalThis.crypto === 'undefined' || typeof globalThis.crypto.subtle === 'undefined') {
77
+ throw new CryptoUnavailableError()
78
+ }
79
+ }
80
+
81
+ /** ECDSA algorithm parameters used throughout the module. */
82
+ const ECDSA_ALGORITHM: EcKeyGenParams = {
83
+ name: 'ECDSA',
84
+ namedCurve: 'P-256',
85
+ }
86
+
87
+ /** Signing algorithm parameters: ECDSA with SHA-256. */
88
+ const ECDSA_SIGN_ALGORITHM: EcdsaParams = {
89
+ name: 'ECDSA',
90
+ hash: { name: 'SHA-256' },
91
+ }
92
+
93
+ // --- Public API ---
94
+
95
+ /**
96
+ * Generates an ECDSA P-256 key pair for device identity.
97
+ *
98
+ * The private key is marked as non-extractable, ensuring it cannot be
99
+ * exported from the browser's crypto subsystem. This provides
100
+ * proof-of-possession: only code running on this device can sign with the key.
101
+ *
102
+ * @returns A CryptoKeyPair containing the public and private ECDSA P-256 keys
103
+ * @throws {CryptoUnavailableError} If `crypto.subtle` is not available
104
+ * @throws {DeviceIdentityError} If key generation fails
105
+ *
106
+ * @example
107
+ * ```typescript
108
+ * const keyPair = await generateDeviceKeyPair()
109
+ * // keyPair.publicKey can be exported; keyPair.privateKey stays on device
110
+ * ```
111
+ */
112
+ export async function generateDeviceKeyPair(): Promise<CryptoKeyPair> {
113
+ assertCryptoAvailable()
114
+
115
+ try {
116
+ const keyPair = await globalThis.crypto.subtle.generateKey(
117
+ ECDSA_ALGORITHM,
118
+ // extractable: false makes the private key non-extractable.
119
+ // The public key is always extractable regardless of this flag.
120
+ false,
121
+ ['sign', 'verify'],
122
+ )
123
+ return keyPair
124
+ } catch (cause) {
125
+ throw new DeviceIdentityError(
126
+ 'Failed to generate ECDSA P-256 device key pair. ' +
127
+ 'Ensure the runtime supports the ECDSA algorithm with the P-256 curve.',
128
+ { cause: cause instanceof Error ? cause.message : String(cause) },
129
+ )
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Exports the public key from a key pair as a JSON Web Key (JWK).
135
+ *
136
+ * The JWK can be safely transmitted to a server or other devices to identify
137
+ * this device. It contains only the public component of the key pair.
138
+ *
139
+ * @param keyPair - The CryptoKeyPair whose public key should be exported
140
+ * @returns The public key in JWK format
141
+ * @throws {CryptoUnavailableError} If `crypto.subtle` is not available
142
+ * @throws {DeviceIdentityError} If the export operation fails
143
+ *
144
+ * @example
145
+ * ```typescript
146
+ * const keyPair = await generateDeviceKeyPair()
147
+ * const jwk = await exportPublicKeyJwk(keyPair)
148
+ * // jwk contains { kty: 'EC', crv: 'P-256', x: '...', y: '...' }
149
+ * ```
150
+ */
151
+ export async function exportPublicKeyJwk(keyPair: CryptoKeyPair): Promise<JsonWebKey> {
152
+ assertCryptoAvailable()
153
+
154
+ try {
155
+ const jwk = await globalThis.crypto.subtle.exportKey('jwk', keyPair.publicKey)
156
+ return jwk
157
+ } catch (cause) {
158
+ throw new DeviceIdentityError(
159
+ 'Failed to export public key as JWK. ' +
160
+ 'The key pair may be invalid or the public key may not support JWK export.',
161
+ { cause: cause instanceof Error ? cause.message : String(cause) },
162
+ )
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Signs a challenge string with the device's private key.
168
+ *
169
+ * Used for proof-of-possession during authentication: the server sends a
170
+ * random challenge, and the device proves it holds the private key by signing it.
171
+ *
172
+ * @param privateKey - The device's private CryptoKey (ECDSA P-256)
173
+ * @param challenge - The challenge string to sign (typically a random nonce from the server)
174
+ * @returns A base64url-encoded ECDSA signature (no padding)
175
+ * @throws {CryptoUnavailableError} If `crypto.subtle` is not available
176
+ * @throws {DeviceIdentityError} If the signing operation fails
177
+ *
178
+ * @example
179
+ * ```typescript
180
+ * const keyPair = await generateDeviceKeyPair()
181
+ * const signature = await signChallenge(keyPair.privateKey, 'server-nonce-abc123')
182
+ * // signature is a base64url string like 'MEUCIQDx...'
183
+ * ```
184
+ */
185
+ export async function signChallenge(privateKey: CryptoKey, challenge: string): Promise<string> {
186
+ assertCryptoAvailable()
187
+
188
+ try {
189
+ const encoded = new TextEncoder().encode(challenge)
190
+ const signatureBuffer = await globalThis.crypto.subtle.sign(
191
+ ECDSA_SIGN_ALGORITHM,
192
+ privateKey,
193
+ encoded,
194
+ )
195
+ return toBase64Url(signatureBuffer)
196
+ } catch (cause) {
197
+ throw new DeviceIdentityError(
198
+ 'Failed to sign challenge. ' +
199
+ 'Ensure the key is a valid ECDSA P-256 private key with "sign" usage.',
200
+ { cause: cause instanceof Error ? cause.message : String(cause) },
201
+ )
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Verifies a challenge signature against a public key.
207
+ *
208
+ * Used server-side (or on any verifying party) to confirm that a device
209
+ * holds the private key corresponding to the given public key.
210
+ *
211
+ * @param publicKeyJwk - The device's public key in JWK format
212
+ * @param challenge - The original challenge string that was signed
213
+ * @param signature - The base64url-encoded signature to verify
214
+ * @returns `true` if the signature is valid, `false` otherwise
215
+ * @throws {CryptoUnavailableError} If `crypto.subtle` is not available
216
+ * @throws {DeviceIdentityError} If the verification operation fails due to an invalid key or format
217
+ *
218
+ * @example
219
+ * ```typescript
220
+ * const isValid = await verifyChallenge(publicKeyJwk, 'server-nonce-abc123', signature)
221
+ * if (isValid) {
222
+ * // Device proved possession of the private key
223
+ * }
224
+ * ```
225
+ */
226
+ export async function verifyChallenge(
227
+ publicKeyJwk: JsonWebKey,
228
+ challenge: string,
229
+ signature: string,
230
+ ): Promise<boolean> {
231
+ assertCryptoAvailable()
232
+
233
+ try {
234
+ const publicKey = await globalThis.crypto.subtle.importKey(
235
+ 'jwk',
236
+ publicKeyJwk,
237
+ ECDSA_ALGORITHM,
238
+ true,
239
+ ['verify'],
240
+ )
241
+
242
+ const encoded = new TextEncoder().encode(challenge)
243
+ const signatureBytes = fromBase64Url(signature)
244
+
245
+ const isValid = await globalThis.crypto.subtle.verify(
246
+ ECDSA_SIGN_ALGORITHM,
247
+ publicKey,
248
+ signatureBytes as unknown as ArrayBuffer,
249
+ encoded,
250
+ )
251
+ return isValid
252
+ } catch (cause) {
253
+ throw new DeviceIdentityError(
254
+ 'Failed to verify challenge signature. ' +
255
+ 'The public key JWK or signature format may be invalid.',
256
+ {
257
+ cause: cause instanceof Error ? cause.message : String(cause),
258
+ publicKeyKty: publicKeyJwk.kty,
259
+ publicKeyCrv: publicKeyJwk.crv,
260
+ },
261
+ )
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Computes a SHA-256 thumbprint of a JWK public key.
267
+ *
268
+ * The thumbprint is computed per RFC 7638: the JWK members required for the key
269
+ * type are serialized in lexicographic order, then hashed with SHA-256. For EC keys
270
+ * (kty: "EC"), the required members are `crv`, `kty`, `x`, and `y`.
271
+ *
272
+ * This thumbprint serves as a compact, stable identifier for the device's public key
273
+ * (used as the `dpk` claim in device credentials).
274
+ *
275
+ * @param publicKeyJwk - The public key in JWK format (must be an EC P-256 key)
276
+ * @returns A base64url-encoded SHA-256 thumbprint (no padding)
277
+ * @throws {CryptoUnavailableError} If `crypto.subtle` is not available
278
+ * @throws {DeviceIdentityError} If the thumbprint computation fails or the JWK is missing required fields
279
+ *
280
+ * @example
281
+ * ```typescript
282
+ * const keyPair = await generateDeviceKeyPair()
283
+ * const jwk = await exportPublicKeyJwk(keyPair)
284
+ * const thumbprint = await computePublicKeyThumbprint(jwk)
285
+ * // thumbprint is a base64url string, e.g., 'NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs'
286
+ * ```
287
+ */
288
+ export async function computePublicKeyThumbprint(publicKeyJwk: JsonWebKey): Promise<string> {
289
+ assertCryptoAvailable()
290
+
291
+ // RFC 7638 requires specific members in lexicographic order for each key type.
292
+ // For EC (kty: "EC"), the required members are: crv, kty, x, y.
293
+ if (publicKeyJwk.kty !== 'EC') {
294
+ throw new DeviceIdentityError(
295
+ `Expected JWK key type "EC" but received "${publicKeyJwk.kty ?? 'undefined'}". Only ECDSA public keys are supported for device identity.`,
296
+ { kty: publicKeyJwk.kty },
297
+ )
298
+ }
299
+
300
+ if (!publicKeyJwk.crv || !publicKeyJwk.x || !publicKeyJwk.y) {
301
+ throw new DeviceIdentityError(
302
+ 'JWK is missing required EC fields. ' +
303
+ 'An EC public key JWK must include "crv", "x", and "y" members.',
304
+ {
305
+ hasCrv: Boolean(publicKeyJwk.crv),
306
+ hasX: Boolean(publicKeyJwk.x),
307
+ hasY: Boolean(publicKeyJwk.y),
308
+ },
309
+ )
310
+ }
311
+
312
+ // Build the canonical JSON with only the required members in lexicographic order.
313
+ // Per RFC 7638, no whitespace, keys in sorted order.
314
+ const canonicalJson = JSON.stringify({
315
+ crv: publicKeyJwk.crv,
316
+ kty: publicKeyJwk.kty,
317
+ x: publicKeyJwk.x,
318
+ y: publicKeyJwk.y,
319
+ })
320
+
321
+ try {
322
+ const encoded = new TextEncoder().encode(canonicalJson)
323
+ const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', encoded)
324
+ return toBase64Url(hashBuffer)
325
+ } catch (cause) {
326
+ throw new DeviceIdentityError('Failed to compute SHA-256 thumbprint of the public key JWK.', {
327
+ cause: cause instanceof Error ? cause.message : String(cause),
328
+ })
329
+ }
330
+ }