11/**
22 * Server-side WebAuthn implementation
3+ *
4+ * Uses @simplewebauthn/server for full WebAuthn verification including:
5+ * - clientDataJSON.type validation ('webauthn.create' / 'webauthn.get')
6+ * - origin verification
7+ * - rpIdHash verification
8+ * - authenticator cryptographic signature verification
9+ * - sign counter checking for clone detection
310 */
4- import type { WebAuthnOptions } from '../types/auth' ;
11+ import type { WebAuthnOptions , StoredCredential } from '../types/auth' ;
512import { generateRandomString } from '../utils' ;
13+ import {
14+ verifyRegistrationResponse ,
15+ verifyAuthenticationResponse ,
16+ type VerifiedRegistrationResponse ,
17+ type VerifiedAuthenticationResponse ,
18+ } from '@simplewebauthn/server' ;
19+ import type {
20+ RegistrationResponseJSON ,
21+ AuthenticationResponseJSON ,
22+ AuthenticatorTransportFuture ,
23+ } from '@simplewebauthn/types' ;
624
725export class WebAuthnServer {
826 private options : WebAuthnOptions ;
927 private challenges : Map < string , { challenge : string ; timestamp : number } > ;
10- private credentials : Map < string , Array < any > > ;
28+ private credentials : Map < string , StoredCredential [ ] > ;
1129
1230 constructor ( options : WebAuthnOptions ) {
31+ if ( ! options . rpId ) {
32+ throw new Error ( 'WebAuthnServer requires rpId to be set' ) ;
33+ }
34+ if ( ! options . origin ) {
35+ throw new Error ( 'WebAuthnServer requires origin to be set for verification' ) ;
36+ }
1337 this . options = options ;
1438 this . challenges = new Map ( ) ;
1539 this . credentials = new Map ( ) ;
@@ -52,11 +76,23 @@ export class WebAuthnServer {
5276 }
5377
5478 /**
55- * Verify a WebAuthn registration response
79+ * Verify a WebAuthn registration response.
80+ *
81+ * Validates:
82+ * - clientDataJSON.type === 'webauthn.create'
83+ * - clientDataJSON.origin matches expected origin
84+ * - clientDataJSON.challenge matches the issued challenge (constant-time)
85+ * - rpIdHash matches SHA-256 of the configured RP ID
86+ * - Authenticator flags (user presence, user verification if required)
87+ * - Attestation statement (if present)
88+ *
89+ * On success, stores the extracted credential public key and counter
90+ * for future authentication verification.
91+ *
5692 * @param userId The user's ID (pubkey)
57- * @param credential The credential from the client
93+ * @param credential The RegistrationResponseJSON from the client
5894 */
59- async verifyRegistration ( userId : string , credential : any ) : Promise < boolean > {
95+ async verifyRegistration ( userId : string , credential : RegistrationResponseJSON ) : Promise < boolean > {
6096 // Check if user already has credentials
6197 if ( this . credentials . has ( userId ) ) {
6298 throw new Error ( 'User already has registered credentials. Please use authentication instead.' ) ;
@@ -67,7 +103,7 @@ export class WebAuthnServer {
67103 throw new Error ( 'No challenge found for user' ) ;
68104 }
69105
70- // Remove the challenge
106+ // Remove the challenge (single-use)
71107 this . challenges . delete ( userId ) ;
72108
73109 // Check if challenge has expired (5 minutes)
@@ -76,33 +112,57 @@ export class WebAuthnServer {
76112 }
77113
78114 try {
79- // TODO: Implement actual credential verification
80- // For now, we'll just verify the challenge matches
81- const clientDataJSON = JSON . parse (
82- Buffer . from ( credential . response . clientDataJSON , 'base64' ) . toString ( )
83- ) ;
84-
85- const isValid = clientDataJSON . challenge === expectedChallenge . challenge ;
86-
87- if ( isValid ) {
88- // Store the credential
89- this . credentials . set ( userId , [ credential ] ) ;
115+ // Use @simplewebauthn /server for full verification.
116+ // This validates: type, origin, rpIdHash, challenge, attestation, flags.
117+ const verification : VerifiedRegistrationResponse = await verifyRegistrationResponse ( {
118+ response : credential ,
119+ expectedChallenge : expectedChallenge . challenge ,
120+ expectedOrigin : Array . isArray ( this . options . origin ) ? this . options . origin : [ this . options . origin ] ,
121+ expectedRPID : this . options . rpId ,
122+ requireUserVerification : this . options . userVerification === 'required' ,
123+ } ) ;
124+
125+ const { verified, registrationInfo } = verification ;
126+
127+ if ( verified && registrationInfo ) {
128+ // Store the credential with its public key for future authentication
129+ const storedCredential : StoredCredential = {
130+ credentialID : Buffer . from ( registrationInfo . credentialID ) . toString ( 'base64url' ) ,
131+ credentialPublicKey : Buffer . from ( registrationInfo . credentialPublicKey ) . toString ( 'base64url' ) ,
132+ counter : registrationInfo . counter ,
133+ credentialBackedUp : registrationInfo . credentialBackedUp ,
134+ credentialDeviceType : registrationInfo . credentialDeviceType ,
135+ transports : credential . response . transports as AuthenticatorTransportFuture [ ] | undefined ,
136+ } ;
137+
138+ this . credentials . set ( userId , [ storedCredential ] ) ;
90139 }
91140
92- return isValid ;
141+ return verified ;
93142 } catch ( error ) {
94- throw new Error ( 'Failed to verify registration' ) ;
143+ const message = error instanceof Error ? error . message : 'Unknown error' ;
144+ throw new Error ( `Failed to verify registration: ${ message } ` ) ;
95145 }
96146 }
97147
98148 /**
99- * Verify a WebAuthn authentication response
149+ * Verify a WebAuthn authentication response.
150+ *
151+ * Validates:
152+ * - clientDataJSON.type === 'webauthn.get'
153+ * - clientDataJSON.origin matches expected origin
154+ * - clientDataJSON.challenge matches the issued challenge (constant-time)
155+ * - rpIdHash matches SHA-256 of the configured RP ID
156+ * - Authenticator signature against the stored credential public key
157+ * - Sign counter to detect cloned authenticators
158+ *
100159 * @param userId The user's ID (pubkey)
101- * @param credential The credential from the client
160+ * @param credential The AuthenticationResponseJSON from the client
102161 */
103- async verifyAuthentication ( userId : string , credential : any ) : Promise < boolean > {
162+ async verifyAuthentication ( userId : string , credential : AuthenticationResponseJSON ) : Promise < boolean > {
104163 // Check if user has registered
105- if ( ! this . credentials . has ( userId ) ) {
164+ const storedCredentials = this . credentials . get ( userId ) ;
165+ if ( ! storedCredentials || storedCredentials . length === 0 ) {
106166 throw new Error ( 'No registered credentials found. Please register first.' ) ;
107167 }
108168
@@ -111,24 +171,70 @@ export class WebAuthnServer {
111171 throw new Error ( 'No challenge found for user' ) ;
112172 }
113173
114- // Remove the challenge
174+ // Remove the challenge (single-use)
115175 this . challenges . delete ( userId ) ;
116176
117177 // Check if challenge has expired (5 minutes)
118178 if ( Date . now ( ) - expectedChallenge . timestamp > 5 * 60 * 1000 ) {
119179 throw new Error ( 'Challenge has expired' ) ;
120180 }
121181
182+ // Find the matching stored credential by credential ID
183+ const matchingCredential = storedCredentials . find (
184+ ( cred ) => cred . credentialID === credential . id
185+ ) ;
186+
187+ if ( ! matchingCredential ) {
188+ throw new Error ( 'Credential not found for this user' ) ;
189+ }
190+
122191 try {
123- // TODO: Implement actual credential verification
124- // For now, we'll just verify the challenge matches
125- const clientDataJSON = JSON . parse (
126- Buffer . from ( credential . response . clientDataJSON , 'base64' ) . toString ( )
127- ) ;
192+ // Use @simplewebauthn /server for full verification.
193+ // This validates: type, origin, rpIdHash, challenge, signature, counter.
194+ const verification : VerifiedAuthenticationResponse = await verifyAuthenticationResponse ( {
195+ response : credential ,
196+ expectedChallenge : expectedChallenge . challenge ,
197+ expectedOrigin : Array . isArray ( this . options . origin ) ? this . options . origin : [ this . options . origin ] ,
198+ expectedRPID : this . options . rpId ,
199+ authenticator : {
200+ credentialID : Buffer . from ( matchingCredential . credentialID , 'base64url' ) ,
201+ credentialPublicKey : Buffer . from ( matchingCredential . credentialPublicKey , 'base64url' ) ,
202+ counter : matchingCredential . counter ,
203+ transports : matchingCredential . transports as AuthenticatorTransportFuture [ ] | undefined ,
204+ } ,
205+ requireUserVerification : this . options . userVerification === 'required' ,
206+ } ) ;
207+
208+ const { verified, authenticationInfo } = verification ;
209+
210+ if ( verified ) {
211+ // Update the sign counter to detect cloned authenticators.
212+ // If the new counter is not greater than the stored counter (and not zero),
213+ // it may indicate a cloned authenticator. @simplewebauthn /server handles
214+ // this check internally and will throw if the counter is suspicious.
215+ matchingCredential . counter = authenticationInfo . newCounter ;
216+ }
128217
129- return clientDataJSON . challenge === expectedChallenge . challenge ;
218+ return verified ;
130219 } catch ( error ) {
131- throw new Error ( 'Failed to verify authentication' ) ;
220+ const message = error instanceof Error ? error . message : 'Unknown error' ;
221+ throw new Error ( `Failed to verify authentication: ${ message } ` ) ;
132222 }
133223 }
224+
225+ /**
226+ * Get stored credentials for a user (useful for generating authentication options)
227+ * @param userId The user's ID (pubkey)
228+ */
229+ getCredentials ( userId : string ) : StoredCredential [ ] | undefined {
230+ return this . credentials . get ( userId ) ;
231+ }
232+
233+ /**
234+ * Remove all credentials for a user
235+ * @param userId The user's ID (pubkey)
236+ */
237+ removeCredentials ( userId : string ) : boolean {
238+ return this . credentials . delete ( userId ) ;
239+ }
134240}
0 commit comments