@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,99 @@
1
+ import type { AuthHonoPorts } from './ports.js';
2
+
3
+ export const AUTH_HONO_AUTH_UI_METHODS = [
4
+ 'requestEmailCode',
5
+ 'verifyEmailCode',
6
+ 'requestMagicLink',
7
+ 'verifyMagicLink',
8
+ 'createPasskeyRegistrationOptions',
9
+ 'verifyPasskeyRegistration',
10
+ 'createPasskeyAuthenticationOptions',
11
+ 'verifyPasskeyAuthentication',
12
+ 'refreshSession',
13
+ 'logout',
14
+ 'listCredentials',
15
+ 'renameCredential',
16
+ 'revokeCredential',
17
+ ] as const;
18
+
19
+ export type AuthHonoAuthUiMethod = (typeof AUTH_HONO_AUTH_UI_METHODS)[number];
20
+
21
+ export type AuthHonoHttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
22
+
23
+ export interface AuthHonoRouteContract {
24
+ method: AuthHonoHttpMethod;
25
+ path: string;
26
+ }
27
+
28
+ export const AUTH_HONO_ROUTE_MAP = {
29
+ requestEmailCode: {
30
+ method: 'POST',
31
+ path: '/email/verify-request',
32
+ },
33
+ verifyEmailCode: {
34
+ method: 'POST',
35
+ path: '/email/verify-code',
36
+ },
37
+ requestMagicLink: {
38
+ method: 'POST',
39
+ path: '/magic-link/request',
40
+ },
41
+ verifyMagicLink: {
42
+ method: 'POST',
43
+ path: '/magic-link/verify',
44
+ },
45
+ createPasskeyRegistrationOptions: {
46
+ method: 'POST',
47
+ path: '/register/options',
48
+ },
49
+ verifyPasskeyRegistration: {
50
+ method: 'POST',
51
+ path: '/register/verify',
52
+ },
53
+ createPasskeyAuthenticationOptions: {
54
+ method: 'POST',
55
+ path: '/login/options',
56
+ },
57
+ verifyPasskeyAuthentication: {
58
+ method: 'POST',
59
+ path: '/login/verify',
60
+ },
61
+ refreshSession: {
62
+ method: 'POST',
63
+ path: '/session/refresh',
64
+ },
65
+ logout: {
66
+ method: 'DELETE',
67
+ path: '/session',
68
+ },
69
+ listCredentials: {
70
+ method: 'GET',
71
+ path: '/credentials',
72
+ },
73
+ renameCredential: {
74
+ method: 'PUT',
75
+ path: '/credentials/:id',
76
+ },
77
+ revokeCredential: {
78
+ method: 'DELETE',
79
+ path: '/credentials/:id',
80
+ },
81
+ } as const satisfies Record<AuthHonoAuthUiMethod, AuthHonoRouteContract>;
82
+
83
+ export const AUTH_HONO_REQUIRED_PORTS = [
84
+ 'users',
85
+ 'credentials',
86
+ 'challenges',
87
+ 'sessions',
88
+ 'emailVerification',
89
+ 'magicLinks',
90
+ 'emailDelivery',
91
+ 'cookies',
92
+ 'tokens',
93
+ 'auditLog',
94
+ 'clock',
95
+ 'random',
96
+ 'accountPolicy',
97
+ ] as const satisfies readonly (keyof AuthHonoPorts)[];
98
+
99
+ export type AuthHonoRequiredPort = (typeof AUTH_HONO_REQUIRED_PORTS)[number];
@@ -0,0 +1,178 @@
1
+ import type { Context } from 'hono';
2
+ import { z } from 'zod';
3
+
4
+ import type { AuthHonoCredentialPort, AuthHonoCredentialRecord } from './ports.js';
5
+ import type { AuthHonoRouteHandlers } from './router.js';
6
+
7
+ export interface AuthHonoCredentialRouteSession {
8
+ userId: string;
9
+ }
10
+
11
+ export type AuthHonoCredentialSessionResolver = (
12
+ c: Context
13
+ ) => Promise<AuthHonoCredentialRouteSession | null>;
14
+
15
+ export interface CreateAuthCredentialRouteHandlersOptions {
16
+ credentials: AuthHonoCredentialPort;
17
+ resolveSession: AuthHonoCredentialSessionResolver;
18
+ }
19
+
20
+ const renameCredentialSchema = z.object({
21
+ deviceName: z.string().min(1).max(100),
22
+ });
23
+
24
+ export const createAuthCredentialRouteHandlers = (
25
+ options: CreateAuthCredentialRouteHandlersOptions
26
+ ): AuthHonoRouteHandlers => ({
27
+ async listCredentials(c) {
28
+ const session = await options.resolveSession(c);
29
+
30
+ if (!session) {
31
+ return authenticationRequired(c);
32
+ }
33
+
34
+ const credentials = await options.credentials.listForUser(session.userId);
35
+ return c.json({ credentials: credentials.map(toCredentialResponse) });
36
+ },
37
+
38
+ async renameCredential(c) {
39
+ const session = await options.resolveSession(c);
40
+
41
+ if (!session) {
42
+ return authenticationRequired(c);
43
+ }
44
+
45
+ const parsed = await parseJson(c, renameCredentialSchema);
46
+
47
+ if (!parsed.ok) {
48
+ return invalidInput(c, parsed.error);
49
+ }
50
+
51
+ const checked = await requireOwnedCredential(c, options, session.userId);
52
+
53
+ if (!checked.ok) {
54
+ return checked.response;
55
+ }
56
+
57
+ const renamed = await options.credentials.rename(
58
+ checked.credential.id,
59
+ session.userId,
60
+ parsed.value.deviceName
61
+ );
62
+
63
+ if (!renamed) {
64
+ return credentialNotFound(c);
65
+ }
66
+
67
+ return c.json({ credential: toCredentialResponse(renamed), success: true });
68
+ },
69
+
70
+ async revokeCredential(c) {
71
+ const session = await options.resolveSession(c);
72
+
73
+ if (!session) {
74
+ return authenticationRequired(c);
75
+ }
76
+
77
+ const checked = await requireOwnedCredential(c, options, session.userId);
78
+
79
+ if (!checked.ok) {
80
+ return checked.response;
81
+ }
82
+
83
+ const revoked = await options.credentials.revoke(checked.credential.id, session.userId);
84
+
85
+ if (!revoked) {
86
+ return credentialNotFound(c);
87
+ }
88
+
89
+ return c.json({ success: true });
90
+ },
91
+ });
92
+
93
+ const toCredentialResponse = (credential: AuthHonoCredentialRecord) => ({
94
+ backedUp: credential.backedUp,
95
+ counter: credential.counter,
96
+ createdAt: credential.createdAt.toISOString(),
97
+ credentialId: credential.credentialId,
98
+ deviceName: credential.name ?? 'Unnamed credential',
99
+ deviceType: credential.deviceType,
100
+ id: credential.id,
101
+ lastUsedAt: credential.lastUsedAt ? credential.lastUsedAt.toISOString() : null,
102
+ transports: credential.transports,
103
+ });
104
+
105
+ const requireOwnedCredential = async (
106
+ c: Context,
107
+ options: CreateAuthCredentialRouteHandlersOptions,
108
+ userId: string
109
+ ): Promise<{ credential: AuthHonoCredentialRecord; ok: true } | { ok: false; response: Response }> => {
110
+ const credentialId = c.req.param('id');
111
+
112
+ if (!credentialId) {
113
+ return { ok: false, response: credentialNotFound(c) };
114
+ }
115
+
116
+ const credential = await options.credentials.findById(credentialId);
117
+
118
+ if (!credential) {
119
+ return { ok: false, response: credentialNotFound(c) };
120
+ }
121
+
122
+ if (credential.userId !== userId) {
123
+ return { ok: false, response: forbidden(c) };
124
+ }
125
+
126
+ return { credential, ok: true };
127
+ };
128
+
129
+ const parseJson = async <T extends z.ZodTypeAny>(
130
+ c: Context,
131
+ schema: T
132
+ ): Promise<{ ok: true; value: z.infer<T> } | { error: z.ZodError; ok: false }> => {
133
+ const body = await c.req.json().catch(() => null);
134
+ const parsed = schema.safeParse(body);
135
+
136
+ if (!parsed.success) {
137
+ return { error: parsed.error, ok: false };
138
+ }
139
+
140
+ return { ok: true, value: parsed.data };
141
+ };
142
+
143
+ const authenticationRequired = (c: Context): Response =>
144
+ c.json(
145
+ {
146
+ error: {
147
+ code: 'authentication_required',
148
+ message: 'Authentication is required to access credentials.',
149
+ },
150
+ },
151
+ 401
152
+ );
153
+
154
+ const invalidInput = (c: Context, error: z.ZodError): Response =>
155
+ c.json(
156
+ {
157
+ error: {
158
+ code: 'invalid_input',
159
+ details: error.errors,
160
+ message: 'Invalid request data.',
161
+ },
162
+ },
163
+ 400
164
+ );
165
+
166
+ const credentialNotFound = (c: Context): Response =>
167
+ c.json({ error: { code: 'credential_not_found', message: 'Credential not found.' } }, 404);
168
+
169
+ const forbidden = (c: Context): Response =>
170
+ c.json(
171
+ {
172
+ error: {
173
+ code: 'forbidden',
174
+ message: 'Credential does not belong to the authenticated user.',
175
+ },
176
+ },
177
+ 403
178
+ );
@@ -0,0 +1,127 @@
1
+ import type { AuthHonoPorts } from './ports.js';
2
+
3
+ export interface CreateAuthEmailVerificationServiceOptions {
4
+ codeLength?: number;
5
+ codeTtlSeconds?: number;
6
+ maxRequestsPerWindow?: number;
7
+ ports: AuthHonoPorts;
8
+ verificationTokenTtlSeconds?: number;
9
+ windowSeconds?: number;
10
+ }
11
+
12
+ export interface AuthHonoRequestEmailCodeInput {
13
+ email: string;
14
+ }
15
+
16
+ export interface AuthHonoVerifyEmailCodeInput {
17
+ code: string;
18
+ email: string;
19
+ }
20
+
21
+ export type AuthHonoRequestEmailCodeResult =
22
+ | {
23
+ expiresAt: Date;
24
+ success: true;
25
+ }
26
+ | {
27
+ error: AuthHonoServiceError;
28
+ success: false;
29
+ };
30
+
31
+ export type AuthHonoVerifyEmailCodeResult =
32
+ | {
33
+ valid: true;
34
+ verificationToken: string;
35
+ }
36
+ | {
37
+ error: AuthHonoServiceError;
38
+ valid: false;
39
+ };
40
+
41
+ export interface AuthHonoServiceError {
42
+ code: string;
43
+ message: string;
44
+ status: number;
45
+ }
46
+
47
+ export interface AuthHonoEmailVerificationService {
48
+ requestEmailCode(input: AuthHonoRequestEmailCodeInput): Promise<AuthHonoRequestEmailCodeResult>;
49
+ verifyEmailCode(input: AuthHonoVerifyEmailCodeInput): Promise<AuthHonoVerifyEmailCodeResult>;
50
+ }
51
+
52
+ export const createAuthEmailVerificationService = (
53
+ options: CreateAuthEmailVerificationServiceOptions
54
+ ): AuthHonoEmailVerificationService => {
55
+ const codeLength = options.codeLength ?? 6;
56
+ const codeTtlSeconds = options.codeTtlSeconds ?? 10 * 60;
57
+ const maxRequestsPerWindow = options.maxRequestsPerWindow ?? 3;
58
+ const verificationTokenTtlSeconds = options.verificationTokenTtlSeconds ?? 15 * 60;
59
+ const windowSeconds = options.windowSeconds ?? 10 * 60;
60
+
61
+ return {
62
+ async requestEmailCode(input) {
63
+ const email = options.ports.accountPolicy.normalizeEmail(input.email);
64
+ const now = options.ports.clock.now();
65
+ const windowStart = options.ports.clock.addSeconds(now, -windowSeconds);
66
+ const recentCount = await options.ports.emailVerification.countRecent(email, windowStart);
67
+
68
+ if (recentCount >= maxRequestsPerWindow) {
69
+ return {
70
+ error: {
71
+ code: 'rate_limited',
72
+ message: 'Too many verification code requests.',
73
+ status: 429,
74
+ },
75
+ success: false,
76
+ };
77
+ }
78
+
79
+ const code = options.ports.random.numericCode(codeLength);
80
+ const codeHash = await options.ports.tokens.hashSecret(code);
81
+ const expiresAt = options.ports.clock.addSeconds(now, codeTtlSeconds);
82
+
83
+ await options.ports.emailVerification.createCode({
84
+ email,
85
+ codeHash,
86
+ expiresAt,
87
+ now,
88
+ });
89
+ await options.ports.emailDelivery.sendVerificationCode({ email, code, expiresAt });
90
+
91
+ return {
92
+ expiresAt,
93
+ success: true,
94
+ };
95
+ },
96
+
97
+ async verifyEmailCode(input) {
98
+ const email = options.ports.accountPolicy.normalizeEmail(input.email);
99
+ const codeHash = await options.ports.tokens.hashSecret(input.code);
100
+ const now = options.ports.clock.now();
101
+ const record = await options.ports.emailVerification.findLatestValidCode(email, codeHash, now);
102
+
103
+ if (!record || record.used || record.expiresAt <= now) {
104
+ return {
105
+ error: {
106
+ code: 'invalid_or_expired_code',
107
+ message: 'Verification code is invalid or expired.',
108
+ status: 400,
109
+ },
110
+ valid: false,
111
+ };
112
+ }
113
+
114
+ const verificationToken = await options.ports.tokens.signVerificationToken({
115
+ email,
116
+ expiresAt: options.ports.clock.addSeconds(now, verificationTokenTtlSeconds),
117
+ });
118
+
119
+ await options.ports.emailVerification.markUsedWithVerificationToken(record.id, verificationToken);
120
+
121
+ return {
122
+ valid: true,
123
+ verificationToken,
124
+ };
125
+ },
126
+ };
127
+ };
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export * from './contracts.js';
2
+ export * from './credential-route-handlers.js';
3
+ export * from './email-verification.js';
4
+ export * from './magic-link.js';
5
+ export * from './middleware.js';
6
+ export * from './ports.js';
7
+ export * from './route-handlers.js';
8
+ export * from './router.js';
9
+ export * from './session.js';
10
+ export * from './session-route-handlers.js';
11
+ export * from './webauthn-authentication.js';
12
+ export * from './webauthn-authentication-route-handlers.js';
13
+ export * from './webauthn-registration.js';
14
+ export * from './webauthn-registration-route-handlers.js';
@@ -0,0 +1,167 @@
1
+ import type { AuthHonoPorts, AuthHonoUserRecord } from './ports.js';
2
+
3
+ export interface CreateAuthMagicLinkServiceOptions {
4
+ baseUrl: string;
5
+ ports: AuthHonoPorts;
6
+ tokenBytes?: number;
7
+ ttlSeconds?: number;
8
+ }
9
+
10
+ export interface AuthHonoRequestMagicLinkInput {
11
+ email: string;
12
+ userId?: string | null;
13
+ }
14
+
15
+ export interface AuthHonoVerifyMagicLinkInput {
16
+ token: string;
17
+ }
18
+
19
+ export type AuthHonoRequestMagicLinkResult =
20
+ | {
21
+ expiresAt: Date;
22
+ success: true;
23
+ }
24
+ | {
25
+ error: AuthHonoMagicLinkError;
26
+ success: false;
27
+ };
28
+
29
+ export type AuthHonoVerifyMagicLinkResult =
30
+ | {
31
+ email: string;
32
+ user: AuthHonoUserRecord;
33
+ valid: true;
34
+ }
35
+ | {
36
+ error: AuthHonoMagicLinkError;
37
+ valid: false;
38
+ };
39
+
40
+ export interface AuthHonoMagicLinkError {
41
+ code: string;
42
+ message: string;
43
+ status: number;
44
+ }
45
+
46
+ export interface AuthHonoMagicLinkService {
47
+ requestMagicLink(input: AuthHonoRequestMagicLinkInput): Promise<AuthHonoRequestMagicLinkResult>;
48
+ verifyMagicLink(input: AuthHonoVerifyMagicLinkInput): Promise<AuthHonoVerifyMagicLinkResult>;
49
+ }
50
+
51
+ export const createAuthMagicLinkService = (
52
+ options: CreateAuthMagicLinkServiceOptions
53
+ ): AuthHonoMagicLinkService => {
54
+ const tokenBytes = options.tokenBytes ?? 32;
55
+ const ttlSeconds = options.ttlSeconds ?? 10 * 60;
56
+
57
+ return {
58
+ async requestMagicLink(input) {
59
+ const email = options.ports.accountPolicy.normalizeEmail(input.email);
60
+ const now = options.ports.clock.now();
61
+ const token = options.ports.random.token(tokenBytes);
62
+ const tokenHash = await options.ports.tokens.hashSecret(token);
63
+ const expiresAt = options.ports.clock.addSeconds(now, ttlSeconds);
64
+ const url = buildMagicLinkUrl(options.baseUrl, token);
65
+
66
+ await options.ports.magicLinks.create({
67
+ email,
68
+ expiresAt,
69
+ now,
70
+ tokenHash,
71
+ userId: input.userId ?? null,
72
+ });
73
+ await options.ports.emailDelivery.sendMagicLink({ email, expiresAt, token, url });
74
+
75
+ return {
76
+ expiresAt,
77
+ success: true,
78
+ };
79
+ },
80
+
81
+ async verifyMagicLink(input) {
82
+ const tokenHash = await options.ports.tokens.hashSecret(input.token);
83
+ const now = options.ports.clock.now();
84
+ const link = await options.ports.magicLinks.findValidByTokenHash(tokenHash, now);
85
+
86
+ if (!link || link.used || link.expiresAt <= now) {
87
+ return invalidMagicLink();
88
+ }
89
+
90
+ const email = options.ports.accountPolicy.normalizeEmail(link.email);
91
+ const user = await resolveMagicLinkUser(options.ports, {
92
+ email,
93
+ now,
94
+ userId: link.userId,
95
+ });
96
+
97
+ if (!user) {
98
+ return invalidMagicLink();
99
+ }
100
+
101
+ await options.ports.magicLinks.markUsed(link.id, user.id);
102
+
103
+ return {
104
+ email,
105
+ user,
106
+ valid: true,
107
+ };
108
+ },
109
+ };
110
+ };
111
+
112
+ const resolveMagicLinkUser = async (
113
+ ports: AuthHonoPorts,
114
+ input: { email: string; now: Date; userId: string | null }
115
+ ): Promise<AuthHonoUserRecord | null> => {
116
+ const existingUser = input.userId
117
+ ? await ports.users.findById(input.userId)
118
+ : await ports.users.findByEmail(input.email);
119
+
120
+ if (existingUser) {
121
+ if (!existingUser.emailVerified || existingUser.email !== input.email || !existingUser.displayName) {
122
+ return (
123
+ (await ports.users.update(existingUser.id, {
124
+ displayName: existingUser.displayName ?? ports.accountPolicy.deriveDisplayName(input.email),
125
+ email: existingUser.email ?? input.email,
126
+ emailVerified: true,
127
+ updatedAt: input.now,
128
+ })) ?? existingUser
129
+ );
130
+ }
131
+
132
+ return existingUser;
133
+ }
134
+
135
+ const isFirstUser = (await ports.users.count()) === 0;
136
+ const status = await ports.accountPolicy.statusForNewUser({
137
+ email: input.email,
138
+ isFirstUser,
139
+ now: input.now,
140
+ });
141
+ const user = await ports.users.create({
142
+ accountStatus: status.accountStatus,
143
+ approvalDueAt: status.approvalDueAt,
144
+ displayName: ports.accountPolicy.deriveDisplayName(input.email),
145
+ email: input.email,
146
+ emailVerified: true,
147
+ role: await ports.accountPolicy.roleForNewUser({ email: input.email, isFirstUser }),
148
+ });
149
+
150
+ await ports.accountPolicy.afterUserCreated?.(user);
151
+
152
+ return user;
153
+ };
154
+
155
+ const invalidMagicLink = (): AuthHonoVerifyMagicLinkResult => ({
156
+ error: {
157
+ code: 'invalid_or_expired_magic_link',
158
+ message: 'Magic link is invalid or expired.',
159
+ status: 400,
160
+ },
161
+ valid: false,
162
+ });
163
+
164
+ const buildMagicLinkUrl = (baseUrl: string, token: string): string => {
165
+ const normalizedBaseUrl = baseUrl.replace(/\/+$/g, '');
166
+ return `${normalizedBaseUrl}/auth/magic-link/verify?token=${encodeURIComponent(token)}`;
167
+ };