@sentropic/auth-hono 0.2.1

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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/dist/contracts.d.ts +64 -0
  4. package/dist/contracts.d.ts.map +1 -0
  5. package/dist/contracts.js +85 -0
  6. package/dist/contracts.js.map +1 -0
  7. package/dist/credential-route-handlers.d.ts +13 -0
  8. package/dist/credential-route-handlers.d.ts.map +1 -0
  9. package/dist/credential-route-handlers.js +102 -0
  10. package/dist/credential-route-handlers.js.map +1 -0
  11. package/dist/email-verification.d.ts +41 -0
  12. package/dist/email-verification.d.ts.map +1 -0
  13. package/dist/email-verification.js +65 -0
  14. package/dist/email-verification.js.map +1 -0
  15. package/dist/index.d.ts +15 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +15 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/magic-link.d.ts +40 -0
  20. package/dist/magic-link.d.ts.map +1 -0
  21. package/dist/magic-link.js +94 -0
  22. package/dist/magic-link.js.map +1 -0
  23. package/dist/middleware.d.ts +25 -0
  24. package/dist/middleware.d.ts.map +1 -0
  25. package/dist/middleware.js +104 -0
  26. package/dist/middleware.js.map +1 -0
  27. package/dist/ports.d.ts +279 -0
  28. package/dist/ports.d.ts.map +1 -0
  29. package/dist/ports.js +2 -0
  30. package/dist/ports.js.map +1 -0
  31. package/dist/route-handlers.d.ts +19 -0
  32. package/dist/route-handlers.d.ts.map +1 -0
  33. package/dist/route-handlers.js +106 -0
  34. package/dist/route-handlers.js.map +1 -0
  35. package/dist/router.d.ts +59 -0
  36. package/dist/router.d.ts.map +1 -0
  37. package/dist/router.js +65 -0
  38. package/dist/router.js.map +1 -0
  39. package/dist/session-route-handlers.d.ts +9 -0
  40. package/dist/session-route-handlers.d.ts.map +1 -0
  41. package/dist/session-route-handlers.js +74 -0
  42. package/dist/session-route-handlers.js.map +1 -0
  43. package/dist/session.d.ts +33 -0
  44. package/dist/session.d.ts.map +1 -0
  45. package/dist/session.js +123 -0
  46. package/dist/session.js.map +1 -0
  47. package/dist/webauthn-authentication-route-handlers.d.ts +26 -0
  48. package/dist/webauthn-authentication-route-handlers.d.ts.map +1 -0
  49. package/dist/webauthn-authentication-route-handlers.js +97 -0
  50. package/dist/webauthn-authentication-route-handlers.js.map +1 -0
  51. package/dist/webauthn-authentication.d.ts +39 -0
  52. package/dist/webauthn-authentication.d.ts.map +1 -0
  53. package/dist/webauthn-authentication.js +110 -0
  54. package/dist/webauthn-authentication.js.map +1 -0
  55. package/dist/webauthn-registration-route-handlers.d.ts +47 -0
  56. package/dist/webauthn-registration-route-handlers.d.ts.map +1 -0
  57. package/dist/webauthn-registration-route-handlers.js +111 -0
  58. package/dist/webauthn-registration-route-handlers.js.map +1 -0
  59. package/dist/webauthn-registration.d.ts +47 -0
  60. package/dist/webauthn-registration.d.ts.map +1 -0
  61. package/dist/webauthn-registration.js +103 -0
  62. package/dist/webauthn-registration.js.map +1 -0
  63. package/package.json +98 -0
  64. package/src/contracts.ts +99 -0
  65. package/src/credential-route-handlers.ts +178 -0
  66. package/src/email-verification.ts +127 -0
  67. package/src/index.ts +14 -0
  68. package/src/magic-link.ts +167 -0
  69. package/src/middleware.ts +178 -0
  70. package/src/ports.ts +289 -0
  71. package/src/route-handlers.ts +182 -0
  72. package/src/router.ts +149 -0
  73. package/src/session-route-handlers.ts +128 -0
  74. package/src/session.ts +201 -0
  75. package/src/webauthn-authentication-route-handlers.ts +200 -0
  76. package/src/webauthn-authentication.ts +211 -0
  77. package/src/webauthn-registration-route-handlers.ts +248 -0
  78. package/src/webauthn-registration.ts +204 -0
@@ -0,0 +1,211 @@
1
+ import {
2
+ generateAuthenticationOptions,
3
+ verifyAuthenticationResponse,
4
+ type VerifiedAuthenticationResponse,
5
+ type WebAuthnCredential,
6
+ } from '@simplewebauthn/server';
7
+ import type {
8
+ AuthenticationResponseJSON,
9
+ AuthenticatorTransportFuture,
10
+ PublicKeyCredentialRequestOptionsJSON,
11
+ UserVerificationRequirement,
12
+ } from '@simplewebauthn/server';
13
+
14
+ import type { AuthHonoCredentialRecord, AuthHonoPorts } from './ports.js';
15
+ import type { AuthHonoRelyingPartyConfig } from './webauthn-registration.js';
16
+
17
+ export interface CreateAuthWebAuthnAuthenticationServiceOptions {
18
+ challengeBytes?: number;
19
+ challengeTtlSeconds?: number;
20
+ ports: AuthHonoPorts;
21
+ rp: AuthHonoRelyingPartyConfig;
22
+ timeoutMs?: number;
23
+ verifyAuthenticationResponse?: typeof verifyAuthenticationResponse;
24
+ }
25
+
26
+ export interface AuthHonoGenerateAuthenticationOptionsInput {
27
+ userId?: string;
28
+ }
29
+
30
+ export interface AuthHonoVerifyAuthenticationInput {
31
+ credential: AuthenticationResponseJSON;
32
+ expectedChallenge: string;
33
+ }
34
+
35
+ export type AuthHonoVerifyAuthenticationResult =
36
+ | {
37
+ credentialId: string;
38
+ userId: string;
39
+ verified: true;
40
+ }
41
+ | {
42
+ error: AuthHonoWebAuthnAuthenticationError;
43
+ verified: false;
44
+ };
45
+
46
+ export interface AuthHonoWebAuthnAuthenticationError {
47
+ code: string;
48
+ message: string;
49
+ status: number;
50
+ }
51
+
52
+ export interface AuthHonoWebAuthnAuthenticationService {
53
+ generateAuthenticationOptions(
54
+ input: AuthHonoGenerateAuthenticationOptionsInput
55
+ ): Promise<PublicKeyCredentialRequestOptionsJSON>;
56
+ verifyAuthentication(input: AuthHonoVerifyAuthenticationInput): Promise<AuthHonoVerifyAuthenticationResult>;
57
+ }
58
+
59
+ export const createAuthWebAuthnAuthenticationService = (
60
+ options: CreateAuthWebAuthnAuthenticationServiceOptions
61
+ ): AuthHonoWebAuthnAuthenticationService => {
62
+ const challengeBytes = options.challengeBytes ?? 32;
63
+ const challengeTtlSeconds = options.challengeTtlSeconds ?? 5 * 60;
64
+ const timeoutMs = options.timeoutMs ?? 5 * 60_000;
65
+ const verifyResponse = options.verifyAuthenticationResponse ?? verifyAuthenticationResponse;
66
+
67
+ return {
68
+ async generateAuthenticationOptions(input) {
69
+ const user = input.userId ? await options.ports.users.findById(input.userId) : null;
70
+ const credentials = input.userId ? await options.ports.credentials.listForUser(input.userId) : [];
71
+ const challenge = options.ports.random.token(challengeBytes);
72
+ const challengeRecord = await options.ports.challenges.create({
73
+ challenge,
74
+ expiresAt: options.ports.clock.addSeconds(options.ports.clock.now(), challengeTtlSeconds),
75
+ type: 'authentication',
76
+ userId: input.userId ?? null,
77
+ });
78
+
79
+ const generatedOptions = await generateAuthenticationOptions({
80
+ allowCredentials:
81
+ credentials.length > 0
82
+ ? credentials.map((credential) => ({
83
+ id: credential.credentialId,
84
+ transports: toAuthenticatorTransports(credential.transports),
85
+ }))
86
+ : undefined,
87
+ challenge: challengeRecord.challenge,
88
+ rpID: options.rp.id,
89
+ timeout: timeoutMs,
90
+ userVerification: resolveUserVerification(options.ports, user),
91
+ });
92
+
93
+ generatedOptions.challenge = challengeRecord.challenge;
94
+ return generatedOptions;
95
+ },
96
+
97
+ async verifyAuthentication(input) {
98
+ const storedCredential = await options.ports.credentials.findByCredentialId(input.credential.id);
99
+
100
+ if (!storedCredential || storedCredential.revokedAt) {
101
+ return invalidAuthentication('credential_not_found', 'Credential was not found.', 401);
102
+ }
103
+
104
+ const challenge = await options.ports.challenges.findValid(
105
+ input.expectedChallenge,
106
+ 'authentication'
107
+ );
108
+
109
+ if (!challenge || challenge.used || challenge.expiresAt <= options.ports.clock.now()) {
110
+ return invalidAuthentication('invalid_or_expired_challenge', 'Authentication challenge is invalid or expired.');
111
+ }
112
+
113
+ if (challenge.userId && challenge.userId !== storedCredential.userId) {
114
+ return invalidAuthentication('challenge_user_mismatch', 'Authentication challenge does not belong to this user.');
115
+ }
116
+
117
+ const user = await options.ports.users.findById(storedCredential.userId);
118
+
119
+ if (!user) {
120
+ return invalidAuthentication('user_not_found', 'Credential user was not found.', 404);
121
+ }
122
+
123
+ const requireUserVerification = resolveUserVerification(options.ports, user) === 'required';
124
+ const verification = await verifyResponse({
125
+ credential: toWebAuthnCredential(storedCredential),
126
+ expectedChallenge: input.expectedChallenge,
127
+ expectedOrigin: options.rp.expectedOrigins,
128
+ expectedRPID: options.rp.id,
129
+ requireUserVerification,
130
+ response: input.credential,
131
+ });
132
+
133
+ if (!verification.verified) {
134
+ return invalidAuthentication('authentication_verification_failed', 'Authentication verification failed.', 401);
135
+ }
136
+
137
+ if (requireUserVerification && !verification.authenticationInfo.userVerified) {
138
+ return invalidAuthentication('user_verification_required', 'User verification is required.', 403);
139
+ }
140
+
141
+ const now = options.ports.clock.now();
142
+ await options.ports.credentials.updateCounter(
143
+ storedCredential.credentialId,
144
+ verification.authenticationInfo.newCounter,
145
+ now
146
+ );
147
+ await options.ports.challenges.markUsed(input.expectedChallenge);
148
+
149
+ return {
150
+ credentialId: storedCredential.credentialId,
151
+ userId: storedCredential.userId,
152
+ verified: true,
153
+ };
154
+ },
155
+ };
156
+ };
157
+
158
+ const toWebAuthnCredential = (credential: AuthHonoCredentialRecord): WebAuthnCredential => ({
159
+ counter: credential.counter,
160
+ id: credential.credentialId,
161
+ publicKey: toUint8Array(credential.publicKey),
162
+ transports: toAuthenticatorTransports(credential.transports),
163
+ });
164
+
165
+ const toAuthenticatorTransports = (
166
+ transports: string[] | null
167
+ ): AuthenticatorTransportFuture[] | undefined => {
168
+ if (!transports || transports.length === 0) {
169
+ return undefined;
170
+ }
171
+
172
+ return transports as AuthenticatorTransportFuture[];
173
+ };
174
+
175
+ const toUint8Array = (value: Uint8Array | ArrayBuffer | string): Uint8Array => {
176
+ if (value instanceof Uint8Array) {
177
+ return value;
178
+ }
179
+
180
+ if (value instanceof ArrayBuffer) {
181
+ return new Uint8Array(value);
182
+ }
183
+
184
+ return new TextEncoder().encode(value);
185
+ };
186
+
187
+ const resolveUserVerification = (
188
+ ports: AuthHonoPorts,
189
+ user: Awaited<ReturnType<AuthHonoPorts['users']['findById']>>
190
+ ): UserVerificationRequirement => {
191
+ if (!user) {
192
+ return 'preferred';
193
+ }
194
+
195
+ return ports.accountPolicy.resolveUserVerification?.(user) ?? 'preferred';
196
+ };
197
+
198
+ const invalidAuthentication = (
199
+ code: string,
200
+ message: string,
201
+ status = 400
202
+ ): AuthHonoVerifyAuthenticationResult => ({
203
+ error: {
204
+ code,
205
+ message,
206
+ status,
207
+ },
208
+ verified: false,
209
+ });
210
+
211
+ export type AuthHonoVerifiedAuthenticationResponse = VerifiedAuthenticationResponse;
@@ -0,0 +1,248 @@
1
+ import type { RegistrationResponseJSON } from '@simplewebauthn/server';
2
+ import type { Context } from 'hono';
3
+ import type { ContentfulStatusCode } from 'hono/utils/http-status';
4
+ import { z } from 'zod';
5
+
6
+ import type {
7
+ AuthHonoGenerateRegistrationOptionsInput,
8
+ AuthHonoWebAuthnRegistrationService,
9
+ } from './webauthn-registration.js';
10
+ import type { AuthHonoRouteHandlers } from './router.js';
11
+
12
+ export interface CreateAuthWebAuthnRegistrationRouteHandlersOptions {
13
+ finalizeRegistration?: AuthHonoFinalizeRegistration;
14
+ prepareRegistrationOptions: AuthHonoPrepareRegistrationOptions;
15
+ resolveRegistrationUser: AuthHonoResolveRegistrationUser;
16
+ service: AuthHonoWebAuthnRegistrationService;
17
+ }
18
+
19
+ export type AuthHonoPrepareRegistrationOptions = (
20
+ input: AuthHonoRegistrationOptionsRequest,
21
+ c: Context
22
+ ) =>
23
+ | Promise<AuthHonoPreparedRegistrationOptions | AuthHonoRouteHandlerError>
24
+ | AuthHonoPreparedRegistrationOptions
25
+ | AuthHonoRouteHandlerError;
26
+
27
+ export type AuthHonoResolveRegistrationUser = (
28
+ input: AuthHonoRegistrationVerifyRequest,
29
+ c: Context
30
+ ) =>
31
+ | Promise<AuthHonoResolvedRegistrationUser | AuthHonoRouteHandlerError>
32
+ | AuthHonoResolvedRegistrationUser
33
+ | AuthHonoRouteHandlerError;
34
+
35
+ export type AuthHonoFinalizeRegistration = (
36
+ result: AuthHonoFinalizeRegistrationInput,
37
+ c: Context
38
+ ) => Response | Promise<Response>;
39
+
40
+ export interface AuthHonoFinalizeRegistrationInput {
41
+ credentialId: string;
42
+ request: AuthHonoRegistrationVerifyRequest;
43
+ userId: string;
44
+ }
45
+
46
+ export interface AuthHonoRegistrationOptionsRequest {
47
+ deviceName?: string;
48
+ email: string;
49
+ verificationToken?: string;
50
+ }
51
+
52
+ export interface AuthHonoRegistrationVerifyRequest {
53
+ credential: RegistrationResponseJSON;
54
+ deviceName?: string;
55
+ email: string;
56
+ userId: string;
57
+ verificationToken: string;
58
+ }
59
+
60
+ export interface AuthHonoPreparedRegistrationOptions {
61
+ serviceInput: AuthHonoGenerateRegistrationOptionsInput;
62
+ userId: string;
63
+ }
64
+
65
+ export interface AuthHonoResolvedRegistrationUser {
66
+ userId: string;
67
+ }
68
+
69
+ export interface AuthHonoRouteHandlerError {
70
+ error: {
71
+ code: string;
72
+ message: string;
73
+ status: ContentfulStatusCode;
74
+ };
75
+ }
76
+
77
+ const isRouteHandlerError = (value: unknown): value is AuthHonoRouteHandlerError =>
78
+ Boolean(
79
+ value &&
80
+ typeof value === 'object' &&
81
+ 'error' in (value as Record<string, unknown>) &&
82
+ typeof (value as { error?: unknown }).error === 'object' &&
83
+ (value as { error?: { code?: unknown } }).error?.code !== undefined
84
+ );
85
+
86
+ const registrationOptionsSchema = z.object({
87
+ deviceName: z.string().max(100).optional(),
88
+ email: z.string().email(),
89
+ verificationToken: z.string().optional(),
90
+ });
91
+
92
+ const registrationVerifySchema = z.object({
93
+ credential: z.custom<RegistrationResponseJSON>(
94
+ (value) => Boolean(value && typeof value === 'object' && 'response' in value)
95
+ ),
96
+ deviceName: z.string().max(100).optional(),
97
+ email: z.string().email(),
98
+ userId: z.string().min(1),
99
+ verificationToken: z.string().min(1),
100
+ });
101
+
102
+ export const createAuthWebAuthnRegistrationRouteHandlers = (
103
+ options: CreateAuthWebAuthnRegistrationRouteHandlersOptions
104
+ ): AuthHonoRouteHandlers => ({
105
+ async createPasskeyRegistrationOptions(c) {
106
+ const input = await parseJson(c, registrationOptionsSchema);
107
+
108
+ if (!input.ok) {
109
+ return invalidRequest(c, input.error);
110
+ }
111
+
112
+ const prepared = await options.prepareRegistrationOptions(input.value, c);
113
+
114
+ if (isRouteHandlerError(prepared)) {
115
+ return simpleError(c, prepared.error.status, prepared.error.code, prepared.error.message);
116
+ }
117
+
118
+ const registrationOptions = await options.service.generateRegistrationOptions(
119
+ prepared.serviceInput
120
+ );
121
+
122
+ return c.json({
123
+ options: registrationOptions,
124
+ userId: prepared.userId,
125
+ });
126
+ },
127
+
128
+ async verifyPasskeyRegistration(c) {
129
+ const input = await parseJson(c, registrationVerifySchema);
130
+
131
+ if (!input.ok) {
132
+ return invalidRequest(c, input.error);
133
+ }
134
+
135
+ const challenge = extractChallenge(input.value.credential);
136
+
137
+ if (!challenge) {
138
+ return simpleError(c, 400, 'invalid_credential', 'Credential challenge is missing or invalid.');
139
+ }
140
+
141
+ const registrationUser = await options.resolveRegistrationUser(input.value, c);
142
+
143
+ if (isRouteHandlerError(registrationUser)) {
144
+ return simpleError(
145
+ c,
146
+ registrationUser.error.status,
147
+ registrationUser.error.code,
148
+ registrationUser.error.message
149
+ );
150
+ }
151
+
152
+ const result = await options.service.verifyRegistration({
153
+ credential: input.value.credential,
154
+ deviceName: input.value.deviceName,
155
+ expectedChallenge: challenge,
156
+ userId: registrationUser.userId,
157
+ });
158
+
159
+ if (!result.verified) {
160
+ return serviceError(c, result.error);
161
+ }
162
+
163
+ if (options.finalizeRegistration) {
164
+ return options.finalizeRegistration(
165
+ {
166
+ credentialId: result.credentialId,
167
+ request: input.value,
168
+ userId: registrationUser.userId,
169
+ },
170
+ c
171
+ );
172
+ }
173
+
174
+ return c.json({
175
+ credentialId: result.credentialId,
176
+ success: true,
177
+ userId: registrationUser.userId,
178
+ });
179
+ },
180
+ });
181
+
182
+ const parseJson = async <T extends z.ZodTypeAny>(
183
+ c: Context,
184
+ schema: T
185
+ ): Promise<{ ok: true; value: z.infer<T> } | { error: z.ZodError; ok: false }> => {
186
+ const body = await c.req.json().catch(() => null);
187
+ const parsed = schema.safeParse(body);
188
+
189
+ if (!parsed.success) {
190
+ return { error: parsed.error, ok: false };
191
+ }
192
+
193
+ return { ok: true, value: parsed.data };
194
+ };
195
+
196
+ const extractChallenge = (credential: RegistrationResponseJSON): string | null => {
197
+ const clientDataJson = credential.response?.clientDataJSON;
198
+
199
+ if (!clientDataJson) {
200
+ return null;
201
+ }
202
+
203
+ try {
204
+ const json = JSON.parse(decodeBase64Url(clientDataJson)) as { challenge?: unknown };
205
+ return typeof json.challenge === 'string' && json.challenge.length > 0 ? json.challenge : null;
206
+ } catch {
207
+ return null;
208
+ }
209
+ };
210
+
211
+ const decodeBase64Url = (value: string): string => {
212
+ const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
213
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
214
+ return atob(padded);
215
+ };
216
+
217
+ const invalidRequest = (c: Context, error: z.ZodError): Response =>
218
+ c.json(
219
+ {
220
+ error: {
221
+ code: 'invalid_input',
222
+ details: error.errors,
223
+ message: 'Invalid request data.',
224
+ },
225
+ },
226
+ 400
227
+ );
228
+
229
+ const serviceError = (
230
+ c: Context,
231
+ error: { code: string; message: string; status: number }
232
+ ): Response => simpleError(c, error.status as ContentfulStatusCode, error.code, error.message);
233
+
234
+ const simpleError = (
235
+ c: Context,
236
+ status: ContentfulStatusCode,
237
+ code: string,
238
+ message: string
239
+ ): Response =>
240
+ c.json(
241
+ {
242
+ error: {
243
+ code,
244
+ message,
245
+ },
246
+ },
247
+ status
248
+ );
@@ -0,0 +1,204 @@
1
+ import {
2
+ generateRegistrationOptions,
3
+ verifyRegistrationResponse,
4
+ type VerifiedRegistrationResponse,
5
+ } from '@simplewebauthn/server';
6
+ import type {
7
+ AttestationConveyancePreference,
8
+ AuthenticatorTransportFuture,
9
+ PublicKeyCredentialCreationOptionsJSON,
10
+ RegistrationResponseJSON,
11
+ UserVerificationRequirement,
12
+ } from '@simplewebauthn/server';
13
+
14
+ import type { AuthHonoPorts } from './ports.js';
15
+
16
+ export interface AuthHonoRelyingPartyConfig {
17
+ expectedOrigins: string[];
18
+ id: string;
19
+ name: string;
20
+ }
21
+
22
+ export interface CreateAuthWebAuthnRegistrationServiceOptions {
23
+ attestation?: AttestationConveyancePreference;
24
+ challengeBytes?: number;
25
+ challengeTtlSeconds?: number;
26
+ ports: AuthHonoPorts;
27
+ rp: AuthHonoRelyingPartyConfig;
28
+ timeoutMs?: number;
29
+ verifyRegistrationResponse?: typeof verifyRegistrationResponse;
30
+ }
31
+
32
+ export interface AuthHonoGenerateRegistrationOptionsInput {
33
+ userDisplayName: string;
34
+ userId?: string;
35
+ userName: string;
36
+ }
37
+
38
+ export interface AuthHonoVerifyRegistrationInput {
39
+ credential: RegistrationResponseJSON;
40
+ deviceName?: string;
41
+ expectedChallenge: string;
42
+ userId: string;
43
+ }
44
+
45
+ export type AuthHonoVerifyRegistrationResult =
46
+ | {
47
+ credentialId: string;
48
+ verified: true;
49
+ }
50
+ | {
51
+ error: AuthHonoWebAuthnRegistrationError;
52
+ verified: false;
53
+ };
54
+
55
+ export interface AuthHonoWebAuthnRegistrationError {
56
+ code: string;
57
+ message: string;
58
+ status: number;
59
+ }
60
+
61
+ export interface AuthHonoWebAuthnRegistrationService {
62
+ generateRegistrationOptions(
63
+ input: AuthHonoGenerateRegistrationOptionsInput
64
+ ): Promise<PublicKeyCredentialCreationOptionsJSON>;
65
+ verifyRegistration(input: AuthHonoVerifyRegistrationInput): Promise<AuthHonoVerifyRegistrationResult>;
66
+ }
67
+
68
+ export const createAuthWebAuthnRegistrationService = (
69
+ options: CreateAuthWebAuthnRegistrationServiceOptions
70
+ ): AuthHonoWebAuthnRegistrationService => {
71
+ const challengeBytes = options.challengeBytes ?? 32;
72
+ const challengeTtlSeconds = options.challengeTtlSeconds ?? 5 * 60;
73
+ const timeoutMs = options.timeoutMs ?? 60_000;
74
+ const verifyResponse = options.verifyRegistrationResponse ?? verifyRegistrationResponse;
75
+
76
+ return {
77
+ async generateRegistrationOptions(input) {
78
+ const user = input.userId ? await options.ports.users.findById(input.userId) : null;
79
+ const existingCredentials = input.userId
80
+ ? await options.ports.credentials.listForUser(input.userId)
81
+ : [];
82
+ const challenge = options.ports.random.token(challengeBytes);
83
+ const challengeRecord = await options.ports.challenges.create({
84
+ challenge,
85
+ expiresAt: options.ports.clock.addSeconds(options.ports.clock.now(), challengeTtlSeconds),
86
+ type: 'registration',
87
+ userId: input.userId ?? null,
88
+ });
89
+
90
+ const generatedOptions = await generateRegistrationOptions({
91
+ attestationType: normalizeAttestation(options.attestation),
92
+ authenticatorSelection: {
93
+ residentKey: 'preferred',
94
+ userVerification: resolveUserVerification(options.ports, user),
95
+ },
96
+ challenge: challengeRecord.challenge,
97
+ excludeCredentials: existingCredentials.map((credential) => ({
98
+ id: credential.credentialId,
99
+ transports: toAuthenticatorTransports(credential.transports),
100
+ })),
101
+ rpID: options.rp.id,
102
+ rpName: options.rp.name,
103
+ timeout: timeoutMs,
104
+ userDisplayName: input.userDisplayName,
105
+ userName: input.userName,
106
+ });
107
+
108
+ generatedOptions.challenge = challengeRecord.challenge;
109
+ return generatedOptions;
110
+ },
111
+
112
+ async verifyRegistration(input) {
113
+ const challenge = await options.ports.challenges.findValid(
114
+ input.expectedChallenge,
115
+ 'registration'
116
+ );
117
+
118
+ if (!challenge || challenge.used || challenge.expiresAt <= options.ports.clock.now()) {
119
+ return invalidRegistration('invalid_or_expired_challenge', 'Registration challenge is invalid or expired.');
120
+ }
121
+
122
+ if (challenge.userId && challenge.userId !== input.userId) {
123
+ return invalidRegistration('challenge_user_mismatch', 'Registration challenge does not belong to this user.');
124
+ }
125
+
126
+ const verification = await verifyResponse({
127
+ expectedChallenge: input.expectedChallenge,
128
+ expectedOrigin: options.rp.expectedOrigins,
129
+ expectedRPID: options.rp.id,
130
+ requireUserVerification: false,
131
+ response: input.credential,
132
+ });
133
+
134
+ if (!verification.verified || !verification.registrationInfo?.credential) {
135
+ return invalidRegistration('registration_verification_failed', 'Registration verification failed.');
136
+ }
137
+
138
+ const credential = verification.registrationInfo.credential;
139
+ const credentialId = credential.id;
140
+ const existing = await options.ports.credentials.findByCredentialId(credentialId);
141
+
142
+ if (existing && !existing.revokedAt) {
143
+ return invalidRegistration('duplicate_credential', 'Credential is already registered.', 409);
144
+ }
145
+
146
+ await options.ports.credentials.create({
147
+ backedUp: verification.registrationInfo.credentialBackedUp,
148
+ counter: credential.counter,
149
+ credentialId,
150
+ deviceType: verification.registrationInfo.credentialDeviceType,
151
+ name: input.deviceName ?? verification.registrationInfo.credentialDeviceType ?? 'Unknown Device',
152
+ publicKey: credential.publicKey,
153
+ transports: input.credential.response.transports ?? [],
154
+ userId: input.userId,
155
+ });
156
+ await options.ports.challenges.markUsed(input.expectedChallenge);
157
+
158
+ return {
159
+ credentialId,
160
+ verified: true,
161
+ };
162
+ },
163
+ };
164
+ };
165
+
166
+ const toAuthenticatorTransports = (
167
+ transports: string[] | null
168
+ ): AuthenticatorTransportFuture[] => (transports ?? []) as AuthenticatorTransportFuture[];
169
+
170
+ const normalizeAttestation = (
171
+ attestation: AttestationConveyancePreference | undefined
172
+ ): 'none' | 'direct' | 'enterprise' | undefined => {
173
+ if (attestation === 'indirect') {
174
+ return 'none';
175
+ }
176
+
177
+ return attestation as 'none' | 'direct' | 'enterprise' | undefined;
178
+ };
179
+
180
+ const resolveUserVerification = (
181
+ ports: AuthHonoPorts,
182
+ user: Awaited<ReturnType<AuthHonoPorts['users']['findById']>>
183
+ ): UserVerificationRequirement => {
184
+ if (!user) {
185
+ return 'preferred';
186
+ }
187
+
188
+ return ports.accountPolicy.resolveUserVerification?.(user) ?? 'preferred';
189
+ };
190
+
191
+ const invalidRegistration = (
192
+ code: string,
193
+ message: string,
194
+ status = 400
195
+ ): AuthHonoVerifyRegistrationResult => ({
196
+ error: {
197
+ code,
198
+ message,
199
+ status,
200
+ },
201
+ verified: false,
202
+ });
203
+
204
+ export type AuthHonoVerifiedRegistrationResponse = VerifiedRegistrationResponse;