@_mustachio/openauth 0.6.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/dist/esm/client.js +186 -0
- package/dist/esm/css.d.js +0 -0
- package/dist/esm/error.js +73 -0
- package/dist/esm/index.js +14 -0
- package/dist/esm/issuer.js +558 -0
- package/dist/esm/jwt.js +16 -0
- package/dist/esm/keys.js +113 -0
- package/dist/esm/pkce.js +35 -0
- package/dist/esm/provider/apple.js +28 -0
- package/dist/esm/provider/arctic.js +43 -0
- package/dist/esm/provider/code.js +58 -0
- package/dist/esm/provider/cognito.js +16 -0
- package/dist/esm/provider/discord.js +15 -0
- package/dist/esm/provider/facebook.js +24 -0
- package/dist/esm/provider/github.js +15 -0
- package/dist/esm/provider/google.js +25 -0
- package/dist/esm/provider/index.js +3 -0
- package/dist/esm/provider/jumpcloud.js +15 -0
- package/dist/esm/provider/keycloak.js +15 -0
- package/dist/esm/provider/linkedin.js +15 -0
- package/dist/esm/provider/m2m.js +17 -0
- package/dist/esm/provider/microsoft.js +24 -0
- package/dist/esm/provider/oauth2.js +119 -0
- package/dist/esm/provider/oidc.js +69 -0
- package/dist/esm/provider/passkey.js +315 -0
- package/dist/esm/provider/password.js +306 -0
- package/dist/esm/provider/provider.js +10 -0
- package/dist/esm/provider/slack.js +15 -0
- package/dist/esm/provider/spotify.js +15 -0
- package/dist/esm/provider/twitch.js +15 -0
- package/dist/esm/provider/x.js +16 -0
- package/dist/esm/provider/yahoo.js +15 -0
- package/dist/esm/random.js +27 -0
- package/dist/esm/storage/aws.js +39 -0
- package/dist/esm/storage/cloudflare.js +42 -0
- package/dist/esm/storage/dynamo.js +116 -0
- package/dist/esm/storage/memory.js +88 -0
- package/dist/esm/storage/storage.js +36 -0
- package/dist/esm/subject.js +7 -0
- package/dist/esm/ui/base.js +407 -0
- package/dist/esm/ui/code.js +151 -0
- package/dist/esm/ui/form.js +43 -0
- package/dist/esm/ui/icon.js +92 -0
- package/dist/esm/ui/passkey.js +329 -0
- package/dist/esm/ui/password.js +338 -0
- package/dist/esm/ui/select.js +187 -0
- package/dist/esm/ui/theme.js +115 -0
- package/dist/esm/util.js +54 -0
- package/dist/types/client.d.ts +466 -0
- package/dist/types/client.d.ts.map +1 -0
- package/dist/types/error.d.ts +77 -0
- package/dist/types/error.d.ts.map +1 -0
- package/dist/types/index.d.ts +20 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/issuer.d.ts +465 -0
- package/dist/types/issuer.d.ts.map +1 -0
- package/dist/types/jwt.d.ts +6 -0
- package/dist/types/jwt.d.ts.map +1 -0
- package/dist/types/keys.d.ts +18 -0
- package/dist/types/keys.d.ts.map +1 -0
- package/dist/types/pkce.d.ts +7 -0
- package/dist/types/pkce.d.ts.map +1 -0
- package/dist/types/provider/apple.d.ts +108 -0
- package/dist/types/provider/apple.d.ts.map +1 -0
- package/dist/types/provider/arctic.d.ts +16 -0
- package/dist/types/provider/arctic.d.ts.map +1 -0
- package/dist/types/provider/code.d.ts +74 -0
- package/dist/types/provider/code.d.ts.map +1 -0
- package/dist/types/provider/cognito.d.ts +64 -0
- package/dist/types/provider/cognito.d.ts.map +1 -0
- package/dist/types/provider/discord.d.ts +38 -0
- package/dist/types/provider/discord.d.ts.map +1 -0
- package/dist/types/provider/facebook.d.ts +74 -0
- package/dist/types/provider/facebook.d.ts.map +1 -0
- package/dist/types/provider/github.d.ts +38 -0
- package/dist/types/provider/github.d.ts.map +1 -0
- package/dist/types/provider/google.d.ts +74 -0
- package/dist/types/provider/google.d.ts.map +1 -0
- package/dist/types/provider/index.d.ts +4 -0
- package/dist/types/provider/index.d.ts.map +1 -0
- package/dist/types/provider/jumpcloud.d.ts +38 -0
- package/dist/types/provider/jumpcloud.d.ts.map +1 -0
- package/dist/types/provider/keycloak.d.ts +67 -0
- package/dist/types/provider/keycloak.d.ts.map +1 -0
- package/dist/types/provider/linkedin.d.ts +6 -0
- package/dist/types/provider/linkedin.d.ts.map +1 -0
- package/dist/types/provider/m2m.d.ts +34 -0
- package/dist/types/provider/m2m.d.ts.map +1 -0
- package/dist/types/provider/microsoft.d.ts +89 -0
- package/dist/types/provider/microsoft.d.ts.map +1 -0
- package/dist/types/provider/oauth2.d.ts +133 -0
- package/dist/types/provider/oauth2.d.ts.map +1 -0
- package/dist/types/provider/oidc.d.ts +91 -0
- package/dist/types/provider/oidc.d.ts.map +1 -0
- package/dist/types/provider/passkey.d.ts +143 -0
- package/dist/types/provider/passkey.d.ts.map +1 -0
- package/dist/types/provider/password.d.ts +210 -0
- package/dist/types/provider/password.d.ts.map +1 -0
- package/dist/types/provider/provider.d.ts +29 -0
- package/dist/types/provider/provider.d.ts.map +1 -0
- package/dist/types/provider/slack.d.ts +59 -0
- package/dist/types/provider/slack.d.ts.map +1 -0
- package/dist/types/provider/spotify.d.ts +38 -0
- package/dist/types/provider/spotify.d.ts.map +1 -0
- package/dist/types/provider/twitch.d.ts +38 -0
- package/dist/types/provider/twitch.d.ts.map +1 -0
- package/dist/types/provider/x.d.ts +38 -0
- package/dist/types/provider/x.d.ts.map +1 -0
- package/dist/types/provider/yahoo.d.ts +38 -0
- package/dist/types/provider/yahoo.d.ts.map +1 -0
- package/dist/types/random.d.ts +3 -0
- package/dist/types/random.d.ts.map +1 -0
- package/dist/types/storage/aws.d.ts +4 -0
- package/dist/types/storage/aws.d.ts.map +1 -0
- package/dist/types/storage/cloudflare.d.ts +34 -0
- package/dist/types/storage/cloudflare.d.ts.map +1 -0
- package/dist/types/storage/dynamo.d.ts +65 -0
- package/dist/types/storage/dynamo.d.ts.map +1 -0
- package/dist/types/storage/memory.d.ts +49 -0
- package/dist/types/storage/memory.d.ts.map +1 -0
- package/dist/types/storage/storage.d.ts +15 -0
- package/dist/types/storage/storage.d.ts.map +1 -0
- package/dist/types/subject.d.ts +122 -0
- package/dist/types/subject.d.ts.map +1 -0
- package/dist/types/ui/base.d.ts +5 -0
- package/dist/types/ui/base.d.ts.map +1 -0
- package/dist/types/ui/code.d.ts +104 -0
- package/dist/types/ui/code.d.ts.map +1 -0
- package/dist/types/ui/form.d.ts +6 -0
- package/dist/types/ui/form.d.ts.map +1 -0
- package/dist/types/ui/icon.d.ts +6 -0
- package/dist/types/ui/icon.d.ts.map +1 -0
- package/dist/types/ui/passkey.d.ts +5 -0
- package/dist/types/ui/passkey.d.ts.map +1 -0
- package/dist/types/ui/password.d.ts +139 -0
- package/dist/types/ui/password.d.ts.map +1 -0
- package/dist/types/ui/select.d.ts +55 -0
- package/dist/types/ui/select.d.ts.map +1 -0
- package/dist/types/ui/theme.d.ts +207 -0
- package/dist/types/ui/theme.d.ts.map +1 -0
- package/dist/types/util.d.ts +8 -0
- package/dist/types/util.d.ts.map +1 -0
- package/package.json +51 -0
- package/src/client.ts +749 -0
- package/src/css.d.ts +4 -0
- package/src/error.ts +120 -0
- package/src/index.ts +26 -0
- package/src/issuer.ts +1302 -0
- package/src/jwt.ts +17 -0
- package/src/keys.ts +139 -0
- package/src/pkce.ts +40 -0
- package/src/provider/apple.ts +127 -0
- package/src/provider/arctic.ts +66 -0
- package/src/provider/code.ts +227 -0
- package/src/provider/cognito.ts +74 -0
- package/src/provider/discord.ts +45 -0
- package/src/provider/facebook.ts +84 -0
- package/src/provider/github.ts +45 -0
- package/src/provider/google.ts +85 -0
- package/src/provider/index.ts +3 -0
- package/src/provider/jumpcloud.ts +45 -0
- package/src/provider/keycloak.ts +75 -0
- package/src/provider/linkedin.ts +12 -0
- package/src/provider/m2m.ts +56 -0
- package/src/provider/microsoft.ts +100 -0
- package/src/provider/oauth2.ts +297 -0
- package/src/provider/oidc.ts +179 -0
- package/src/provider/passkey.ts +655 -0
- package/src/provider/password.ts +672 -0
- package/src/provider/provider.ts +33 -0
- package/src/provider/slack.ts +67 -0
- package/src/provider/spotify.ts +45 -0
- package/src/provider/twitch.ts +45 -0
- package/src/provider/x.ts +46 -0
- package/src/provider/yahoo.ts +45 -0
- package/src/random.ts +24 -0
- package/src/storage/aws.ts +59 -0
- package/src/storage/cloudflare.ts +77 -0
- package/src/storage/dynamo.ts +193 -0
- package/src/storage/memory.ts +135 -0
- package/src/storage/storage.ts +46 -0
- package/src/subject.ts +130 -0
- package/src/ui/base.tsx +118 -0
- package/src/ui/code.tsx +215 -0
- package/src/ui/form.tsx +40 -0
- package/src/ui/icon.tsx +95 -0
- package/src/ui/passkey.tsx +321 -0
- package/src/ui/password.tsx +405 -0
- package/src/ui/select.tsx +221 -0
- package/src/ui/theme.ts +319 -0
- package/src/ui/ui.css +252 -0
- package/src/util.ts +58 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configures a provider that supports passkey (WebAuthn) authentication.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { PasskeyProvider } from "@openauthjs/openauth/provider/passkey"
|
|
6
|
+
*
|
|
7
|
+
* export default issuer({
|
|
8
|
+
* providers: {
|
|
9
|
+
* passkey: PasskeyProvider({
|
|
10
|
+
* rpName: "My Application",
|
|
11
|
+
* rpID: "example.com", // optional - can also be passed in as a query parameter (see the UI)
|
|
12
|
+
* origin: "https://example.com", // optional - can also be passed in as a query parameter (see the UI)
|
|
13
|
+
* userCanRegisterPasskey: async (userId, req) => { // optional
|
|
14
|
+
* // Check if the user is allowed to register a passkey
|
|
15
|
+
* return true
|
|
16
|
+
* }
|
|
17
|
+
* })
|
|
18
|
+
* },
|
|
19
|
+
* // ...
|
|
20
|
+
* })
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* PasskeyProvider implements WebAuthn (Web Authentication) to enable passwordless
|
|
24
|
+
* authentication using biometrics, mobile devices, or security keys. It handles
|
|
25
|
+
* the complete flow for registering new passkeys and authenticating with them.
|
|
26
|
+
*
|
|
27
|
+
* The provider requires configuration of:
|
|
28
|
+
* - Relying Party information (rpName, rpID)
|
|
29
|
+
* - Origin validation
|
|
30
|
+
* - UI handlers for authorization and registration
|
|
31
|
+
*
|
|
32
|
+
* It automatically manages:
|
|
33
|
+
* - Challenge generation
|
|
34
|
+
* - Credential storage
|
|
35
|
+
* - Registration verification
|
|
36
|
+
* - Authentication verification
|
|
37
|
+
*
|
|
38
|
+
* This implementation is powered by [@simplewebauthn/server](https://simplewebauthn.dev),
|
|
39
|
+
* which provides the core WebAuthn functionality for passkey authentication.
|
|
40
|
+
*
|
|
41
|
+
* @packageDocumentation
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import type {
|
|
45
|
+
AuthenticatorTransportFuture,
|
|
46
|
+
CredentialDeviceType,
|
|
47
|
+
Base64URLString,
|
|
48
|
+
AuthenticatorSelectionCriteria,
|
|
49
|
+
PublicKeyCredentialCreationOptionsJSON,
|
|
50
|
+
PublicKeyCredentialRequestOptionsJSON,
|
|
51
|
+
RegistrationResponseJSON,
|
|
52
|
+
AuthenticationResponseJSON,
|
|
53
|
+
VerifiedRegistrationResponse,
|
|
54
|
+
} from "@simplewebauthn/server"
|
|
55
|
+
import {
|
|
56
|
+
generateRegistrationOptions,
|
|
57
|
+
verifyRegistrationResponse,
|
|
58
|
+
generateAuthenticationOptions,
|
|
59
|
+
verifyAuthenticationResponse,
|
|
60
|
+
} from "@simplewebauthn/server"
|
|
61
|
+
|
|
62
|
+
import type { Provider, ProviderOptions, ProviderRoute } from "./provider.js"
|
|
63
|
+
import { Storage } from "../storage/storage.js"
|
|
64
|
+
import type { Context } from "hono"
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Converts a Uint8Array to a Base64URL encoded string.
|
|
68
|
+
* This is used to convert binary data for storage in databases or JSON.
|
|
69
|
+
*
|
|
70
|
+
* @param bytes - The Uint8Array to convert
|
|
71
|
+
* @returns Base64URL encoded string
|
|
72
|
+
*/
|
|
73
|
+
function uint8ArrayToBase64Url(bytes: Uint8Array): string {
|
|
74
|
+
let str = ""
|
|
75
|
+
|
|
76
|
+
for (const charCode of bytes) {
|
|
77
|
+
str += String.fromCharCode(charCode)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const base64String = btoa(str)
|
|
81
|
+
|
|
82
|
+
return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Converts a Base64URL encoded string back to a Uint8Array.
|
|
87
|
+
* This is used to convert stored data back to binary format for WebAuthn operations.
|
|
88
|
+
*
|
|
89
|
+
* @param base64urlString - The Base64URL encoded string to convert
|
|
90
|
+
* @returns Uint8Array containing the decoded data
|
|
91
|
+
*/
|
|
92
|
+
function base64UrlToUint8Array(base64urlString: string): Uint8Array {
|
|
93
|
+
// Convert from Base64URL to Base64
|
|
94
|
+
const base64 = base64urlString.replace(/-/g, "+").replace(/_/g, "/")
|
|
95
|
+
/**
|
|
96
|
+
* Pad with '=' until it's a multiple of four
|
|
97
|
+
* (4 - (85 % 4 = 1) = 3) % 4 = 3 padding
|
|
98
|
+
* (4 - (86 % 4 = 2) = 2) % 4 = 2 padding
|
|
99
|
+
* (4 - (87 % 4 = 3) = 1) % 4 = 1 padding
|
|
100
|
+
* (4 - (88 % 4 = 0) = 4) % 4 = 0 padding
|
|
101
|
+
*/
|
|
102
|
+
const padLength = (4 - (base64.length % 4)) % 4
|
|
103
|
+
const padded = base64.padEnd(base64.length + padLength, "=")
|
|
104
|
+
|
|
105
|
+
// Convert to a binary string
|
|
106
|
+
const binary = atob(padded)
|
|
107
|
+
|
|
108
|
+
// Convert binary string to buffer
|
|
109
|
+
const buffer = new ArrayBuffer(binary.length)
|
|
110
|
+
const bytes = new Uint8Array(buffer)
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < binary.length; i++) {
|
|
113
|
+
bytes[i] = binary.charCodeAt(i)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return bytes
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* User model for passkey authentication.
|
|
121
|
+
* Contains the core user data needed for WebAuthn operations.
|
|
122
|
+
*/
|
|
123
|
+
export type UserModel = {
|
|
124
|
+
id: string // User's unique ID (must be stable and unique)
|
|
125
|
+
username: string
|
|
126
|
+
// other user fields...
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Original PasskeyModel structure for in-memory use.
|
|
131
|
+
* Represents a registered credential with public key as Uint8Array.
|
|
132
|
+
*/
|
|
133
|
+
export type PasskeyModel = {
|
|
134
|
+
id: string
|
|
135
|
+
publicKey: Uint8Array
|
|
136
|
+
userId: string // Foreign key to UserModel
|
|
137
|
+
webauthnUserID: string
|
|
138
|
+
counter: number
|
|
139
|
+
deviceType: CredentialDeviceType
|
|
140
|
+
backedUp: boolean
|
|
141
|
+
transports?: AuthenticatorTransportFuture[]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* PasskeyModel version for KV storage with publicKey as string.
|
|
146
|
+
* Used for storing credentials in a key-value store.
|
|
147
|
+
*/
|
|
148
|
+
export type PasskeyModelStored = Omit<PasskeyModel, "publicKey"> & {
|
|
149
|
+
publicKey: string // Stored as Base64URL string
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- Storage Key Definitions ---
|
|
153
|
+
const userKey = (userId: string) => ["passkey", "user", userId]
|
|
154
|
+
const passkeyKey = (userId: string, credentialId: Base64URLString) => [
|
|
155
|
+
"passkey",
|
|
156
|
+
"user",
|
|
157
|
+
userId,
|
|
158
|
+
"credential",
|
|
159
|
+
credentialId,
|
|
160
|
+
"passkey",
|
|
161
|
+
]
|
|
162
|
+
const optionsKey = (userId: string) => ["passkey", "user", userId, "options"]
|
|
163
|
+
const userPasskeysIndexKey = (userId: string) => [
|
|
164
|
+
"passkey",
|
|
165
|
+
"user",
|
|
166
|
+
userId,
|
|
167
|
+
"passkeys",
|
|
168
|
+
] // Stores list of credentialIDs
|
|
169
|
+
|
|
170
|
+
// Configuration
|
|
171
|
+
const DEFAULT_COPY = {
|
|
172
|
+
error_user_not_allowed:
|
|
173
|
+
"There is already an account with this email. Login to add a passkey.",
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Configuration for the PasskeyProvider.
|
|
178
|
+
* Defines how the passkey authentication flow should behave.
|
|
179
|
+
*/
|
|
180
|
+
export interface PasskeyProviderConfig {
|
|
181
|
+
/**
|
|
182
|
+
* Custom authorization handler that generates the UI for authorization.
|
|
183
|
+
*/
|
|
184
|
+
authorize: (req: Request) => Promise<Response>
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Custom registration handler that generates the UI for registration.
|
|
188
|
+
*/
|
|
189
|
+
register: (req: Request) => Promise<Response>
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The human-readable name of the relying party (your application).
|
|
193
|
+
*/
|
|
194
|
+
rpName: string
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The ID of the relying party, typically the domain name without protocol.
|
|
198
|
+
*/
|
|
199
|
+
rpID?: string
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The origin URL(s) that are allowed to initiate WebAuthn ceremonies.
|
|
203
|
+
*/
|
|
204
|
+
origin?: string | string[]
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Optional function to check if a user is allowed to register a passkey.
|
|
208
|
+
*/
|
|
209
|
+
userCanRegisterPasskey?: (userId: string, req: Request) => Promise<boolean>
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Optional WebAuthn authenticator selection criteria.
|
|
213
|
+
*/
|
|
214
|
+
authenticatorSelection?: AuthenticatorSelectionCriteria
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Optional attestation type.
|
|
218
|
+
*/
|
|
219
|
+
attestationType?: "none" | "direct" | "enterprise"
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Optional timeout for challenges in milliseconds.
|
|
223
|
+
*/
|
|
224
|
+
timeout?: number
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Custom copy texts for error messages and UI elements.
|
|
228
|
+
*/
|
|
229
|
+
copy?: Partial<typeof DEFAULT_COPY>
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Creates a passkey (WebAuthn) authentication provider.
|
|
234
|
+
*
|
|
235
|
+
* This provider enables passwordless authentication using biometrics, hardware security
|
|
236
|
+
* keys, or platform authenticators. It implements the Web Authentication (WebAuthn) standard.
|
|
237
|
+
*
|
|
238
|
+
* It handles:
|
|
239
|
+
* - Passkey registration (creating new credentials)
|
|
240
|
+
* - Authentication with existing passkeys
|
|
241
|
+
* - Secure storage of credentials
|
|
242
|
+
* - Challenge verification
|
|
243
|
+
*
|
|
244
|
+
* @param config Configuration options for the passkey provider
|
|
245
|
+
* @returns A Provider instance configured for passkey authentication
|
|
246
|
+
*/
|
|
247
|
+
export function PasskeyProvider(
|
|
248
|
+
config: PasskeyProviderConfig,
|
|
249
|
+
): Provider<{ userId: string; credentialId?: Base64URLString }> {
|
|
250
|
+
const copy = {
|
|
251
|
+
...DEFAULT_COPY,
|
|
252
|
+
...config.copy,
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
type: "passkey",
|
|
256
|
+
init(
|
|
257
|
+
routes: ProviderRoute,
|
|
258
|
+
ctx: ProviderOptions<{
|
|
259
|
+
userId: string
|
|
260
|
+
credentialId?: Base64URLString
|
|
261
|
+
verified: boolean
|
|
262
|
+
}>,
|
|
263
|
+
) {
|
|
264
|
+
const {
|
|
265
|
+
rpName,
|
|
266
|
+
authenticatorSelection,
|
|
267
|
+
attestationType = "none",
|
|
268
|
+
timeout = 5 * 60 * 1000, // 5 minutes in ms for challenge
|
|
269
|
+
} = config
|
|
270
|
+
|
|
271
|
+
// --- Internal Data Access Functions using options.storage ---
|
|
272
|
+
|
|
273
|
+
async function getStoredUserById(
|
|
274
|
+
userId: string,
|
|
275
|
+
): Promise<UserModel | null> {
|
|
276
|
+
return await Storage.get<UserModel>(ctx.storage, userKey(userId))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function saveUser(user: UserModel): Promise<void> {
|
|
280
|
+
await Storage.set(ctx.storage, userKey(user.id), user)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function getStoredPasskeyById(
|
|
284
|
+
userId: string,
|
|
285
|
+
credentialID: Base64URLString,
|
|
286
|
+
): Promise<PasskeyModel | null> {
|
|
287
|
+
const storedPasskey = await Storage.get<PasskeyModelStored>(
|
|
288
|
+
ctx.storage,
|
|
289
|
+
passkeyKey(userId, credentialID),
|
|
290
|
+
)
|
|
291
|
+
if (!storedPasskey) return null
|
|
292
|
+
return {
|
|
293
|
+
...storedPasskey,
|
|
294
|
+
publicKey: base64UrlToUint8Array(storedPasskey.publicKey),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function getStoredUserPasskeys(
|
|
299
|
+
userId: string,
|
|
300
|
+
): Promise<PasskeyModel[]> {
|
|
301
|
+
const passkeyIds =
|
|
302
|
+
(await Storage.get<Base64URLString[]>(
|
|
303
|
+
ctx.storage,
|
|
304
|
+
userPasskeysIndexKey(userId),
|
|
305
|
+
)) || []
|
|
306
|
+
const passkeys: PasskeyModel[] = []
|
|
307
|
+
for (const id of passkeyIds) {
|
|
308
|
+
const pk = await getStoredPasskeyById(userId, id)
|
|
309
|
+
if (pk) passkeys.push(pk)
|
|
310
|
+
}
|
|
311
|
+
return passkeys
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function saveNewStoredPasskey(
|
|
315
|
+
passkeyData: PasskeyModel,
|
|
316
|
+
): Promise<void> {
|
|
317
|
+
const storablePasskey: PasskeyModelStored = {
|
|
318
|
+
...passkeyData,
|
|
319
|
+
publicKey: uint8ArrayToBase64Url(passkeyData.publicKey),
|
|
320
|
+
}
|
|
321
|
+
await Storage.set(
|
|
322
|
+
ctx.storage,
|
|
323
|
+
passkeyKey(passkeyData.userId, passkeyData.id),
|
|
324
|
+
storablePasskey,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
// Update user's passkey index
|
|
328
|
+
const passkeyIds =
|
|
329
|
+
(await Storage.get<Base64URLString[]>(
|
|
330
|
+
ctx.storage,
|
|
331
|
+
userPasskeysIndexKey(passkeyData.userId),
|
|
332
|
+
)) || []
|
|
333
|
+
if (!passkeyIds.includes(passkeyData.id)) {
|
|
334
|
+
passkeyIds.push(passkeyData.id)
|
|
335
|
+
await Storage.set(
|
|
336
|
+
ctx.storage,
|
|
337
|
+
userPasskeysIndexKey(passkeyData.userId),
|
|
338
|
+
passkeyIds,
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function updateStoredPasskeyCounter(
|
|
344
|
+
userId: string,
|
|
345
|
+
credentialID: Base64URLString,
|
|
346
|
+
newCounter: number,
|
|
347
|
+
): Promise<void> {
|
|
348
|
+
const passkey = await getStoredPasskeyById(userId, credentialID)
|
|
349
|
+
if (passkey) {
|
|
350
|
+
passkey.counter = newCounter
|
|
351
|
+
const storablePasskey: PasskeyModelStored = {
|
|
352
|
+
...passkey,
|
|
353
|
+
publicKey: uint8ArrayToBase64Url(passkey.publicKey),
|
|
354
|
+
}
|
|
355
|
+
await Storage.set(
|
|
356
|
+
ctx.storage,
|
|
357
|
+
passkeyKey(userId, credentialID),
|
|
358
|
+
storablePasskey,
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
routes.get("/authorize", async (c) => {
|
|
364
|
+
return ctx.forward(c, await config.authorize(c.req.raw))
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
routes.get("/register", async (c) => {
|
|
368
|
+
return ctx.forward(c, await config.register(c.req.raw))
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
// --- REGISTRATION FLOW ---
|
|
372
|
+
routes.get("/register-request", async (c: Context) => {
|
|
373
|
+
const userId = c.req.query("userId")
|
|
374
|
+
const rpID = config.rpID || c.req.query("rpID")
|
|
375
|
+
const otherDevice = c.req.query("otherDevice") === "true"
|
|
376
|
+
|
|
377
|
+
if (!userId) {
|
|
378
|
+
return c.json({ error: "User ID for registration is required." }, 400)
|
|
379
|
+
}
|
|
380
|
+
if (!rpID) {
|
|
381
|
+
return c.json({ error: "RP ID for registration is required." }, 400)
|
|
382
|
+
}
|
|
383
|
+
const username = c.req.query("username") || userId
|
|
384
|
+
|
|
385
|
+
let user = await getStoredUserById(userId)
|
|
386
|
+
|
|
387
|
+
if (config.userCanRegisterPasskey) {
|
|
388
|
+
const isAllowed = await config.userCanRegisterPasskey(
|
|
389
|
+
userId,
|
|
390
|
+
c.req.raw,
|
|
391
|
+
)
|
|
392
|
+
if (!isAllowed) {
|
|
393
|
+
return c.json(
|
|
394
|
+
{
|
|
395
|
+
error: copy.error_user_not_allowed,
|
|
396
|
+
},
|
|
397
|
+
403,
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// If user does not exist, you might create them here or expect them to be pre-registered
|
|
402
|
+
if (!user) {
|
|
403
|
+
user = { id: userId, username }
|
|
404
|
+
await saveUser(user)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const userPasskeys = await getStoredUserPasskeys(user.id)
|
|
408
|
+
|
|
409
|
+
const regOptions: PublicKeyCredentialCreationOptionsJSON =
|
|
410
|
+
await generateRegistrationOptions({
|
|
411
|
+
rpName,
|
|
412
|
+
rpID,
|
|
413
|
+
userName: user.username,
|
|
414
|
+
attestationType,
|
|
415
|
+
excludeCredentials: userPasskeys.map((pk) => ({
|
|
416
|
+
id: pk.id,
|
|
417
|
+
transports: pk.transports,
|
|
418
|
+
})),
|
|
419
|
+
authenticatorSelection: authenticatorSelection ?? {
|
|
420
|
+
residentKey: "preferred",
|
|
421
|
+
userVerification: "preferred",
|
|
422
|
+
authenticatorAttachment: otherDevice
|
|
423
|
+
? "cross-platform"
|
|
424
|
+
: "platform",
|
|
425
|
+
},
|
|
426
|
+
timeout,
|
|
427
|
+
})
|
|
428
|
+
await Storage.set(ctx.storage, optionsKey(user.id), regOptions)
|
|
429
|
+
return c.json(regOptions)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
routes.post("/register-verify", async (c: Context) => {
|
|
433
|
+
const body: RegistrationResponseJSON = await c.req.json()
|
|
434
|
+
|
|
435
|
+
const { userId } = c.req.query() as { userId: string }
|
|
436
|
+
const rpID = config.rpID || c.req.query("rpID")
|
|
437
|
+
const origin = config.origin || c.req.query("origin")
|
|
438
|
+
if (!userId) {
|
|
439
|
+
return c.json(
|
|
440
|
+
{
|
|
441
|
+
verified: false,
|
|
442
|
+
error: "User ID for verification is required.",
|
|
443
|
+
},
|
|
444
|
+
400,
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
if (!rpID) {
|
|
448
|
+
return c.json({ error: "RP ID for verification is required." }, 400)
|
|
449
|
+
}
|
|
450
|
+
if (!origin) {
|
|
451
|
+
return c.json({ error: "Origin for verification is required." }, 400)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const user = await getStoredUserById(userId)
|
|
455
|
+
if (!user) {
|
|
456
|
+
return c.json(
|
|
457
|
+
{ verified: false, error: "User not found during verification." },
|
|
458
|
+
404,
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
const regOptions =
|
|
462
|
+
await Storage.get<PublicKeyCredentialCreationOptionsJSON>(
|
|
463
|
+
ctx.storage,
|
|
464
|
+
optionsKey(user.id),
|
|
465
|
+
)
|
|
466
|
+
if (!regOptions) {
|
|
467
|
+
return c.json(
|
|
468
|
+
{ verified: false, error: "Registration options not found." },
|
|
469
|
+
400,
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
const challenge = regOptions.challenge
|
|
473
|
+
|
|
474
|
+
let verification: VerifiedRegistrationResponse
|
|
475
|
+
try {
|
|
476
|
+
verification = await verifyRegistrationResponse({
|
|
477
|
+
response: body,
|
|
478
|
+
expectedChallenge: challenge,
|
|
479
|
+
expectedOrigin: origin,
|
|
480
|
+
expectedRPID: rpID,
|
|
481
|
+
requireUserVerification:
|
|
482
|
+
authenticatorSelection?.userVerification !== "discouraged",
|
|
483
|
+
})
|
|
484
|
+
} catch (error: any) {
|
|
485
|
+
console.error("Passkey Registration Verification Error:", error)
|
|
486
|
+
return c.json({ verified: false, error: error.message }, 400)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const { verified, registrationInfo } = verification
|
|
490
|
+
|
|
491
|
+
if (verified && registrationInfo) {
|
|
492
|
+
const { credential, credentialDeviceType, credentialBackedUp } =
|
|
493
|
+
registrationInfo
|
|
494
|
+
|
|
495
|
+
if (credential) {
|
|
496
|
+
const newPasskey: PasskeyModel = {
|
|
497
|
+
id: credential.id,
|
|
498
|
+
userId: user.id,
|
|
499
|
+
webauthnUserID: regOptions.user.id,
|
|
500
|
+
publicKey: credential.publicKey,
|
|
501
|
+
counter: credential.counter,
|
|
502
|
+
transports: credential.transports,
|
|
503
|
+
deviceType: credentialDeviceType,
|
|
504
|
+
backedUp: credentialBackedUp,
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
await saveNewStoredPasskey(newPasskey)
|
|
508
|
+
|
|
509
|
+
return ctx.success(c, {
|
|
510
|
+
userId: user.id,
|
|
511
|
+
credentialId: newPasskey.id,
|
|
512
|
+
verified: true,
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return c.json(
|
|
517
|
+
{ verified: false, error: "Registration verification failed." },
|
|
518
|
+
400,
|
|
519
|
+
)
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
// --- AUTHENTICATION FLOW ---
|
|
523
|
+
routes.get("/authenticate-options", async (c: Context) => {
|
|
524
|
+
const { userId } = c.req.query() as { userId?: string }
|
|
525
|
+
if (!userId) {
|
|
526
|
+
return c.json(
|
|
527
|
+
{ error: "User ID for authentication is required." },
|
|
528
|
+
400,
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
const rpID = config.rpID || c.req.query("rpID")
|
|
532
|
+
if (!rpID) {
|
|
533
|
+
return c.json({ error: "RP ID for authentication is required." }, 400)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const userForAuth = await getStoredUserById(userId)
|
|
537
|
+
if (!userForAuth) {
|
|
538
|
+
return c.json({ error: "User not found for authentication." }, 404)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const userPasskeys = await getStoredUserPasskeys(userForAuth.id)
|
|
542
|
+
const allowCredentialsList = userPasskeys.map((pk) => ({
|
|
543
|
+
id: pk.id,
|
|
544
|
+
transports: pk.transports,
|
|
545
|
+
}))
|
|
546
|
+
|
|
547
|
+
const authOptions: PublicKeyCredentialRequestOptionsJSON =
|
|
548
|
+
await generateAuthenticationOptions({
|
|
549
|
+
rpID,
|
|
550
|
+
allowCredentials: allowCredentialsList,
|
|
551
|
+
userVerification:
|
|
552
|
+
authenticatorSelection?.userVerification ?? "preferred",
|
|
553
|
+
timeout,
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
await Storage.set(ctx.storage, optionsKey(userForAuth.id), authOptions)
|
|
557
|
+
return c.json(authOptions)
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
routes.post("/authenticate-verify", async (c: Context) => {
|
|
561
|
+
const body: AuthenticationResponseJSON = await c.req.json()
|
|
562
|
+
const { userId } = c.req.query() as { userId?: string }
|
|
563
|
+
if (!userId) {
|
|
564
|
+
return c.json(
|
|
565
|
+
{ error: "User ID for authentication is required." },
|
|
566
|
+
400,
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
const rpID = config.rpID || c.req.query("rpID")
|
|
570
|
+
if (!rpID) {
|
|
571
|
+
return c.json({ error: "RP ID for authentication is required." }, 400)
|
|
572
|
+
}
|
|
573
|
+
const origin = config.origin || c.req.query("origin")
|
|
574
|
+
if (!origin) {
|
|
575
|
+
return c.json(
|
|
576
|
+
{ error: "Origin for authentication is required." },
|
|
577
|
+
400,
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const user = await getStoredUserById(userId)
|
|
582
|
+
if (!user) {
|
|
583
|
+
return c.json(
|
|
584
|
+
{ verified: false, error: `User ${userId} not found.` },
|
|
585
|
+
404,
|
|
586
|
+
)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const authOptions =
|
|
590
|
+
await Storage.get<PublicKeyCredentialRequestOptionsJSON>(
|
|
591
|
+
ctx.storage,
|
|
592
|
+
optionsKey(user.id),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
if (!authOptions) {
|
|
596
|
+
return c.json({ error: "Authentication options not found." }, 400)
|
|
597
|
+
}
|
|
598
|
+
const passkey = await getStoredPasskeyById(userId, body.id)
|
|
599
|
+
|
|
600
|
+
if (!passkey) {
|
|
601
|
+
return c.json(
|
|
602
|
+
{
|
|
603
|
+
verified: false,
|
|
604
|
+
error: `Passkey ${body.id} not found for user ${user.username}.`,
|
|
605
|
+
},
|
|
606
|
+
400,
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const { publicKey, counter, transports } = passkey
|
|
611
|
+
|
|
612
|
+
if (!publicKey || typeof counter !== "number" || !transports) {
|
|
613
|
+
return c.json({ error: "Passkey not found for authentication." }, 400)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const challenge = authOptions.challenge
|
|
617
|
+
if (!challenge) {
|
|
618
|
+
return c.json({ error: "Authentication challenge not found." }, 400)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const verification = await verifyAuthenticationResponse({
|
|
622
|
+
response: body,
|
|
623
|
+
expectedChallenge: challenge,
|
|
624
|
+
expectedOrigin: origin || "",
|
|
625
|
+
expectedRPID: rpID,
|
|
626
|
+
credential: {
|
|
627
|
+
id: passkey.id,
|
|
628
|
+
publicKey: publicKey,
|
|
629
|
+
counter: counter,
|
|
630
|
+
transports: transports,
|
|
631
|
+
},
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
const { verified, authenticationInfo } = verification
|
|
635
|
+
|
|
636
|
+
if (verified) {
|
|
637
|
+
await updateStoredPasskeyCounter(
|
|
638
|
+
user.id,
|
|
639
|
+
passkey.id,
|
|
640
|
+
authenticationInfo.newCounter,
|
|
641
|
+
)
|
|
642
|
+
return ctx.success(c, {
|
|
643
|
+
userId: user.id,
|
|
644
|
+
credentialId: passkey.id,
|
|
645
|
+
verified: true,
|
|
646
|
+
})
|
|
647
|
+
}
|
|
648
|
+
return c.json(
|
|
649
|
+
{ verified: false, error: "Authentication verification failed." },
|
|
650
|
+
400,
|
|
651
|
+
)
|
|
652
|
+
})
|
|
653
|
+
},
|
|
654
|
+
}
|
|
655
|
+
}
|