@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
package/src/router.ts ADDED
@@ -0,0 +1,149 @@
1
+ import { Hono, type Context } from 'hono';
2
+
3
+ import {
4
+ AUTH_HONO_ROUTE_MAP,
5
+ type AuthHonoAuthUiMethod,
6
+ type AuthHonoRouteContract,
7
+ } from './contracts.js';
8
+ import type { AuthHonoPorts } from './ports.js';
9
+
10
+ export interface CreateAuthRouterOptions {
11
+ cookieNames?: AuthHonoCookieNames;
12
+ emailCode?: AuthHonoEmailCodePolicy;
13
+ handlers?: AuthHonoRouteHandlers;
14
+ magicLink?: AuthHonoMagicLinkPolicy;
15
+ ports?: AuthHonoPorts;
16
+ responsePolicy?: AuthHonoResponsePolicy;
17
+ routeOverrides?: Partial<Record<AuthHonoAuthUiMethod, AuthHonoRouteContract>>;
18
+ routePrefix?: string;
19
+ rp?: AuthHonoRelyingPartyOptions;
20
+ session?: AuthHonoSessionPolicy;
21
+ serviceName?: string;
22
+ }
23
+
24
+ export interface AuthHonoCookieNames {
25
+ session: string;
26
+ refresh: string;
27
+ }
28
+
29
+ export interface AuthHonoRelyingPartyOptions {
30
+ id: string;
31
+ name: string;
32
+ expectedOrigins: string[];
33
+ }
34
+
35
+ export interface AuthHonoSessionPolicy {
36
+ sessionTtlSeconds: number;
37
+ refreshTtlSeconds: number;
38
+ }
39
+
40
+ export interface AuthHonoEmailCodePolicy {
41
+ length: number;
42
+ ttlSeconds: number;
43
+ maxRequestsPerWindow: number;
44
+ windowSeconds: number;
45
+ }
46
+
47
+ export interface AuthHonoMagicLinkPolicy {
48
+ ttlSeconds: number;
49
+ baseUrl?: string;
50
+ }
51
+
52
+ export interface AuthHonoResponsePolicy {
53
+ notImplementedCode: string;
54
+ notImplementedMessage: string;
55
+ }
56
+
57
+ export interface AuthHonoRouterConfig {
58
+ cookieNames: AuthHonoCookieNames;
59
+ emailCode: AuthHonoEmailCodePolicy;
60
+ magicLink: AuthHonoMagicLinkPolicy;
61
+ responsePolicy: AuthHonoResponsePolicy;
62
+ routePrefix: string;
63
+ routes: Record<AuthHonoAuthUiMethod, AuthHonoRouteContract>;
64
+ rp?: AuthHonoRelyingPartyOptions;
65
+ session: AuthHonoSessionPolicy;
66
+ serviceName: string;
67
+ }
68
+
69
+ export type AuthHonoRouteHandler = (c: Context) => Response | Promise<Response>;
70
+
71
+ export type AuthHonoRouteHandlers = Partial<Record<AuthHonoAuthUiMethod, AuthHonoRouteHandler>>;
72
+
73
+ export const createAuthHonoConfig = (options: CreateAuthRouterOptions = {}): AuthHonoRouterConfig => ({
74
+ cookieNames: {
75
+ session: options.cookieNames?.session ?? 'session',
76
+ refresh: options.cookieNames?.refresh ?? 'refreshToken',
77
+ },
78
+ emailCode: {
79
+ length: options.emailCode?.length ?? 6,
80
+ ttlSeconds: options.emailCode?.ttlSeconds ?? 10 * 60,
81
+ maxRequestsPerWindow: options.emailCode?.maxRequestsPerWindow ?? 3,
82
+ windowSeconds: options.emailCode?.windowSeconds ?? 10 * 60,
83
+ },
84
+ magicLink: {
85
+ ttlSeconds: options.magicLink?.ttlSeconds ?? 10 * 60,
86
+ baseUrl: options.magicLink?.baseUrl,
87
+ },
88
+ responsePolicy: {
89
+ notImplementedCode: options.responsePolicy?.notImplementedCode ?? 'not_implemented',
90
+ notImplementedMessage:
91
+ options.responsePolicy?.notImplementedMessage ?? 'Auth route handler is not implemented yet.',
92
+ },
93
+ routePrefix: normalizeRoutePrefix(options.routePrefix),
94
+ routes: {
95
+ ...AUTH_HONO_ROUTE_MAP,
96
+ ...options.routeOverrides,
97
+ },
98
+ rp: options.rp,
99
+ session: {
100
+ sessionTtlSeconds: options.session?.sessionTtlSeconds ?? 7 * 24 * 60 * 60,
101
+ refreshTtlSeconds: options.session?.refreshTtlSeconds ?? 30 * 24 * 60 * 60,
102
+ },
103
+ serviceName: options.serviceName ?? 'auth',
104
+ });
105
+
106
+ export const createAuthRouter = (options: CreateAuthRouterOptions = {}): Hono => {
107
+ const router = new Hono();
108
+ const config = createAuthHonoConfig(options);
109
+
110
+ router.get(joinRoutePath(config.routePrefix, '/health'), (c) =>
111
+ c.json({ status: 'ok', service: config.serviceName })
112
+ );
113
+
114
+ for (const routeName of Object.keys(config.routes) as AuthHonoAuthUiMethod[]) {
115
+ const contract = config.routes[routeName];
116
+ const handler = options.handlers?.[routeName];
117
+
118
+ router.on(contract.method, joinRoutePath(config.routePrefix, contract.path), (c) => {
119
+ if (handler) {
120
+ return handler(c);
121
+ }
122
+
123
+ return c.json(
124
+ {
125
+ error: {
126
+ code: config.responsePolicy.notImplementedCode,
127
+ message: config.responsePolicy.notImplementedMessage,
128
+ },
129
+ },
130
+ 501
131
+ );
132
+ });
133
+ }
134
+
135
+ return router;
136
+ };
137
+
138
+ const normalizeRoutePrefix = (prefix: string | undefined): string => {
139
+ if (!prefix || prefix === '/') {
140
+ return '';
141
+ }
142
+
143
+ return `/${prefix.replace(/^\/+|\/+$/g, '')}`;
144
+ };
145
+
146
+ const joinRoutePath = (prefix: string, path: string): string => {
147
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
148
+ return `${prefix}${normalizedPath}`;
149
+ };
@@ -0,0 +1,128 @@
1
+ import type { Context } from 'hono';
2
+ import { z } from 'zod';
3
+
4
+ import type { AuthHonoCookiePort } from './ports.js';
5
+ import type { AuthHonoSessionService } from './session.js';
6
+ import type { AuthHonoRouteHandlers } from './router.js';
7
+
8
+ export interface CreateAuthSessionRouteHandlersOptions {
9
+ cookies: AuthHonoCookiePort;
10
+ service: AuthHonoSessionService;
11
+ }
12
+
13
+ const refreshSessionSchema = z.object({
14
+ refreshToken: z.string().min(1),
15
+ });
16
+
17
+ export const createAuthSessionRouteHandlers = (
18
+ options: CreateAuthSessionRouteHandlersOptions
19
+ ): AuthHonoRouteHandlers => ({
20
+ async refreshSession(c) {
21
+ const parsed = await parseJson(c, refreshSessionSchema);
22
+
23
+ if (!parsed.ok) {
24
+ return errorResponse(c, 400, 'invalid_input', 'Invalid request data.', parsed.error.errors);
25
+ }
26
+
27
+ const tokens = await options.service.refreshSession(parsed.value.refreshToken);
28
+
29
+ if (!tokens) {
30
+ return errorResponse(c, 401, 'invalid_session', 'Invalid or expired refresh token.');
31
+ }
32
+
33
+ appendSetCookie(
34
+ c,
35
+ options.cookies.serializeSessionCookie({
36
+ expiresAt: tokens.expiresAt,
37
+ token: tokens.sessionToken,
38
+ })
39
+ );
40
+ appendSetCookie(
41
+ c,
42
+ options.cookies.serializeRefreshCookie({
43
+ expiresAt: tokens.expiresAt,
44
+ token: tokens.refreshToken,
45
+ })
46
+ );
47
+
48
+ return c.json({
49
+ expiresAt: tokens.expiresAt.toISOString(),
50
+ refreshToken: tokens.refreshToken,
51
+ sessionToken: tokens.sessionToken,
52
+ success: true,
53
+ });
54
+ },
55
+
56
+ async logout(c) {
57
+ const sessionToken = readSessionToken(c, options.cookies);
58
+
59
+ if (!sessionToken) {
60
+ return errorResponse(c, 401, 'authentication_required', 'No session token provided.');
61
+ }
62
+
63
+ const session = await options.service.validateSessionToken(sessionToken);
64
+
65
+ if (!session) {
66
+ return errorResponse(c, 401, 'invalid_session', 'Invalid session.');
67
+ }
68
+
69
+ await options.service.revokeSession(session.sessionRecord.id);
70
+
71
+ appendSetCookie(c, options.cookies.serializeClearedSessionCookie());
72
+ appendSetCookie(c, options.cookies.serializeClearedRefreshCookie());
73
+
74
+ return c.json({ message: 'Logged out successfully', success: true });
75
+ },
76
+ });
77
+
78
+ const parseJson = async <T extends z.ZodTypeAny>(
79
+ c: Context,
80
+ schema: T
81
+ ): Promise<{ ok: true; value: z.infer<T> } | { error: z.ZodError; ok: false }> => {
82
+ const body = await c.req.json().catch(() => null);
83
+ const parsed = schema.safeParse(body);
84
+
85
+ if (!parsed.success) {
86
+ return { error: parsed.error, ok: false };
87
+ }
88
+
89
+ return { ok: true, value: parsed.data };
90
+ };
91
+
92
+ const readSessionToken = (c: Context, cookies: AuthHonoCookiePort): string | null => {
93
+ const cookieToken = cookies.readSessionToken(c.req.raw);
94
+
95
+ if (cookieToken) {
96
+ return cookieToken;
97
+ }
98
+
99
+ const authorization = c.req.header('authorization');
100
+
101
+ if (!authorization?.startsWith('Bearer ')) {
102
+ return null;
103
+ }
104
+
105
+ return authorization.slice('Bearer '.length).trim() || null;
106
+ };
107
+
108
+ const appendSetCookie = (c: Context, value: string): void => {
109
+ c.header('Set-Cookie', value, { append: true });
110
+ };
111
+
112
+ const errorResponse = (
113
+ c: Context,
114
+ status: 400 | 401,
115
+ code: string,
116
+ message: string,
117
+ details?: z.ZodIssue[]
118
+ ): Response =>
119
+ c.json(
120
+ {
121
+ error: {
122
+ code,
123
+ ...(details ? { details } : {}),
124
+ message,
125
+ },
126
+ },
127
+ status
128
+ );
package/src/session.ts ADDED
@@ -0,0 +1,201 @@
1
+ import type {
2
+ AuthHonoDeviceInfo,
3
+ AuthHonoPorts,
4
+ AuthHonoSessionClaims,
5
+ AuthHonoSessionRecord,
6
+ AuthHonoUserRecord,
7
+ } from './ports.js';
8
+
9
+ export interface AuthHonoSessionTokens {
10
+ expiresAt: Date;
11
+ refreshToken: string;
12
+ sessionId: string;
13
+ sessionToken: string;
14
+ }
15
+
16
+ export interface AuthHonoValidatedSession {
17
+ role: string;
18
+ session: AuthHonoSessionClaims;
19
+ sessionRecord: AuthHonoSessionRecord;
20
+ user: AuthHonoUserRecord;
21
+ }
22
+
23
+ export interface CreateAuthSessionServiceOptions {
24
+ ports: AuthHonoPorts;
25
+ refreshTokenBytes?: number;
26
+ sessionTtlSeconds?: number;
27
+ }
28
+
29
+ export interface CreateAuthSessionInput {
30
+ deviceInfo?: AuthHonoDeviceInfo;
31
+ mfaVerified?: boolean;
32
+ user: AuthHonoUserRecord;
33
+ }
34
+
35
+ export interface AuthHonoSessionService {
36
+ createSession(input: CreateAuthSessionInput): Promise<AuthHonoSessionTokens>;
37
+ listUserSessions(userId: string): Promise<AuthHonoSessionRecord[]>;
38
+ refreshSession(refreshToken: string): Promise<AuthHonoSessionTokens | null>;
39
+ revokeAllSessions(userId: string): Promise<number>;
40
+ revokeSession(sessionId: string): Promise<boolean>;
41
+ validateSessionToken(sessionToken: string): Promise<AuthHonoValidatedSession | null>;
42
+ }
43
+
44
+ export const createAuthSessionService = (
45
+ options: CreateAuthSessionServiceOptions
46
+ ): AuthHonoSessionService => {
47
+ const sessionTtlSeconds = options.sessionTtlSeconds ?? 7 * 24 * 60 * 60;
48
+ const refreshTokenBytes = options.refreshTokenBytes ?? 32;
49
+
50
+ return {
51
+ async createSession(input) {
52
+ const now = options.ports.clock.now();
53
+ const sessionId = options.ports.random.uuid();
54
+ const expiresAt = options.ports.clock.addSeconds(now, sessionTtlSeconds);
55
+ const refreshToken = options.ports.random.token(refreshTokenBytes);
56
+ const sessionToken = await options.ports.tokens.signSessionToken(
57
+ {
58
+ userId: input.user.id,
59
+ sessionId,
60
+ role: input.user.role,
61
+ email: input.user.email,
62
+ displayName: input.user.displayName,
63
+ },
64
+ expiresAt
65
+ );
66
+
67
+ const sessionTokenHash = await options.ports.tokens.hashSecret(sessionToken);
68
+ const refreshTokenHash = await options.ports.tokens.hashSecret(refreshToken);
69
+ const sessionRecord = await options.ports.sessions.create({
70
+ id: sessionId,
71
+ userId: input.user.id,
72
+ sessionTokenHash,
73
+ refreshTokenHash,
74
+ deviceInfo: input.deviceInfo,
75
+ mfaVerified: input.mfaVerified ?? false,
76
+ expiresAt,
77
+ now,
78
+ });
79
+
80
+ return {
81
+ expiresAt: sessionRecord.expiresAt,
82
+ refreshToken,
83
+ sessionId: sessionRecord.id,
84
+ sessionToken,
85
+ };
86
+ },
87
+
88
+ listUserSessions(userId) {
89
+ return options.ports.sessions.listForUser(userId);
90
+ },
91
+
92
+ async refreshSession(refreshToken) {
93
+ const now = options.ports.clock.now();
94
+ const refreshTokenHash = await options.ports.tokens.hashSecret(refreshToken);
95
+ const sessionRecord = await options.ports.sessions.findByRefreshTokenHash(refreshTokenHash);
96
+
97
+ if (!sessionRecord || sessionRecord.revokedAt || sessionRecord.expiresAt <= now) {
98
+ return null;
99
+ }
100
+
101
+ const user = await options.ports.users.findById(sessionRecord.userId);
102
+
103
+ if (!user) {
104
+ return null;
105
+ }
106
+
107
+ const decision = await options.ports.accountPolicy.canAuthenticate(user, now);
108
+
109
+ if (!decision.allowed) {
110
+ return null;
111
+ }
112
+
113
+ const role = await options.ports.accountPolicy.resolveSessionRole(user, now);
114
+ const expiresAt = options.ports.clock.addSeconds(now, sessionTtlSeconds);
115
+ const nextRefreshToken = options.ports.random.token(refreshTokenBytes);
116
+ const nextSessionToken = await options.ports.tokens.signSessionToken(
117
+ {
118
+ userId: user.id,
119
+ sessionId: sessionRecord.id,
120
+ role,
121
+ email: user.email,
122
+ displayName: user.displayName,
123
+ },
124
+ expiresAt
125
+ );
126
+ const nextSessionTokenHash = await options.ports.tokens.hashSecret(nextSessionToken);
127
+ const nextRefreshTokenHash = await options.ports.tokens.hashSecret(nextRefreshToken);
128
+ const updatedSession = await options.ports.sessions.updateTokens({
129
+ expiresAt,
130
+ refreshTokenHash: nextRefreshTokenHash,
131
+ sessionId: sessionRecord.id,
132
+ sessionTokenHash: nextSessionTokenHash,
133
+ });
134
+
135
+ if (!updatedSession) {
136
+ return null;
137
+ }
138
+
139
+ return {
140
+ expiresAt: updatedSession.expiresAt,
141
+ refreshToken: nextRefreshToken,
142
+ sessionId: updatedSession.id,
143
+ sessionToken: nextSessionToken,
144
+ };
145
+ },
146
+
147
+ revokeAllSessions(userId) {
148
+ return options.ports.sessions.revokeAllForUser(userId);
149
+ },
150
+
151
+ revokeSession(sessionId) {
152
+ return options.ports.sessions.revoke(sessionId);
153
+ },
154
+
155
+ async validateSessionToken(sessionToken) {
156
+ const claims = await options.ports.tokens.verifySessionToken(sessionToken);
157
+
158
+ if (!claims) {
159
+ return null;
160
+ }
161
+
162
+ const tokenHash = await options.ports.tokens.hashSecret(sessionToken);
163
+ const sessionRecord = await options.ports.sessions.findByTokenHash(tokenHash);
164
+ const now = options.ports.clock.now();
165
+
166
+ if (
167
+ !sessionRecord ||
168
+ sessionRecord.id !== claims.sessionId ||
169
+ sessionRecord.userId !== claims.userId ||
170
+ sessionRecord.revokedAt ||
171
+ sessionRecord.expiresAt <= now
172
+ ) {
173
+ return null;
174
+ }
175
+
176
+ const user = await options.ports.users.findById(claims.userId);
177
+
178
+ if (!user) {
179
+ return null;
180
+ }
181
+
182
+ const decision = await options.ports.accountPolicy.canAuthenticate(user, now);
183
+
184
+ if (!decision.allowed) {
185
+ return null;
186
+ }
187
+
188
+ const role = await options.ports.accountPolicy.resolveSessionRole(user, now);
189
+ const session = { ...claims, role };
190
+
191
+ await options.ports.sessions.touch(sessionRecord.id, now);
192
+
193
+ return {
194
+ role,
195
+ session,
196
+ sessionRecord,
197
+ user,
198
+ };
199
+ },
200
+ };
201
+ };
@@ -0,0 +1,200 @@
1
+ import type { AuthenticationResponseJSON } 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
+ AuthHonoGenerateAuthenticationOptionsInput,
8
+ AuthHonoWebAuthnAuthenticationService,
9
+ } from './webauthn-authentication.js';
10
+ import type { AuthHonoRouteHandlers } from './router.js';
11
+ import type { AuthHonoRouteHandlerError } from './webauthn-registration-route-handlers.js';
12
+
13
+ export interface CreateAuthWebAuthnAuthenticationRouteHandlersOptions {
14
+ finalizeAuthentication?: AuthHonoFinalizeAuthentication;
15
+ resolveAuthenticationOptions: AuthHonoResolveAuthenticationOptions;
16
+ service: AuthHonoWebAuthnAuthenticationService;
17
+ }
18
+
19
+ export type AuthHonoResolveAuthenticationOptions = (
20
+ input: AuthHonoAuthenticationOptionsRequest,
21
+ c: Context
22
+ ) =>
23
+ | Promise<AuthHonoGenerateAuthenticationOptionsInput | AuthHonoRouteHandlerError>
24
+ | AuthHonoGenerateAuthenticationOptionsInput
25
+ | AuthHonoRouteHandlerError;
26
+
27
+ export type AuthHonoFinalizeAuthentication = (
28
+ result: AuthHonoFinalizeAuthenticationInput,
29
+ c: Context
30
+ ) => Response | Promise<Response>;
31
+
32
+ export interface AuthHonoFinalizeAuthenticationInput {
33
+ credentialId: string;
34
+ request: AuthHonoAuthenticationVerifyRequest;
35
+ userId: string;
36
+ }
37
+
38
+ export interface AuthHonoAuthenticationOptionsRequest {
39
+ email?: string;
40
+ }
41
+
42
+ export interface AuthHonoAuthenticationVerifyRequest {
43
+ credential: AuthenticationResponseJSON;
44
+ deviceName?: string;
45
+ }
46
+
47
+ const isRouteHandlerError = (value: unknown): value is AuthHonoRouteHandlerError =>
48
+ Boolean(
49
+ value &&
50
+ typeof value === 'object' &&
51
+ 'error' in (value as Record<string, unknown>) &&
52
+ typeof (value as { error?: unknown }).error === 'object' &&
53
+ (value as { error?: { code?: unknown } }).error?.code !== undefined
54
+ );
55
+
56
+ const authenticationOptionsSchema = z.object({
57
+ email: z.string().email().optional(),
58
+ });
59
+
60
+ const authenticationVerifySchema = z.object({
61
+ credential: z.custom<AuthenticationResponseJSON>(
62
+ (value) => Boolean(value && typeof value === 'object' && 'response' in value)
63
+ ),
64
+ deviceName: z.string().max(100).optional(),
65
+ });
66
+
67
+ export const createAuthWebAuthnAuthenticationRouteHandlers = (
68
+ options: CreateAuthWebAuthnAuthenticationRouteHandlersOptions
69
+ ): AuthHonoRouteHandlers => ({
70
+ async createPasskeyAuthenticationOptions(c) {
71
+ const input = await parseJson(c, authenticationOptionsSchema);
72
+
73
+ if (!input.ok) {
74
+ return invalidRequest(c, input.error);
75
+ }
76
+
77
+ const serviceInput = await options.resolveAuthenticationOptions(input.value, c);
78
+
79
+ if (isRouteHandlerError(serviceInput)) {
80
+ return simpleError(
81
+ c,
82
+ serviceInput.error.status,
83
+ serviceInput.error.code,
84
+ serviceInput.error.message
85
+ );
86
+ }
87
+
88
+ const authenticationOptions = await options.service.generateAuthenticationOptions(serviceInput);
89
+
90
+ return c.json({ options: authenticationOptions });
91
+ },
92
+
93
+ async verifyPasskeyAuthentication(c) {
94
+ const input = await parseJson(c, authenticationVerifySchema);
95
+
96
+ if (!input.ok) {
97
+ return invalidRequest(c, input.error);
98
+ }
99
+
100
+ const challenge = extractChallenge(input.value.credential);
101
+
102
+ if (!challenge) {
103
+ return simpleError(c, 400, 'invalid_credential', 'Credential challenge is missing or invalid.');
104
+ }
105
+
106
+ const result = await options.service.verifyAuthentication({
107
+ credential: input.value.credential,
108
+ expectedChallenge: challenge,
109
+ });
110
+
111
+ if (!result.verified) {
112
+ return serviceError(c, result.error);
113
+ }
114
+
115
+ if (options.finalizeAuthentication) {
116
+ return options.finalizeAuthentication(
117
+ {
118
+ credentialId: result.credentialId,
119
+ request: input.value,
120
+ userId: result.userId,
121
+ },
122
+ c
123
+ );
124
+ }
125
+
126
+ return c.json({
127
+ credentialId: result.credentialId,
128
+ success: true,
129
+ userId: result.userId,
130
+ });
131
+ },
132
+ });
133
+
134
+ const parseJson = async <T extends z.ZodTypeAny>(
135
+ c: Context,
136
+ schema: T
137
+ ): Promise<{ ok: true; value: z.infer<T> } | { error: z.ZodError; ok: false }> => {
138
+ const body = await c.req.json().catch(() => null);
139
+ const parsed = schema.safeParse(body);
140
+
141
+ if (!parsed.success) {
142
+ return { error: parsed.error, ok: false };
143
+ }
144
+
145
+ return { ok: true, value: parsed.data };
146
+ };
147
+
148
+ const extractChallenge = (credential: AuthenticationResponseJSON): string | null => {
149
+ const clientDataJson = credential.response?.clientDataJSON;
150
+
151
+ if (!clientDataJson) {
152
+ return null;
153
+ }
154
+
155
+ try {
156
+ const json = JSON.parse(decodeBase64Url(clientDataJson)) as { challenge?: unknown };
157
+ return typeof json.challenge === 'string' && json.challenge.length > 0 ? json.challenge : null;
158
+ } catch {
159
+ return null;
160
+ }
161
+ };
162
+
163
+ const decodeBase64Url = (value: string): string => {
164
+ const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
165
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
166
+ return atob(padded);
167
+ };
168
+
169
+ const invalidRequest = (c: Context, error: z.ZodError): Response =>
170
+ c.json(
171
+ {
172
+ error: {
173
+ code: 'invalid_input',
174
+ details: error.errors,
175
+ message: 'Invalid request data.',
176
+ },
177
+ },
178
+ 400
179
+ );
180
+
181
+ const serviceError = (
182
+ c: Context,
183
+ error: { code: string; message: string; status: number }
184
+ ): Response => simpleError(c, error.status as ContentfulStatusCode, error.code, error.message);
185
+
186
+ const simpleError = (
187
+ c: Context,
188
+ status: ContentfulStatusCode,
189
+ code: string,
190
+ message: string
191
+ ): Response =>
192
+ c.json(
193
+ {
194
+ error: {
195
+ code,
196
+ message,
197
+ },
198
+ },
199
+ status
200
+ );