@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.13
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 +52 -47
- package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
- package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
- package/dist/index.cjs +645 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -9
- package/dist/index.d.ts +27 -9
- package/dist/index.js +644 -150
- package/dist/index.js.map +1 -1
- package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
- package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/server.cjs +2852 -1675
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +779 -168
- package/dist/server.d.ts +779 -168
- package/dist/server.js +2831 -1665
- package/dist/server.js.map +1 -1
- package/dist/svelte.cjs +2 -2
- package/dist/svelte.cjs.map +1 -1
- package/dist/svelte.d.cts +2 -2
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +2 -2
- package/dist/svelte.js.map +1 -1
- package/dist/vue.d.cts +1 -1
- package/dist/vue.d.ts +1 -1
- package/package.json +7 -7
- package/src/admin/admin-api.ts +327 -0
- package/src/admin/audit-log.ts +324 -0
- package/src/admin/webhooks.ts +576 -0
- package/src/bindings/create-auth-session.ts +184 -0
- package/src/bindings/create-org-session.ts +130 -0
- package/src/client/auth-client.ts +1592 -0
- package/src/client/auth-sync.ts +213 -0
- package/src/client/device-session.ts +104 -0
- package/src/client/org-client.ts +399 -0
- package/src/client/quickstart.ts +108 -0
- package/src/client/storage.ts +94 -0
- package/src/device/device-identity.ts +330 -0
- package/src/device/device-store.ts +379 -0
- package/src/encryption/auto-lock.ts +170 -0
- package/src/encryption/database-encryption.ts +265 -0
- package/src/encryption/key-derivation.ts +149 -0
- package/src/encryption/operation-encryptor.ts +361 -0
- package/src/index.ts +132 -0
- package/src/mfa/totp.ts +826 -0
- package/src/org/org-routes.ts +758 -0
- package/src/org/org-store.ts +490 -0
- package/src/org/org-types.ts +230 -0
- package/src/passkey/passkey-client.ts +597 -0
- package/src/passkey/passkey-server.ts +779 -0
- package/src/postgres/ensure-schema.ts +65 -0
- package/src/provider/adapter.ts +246 -0
- package/src/provider/built-in/auth-routes.ts +1313 -0
- package/src/provider/built-in/email-verification.ts +303 -0
- package/src/provider/built-in/password-hash.ts +118 -0
- package/src/provider/built-in/password-reset.ts +416 -0
- package/src/provider/built-in/postgres-user-store.ts +328 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +322 -0
- package/src/provider/built-in/sync-scopes.ts +85 -0
- package/src/provider/built-in/user-store.ts +465 -0
- package/src/provider/external/clerk-adapter.ts +157 -0
- package/src/provider/external/external-jwt-provider.ts +491 -0
- package/src/provider/external/supabase-adapter.ts +163 -0
- package/src/provider/oauth/linked-identity-store.ts +108 -0
- package/src/provider/oauth/oauth-flow.ts +550 -0
- package/src/provider/oauth/oauth-types.ts +184 -0
- package/src/provider/oauth/postgres-oauth-store.ts +296 -0
- package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
- package/src/rbac/rbac-engine.ts +323 -0
- package/src/rbac/rbac-types.ts +210 -0
- package/src/rbac/scope-resolver.ts +140 -0
- package/src/react/AuthProvider.tsx +97 -0
- package/src/react/OrgProvider.tsx +41 -0
- package/src/react/auth-context.ts +26 -0
- package/src/react/hooks.ts +110 -0
- package/src/react/org-hooks.ts +214 -0
- package/src/react.ts +26 -0
- package/src/server.ts +334 -0
- package/src/session/session.ts +401 -0
- package/src/svelte/auth-context.ts +50 -0
- package/src/svelte/org-context.ts +32 -0
- package/src/svelte/org-hooks.ts +201 -0
- package/src/svelte/use-auth.ts +115 -0
- package/src/svelte.ts +25 -0
- package/src/tokens/encrypted-token-store.ts +360 -0
- package/src/tokens/jwt.ts +236 -0
- package/src/tokens/postgres-token-revocation-store.ts +140 -0
- package/src/tokens/sqlite-token-revocation-store.ts +121 -0
- package/src/tokens/token-manager.ts +821 -0
- package/src/tokens/token-store.ts +192 -0
- package/src/types.ts +394 -0
- package/src/vue/auth-context.ts +10 -0
- package/src/vue/auth-provider-types.ts +5 -0
- package/src/vue/auth-provider.ts +76 -0
- package/src/vue/org-hooks.ts +193 -0
- package/src/vue/org-provider.ts +49 -0
- package/src/vue/use-auth.ts +139 -0
- 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
|
+
}
|