@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.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/contracts.d.ts +64 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +85 -0
- package/dist/contracts.js.map +1 -0
- package/dist/credential-route-handlers.d.ts +13 -0
- package/dist/credential-route-handlers.d.ts.map +1 -0
- package/dist/credential-route-handlers.js +102 -0
- package/dist/credential-route-handlers.js.map +1 -0
- package/dist/email-verification.d.ts +41 -0
- package/dist/email-verification.d.ts.map +1 -0
- package/dist/email-verification.js +65 -0
- package/dist/email-verification.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/magic-link.d.ts +40 -0
- package/dist/magic-link.d.ts.map +1 -0
- package/dist/magic-link.js +94 -0
- package/dist/magic-link.js.map +1 -0
- package/dist/middleware.d.ts +25 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +104 -0
- package/dist/middleware.js.map +1 -0
- package/dist/ports.d.ts +279 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +2 -0
- package/dist/ports.js.map +1 -0
- package/dist/route-handlers.d.ts +19 -0
- package/dist/route-handlers.d.ts.map +1 -0
- package/dist/route-handlers.js +106 -0
- package/dist/route-handlers.js.map +1 -0
- package/dist/router.d.ts +59 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +65 -0
- package/dist/router.js.map +1 -0
- package/dist/session-route-handlers.d.ts +9 -0
- package/dist/session-route-handlers.d.ts.map +1 -0
- package/dist/session-route-handlers.js +74 -0
- package/dist/session-route-handlers.js.map +1 -0
- package/dist/session.d.ts +33 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +123 -0
- package/dist/session.js.map +1 -0
- package/dist/webauthn-authentication-route-handlers.d.ts +26 -0
- package/dist/webauthn-authentication-route-handlers.d.ts.map +1 -0
- package/dist/webauthn-authentication-route-handlers.js +97 -0
- package/dist/webauthn-authentication-route-handlers.js.map +1 -0
- package/dist/webauthn-authentication.d.ts +39 -0
- package/dist/webauthn-authentication.d.ts.map +1 -0
- package/dist/webauthn-authentication.js +110 -0
- package/dist/webauthn-authentication.js.map +1 -0
- package/dist/webauthn-registration-route-handlers.d.ts +47 -0
- package/dist/webauthn-registration-route-handlers.d.ts.map +1 -0
- package/dist/webauthn-registration-route-handlers.js +111 -0
- package/dist/webauthn-registration-route-handlers.js.map +1 -0
- package/dist/webauthn-registration.d.ts +47 -0
- package/dist/webauthn-registration.d.ts.map +1 -0
- package/dist/webauthn-registration.js +103 -0
- package/dist/webauthn-registration.js.map +1 -0
- package/package.json +98 -0
- package/src/contracts.ts +99 -0
- package/src/credential-route-handlers.ts +178 -0
- package/src/email-verification.ts +127 -0
- package/src/index.ts +14 -0
- package/src/magic-link.ts +167 -0
- package/src/middleware.ts +178 -0
- package/src/ports.ts +289 -0
- package/src/route-handlers.ts +182 -0
- package/src/router.ts +149 -0
- package/src/session-route-handlers.ts +128 -0
- package/src/session.ts +201 -0
- package/src/webauthn-authentication-route-handlers.ts +200 -0
- package/src/webauthn-authentication.ts +211 -0
- package/src/webauthn-registration-route-handlers.ts +248 -0
- 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;
|