@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,178 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler, Next } from 'hono';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AuthHonoPorts,
|
|
5
|
+
AuthHonoSessionClaims,
|
|
6
|
+
AuthHonoSessionRecord,
|
|
7
|
+
AuthHonoUserRecord,
|
|
8
|
+
} from './ports.js';
|
|
9
|
+
|
|
10
|
+
export interface AuthHonoAuthContext {
|
|
11
|
+
role: string;
|
|
12
|
+
session: AuthHonoSessionClaims;
|
|
13
|
+
sessionRecord: AuthHonoSessionRecord;
|
|
14
|
+
token: string;
|
|
15
|
+
user: AuthHonoUserRecord;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AuthHonoMiddlewareVariables {
|
|
19
|
+
auth: AuthHonoAuthContext;
|
|
20
|
+
session: AuthHonoSessionClaims;
|
|
21
|
+
user: AuthHonoUserRecord;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AuthHonoMiddlewareEnv {
|
|
25
|
+
Variables: AuthHonoMiddlewareVariables;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CreateAuthMiddlewareOptions {
|
|
29
|
+
ports: AuthHonoPorts;
|
|
30
|
+
unauthorizedCode?: string;
|
|
31
|
+
unauthorizedMessage?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type AuthResult =
|
|
35
|
+
| { kind: 'authenticated'; auth: AuthHonoAuthContext }
|
|
36
|
+
| { kind: 'missing' }
|
|
37
|
+
| { kind: 'rejected'; code: string; message: string; status: 401 | 403 };
|
|
38
|
+
|
|
39
|
+
export const createRequireAuth = (options: CreateAuthMiddlewareOptions): MiddlewareHandler => {
|
|
40
|
+
return async (c, next) => {
|
|
41
|
+
const result = await authenticateRequest(c, options);
|
|
42
|
+
|
|
43
|
+
if (result.kind === 'missing') {
|
|
44
|
+
return authError(c, {
|
|
45
|
+
code: options.unauthorizedCode ?? 'unauthorized',
|
|
46
|
+
message: options.unauthorizedMessage ?? 'Authentication required.',
|
|
47
|
+
status: 401,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (result.kind === 'rejected') {
|
|
52
|
+
return authError(c, result);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setAuthContext(c, result.auth);
|
|
56
|
+
await next();
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const createOptionalAuth = (options: CreateAuthMiddlewareOptions): MiddlewareHandler => {
|
|
61
|
+
return async (c, next) => {
|
|
62
|
+
const result = await authenticateRequest(c, options);
|
|
63
|
+
|
|
64
|
+
if (result.kind === 'rejected') {
|
|
65
|
+
return authError(c, result);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (result.kind === 'authenticated') {
|
|
69
|
+
setAuthContext(c, result.auth);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await next();
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const authenticateRequest = async (
|
|
77
|
+
c: Context,
|
|
78
|
+
options: CreateAuthMiddlewareOptions
|
|
79
|
+
): Promise<AuthResult> => {
|
|
80
|
+
const token = readBearerToken(c.req.raw) ?? options.ports.cookies.readSessionToken(c.req.raw);
|
|
81
|
+
|
|
82
|
+
if (!token) {
|
|
83
|
+
return { kind: 'missing' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const now = options.ports.clock.now();
|
|
87
|
+
const claims = await options.ports.tokens.verifySessionToken(token);
|
|
88
|
+
|
|
89
|
+
if (!claims) {
|
|
90
|
+
return invalidSession();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const tokenHash = await options.ports.tokens.hashSecret(token);
|
|
94
|
+
const sessionRecord = await options.ports.sessions.findByTokenHash(tokenHash);
|
|
95
|
+
|
|
96
|
+
if (
|
|
97
|
+
!sessionRecord ||
|
|
98
|
+
sessionRecord.id !== claims.sessionId ||
|
|
99
|
+
sessionRecord.userId !== claims.userId ||
|
|
100
|
+
sessionRecord.revokedAt ||
|
|
101
|
+
sessionRecord.expiresAt <= now
|
|
102
|
+
) {
|
|
103
|
+
return invalidSession();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const user = await options.ports.users.findById(claims.userId);
|
|
107
|
+
|
|
108
|
+
if (!user) {
|
|
109
|
+
return invalidSession();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const decision = await options.ports.accountPolicy.canAuthenticate(user, now);
|
|
113
|
+
|
|
114
|
+
if (!decision.allowed) {
|
|
115
|
+
return {
|
|
116
|
+
kind: 'rejected',
|
|
117
|
+
code: decision.code ?? 'forbidden',
|
|
118
|
+
message: decision.message ?? 'Authentication is not allowed for this account.',
|
|
119
|
+
status: decision.status === 401 ? 401 : 403,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const role = await options.ports.accountPolicy.resolveSessionRole(user, now);
|
|
124
|
+
const session = { ...claims, role };
|
|
125
|
+
|
|
126
|
+
await options.ports.sessions.touch(sessionRecord.id, now);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
kind: 'authenticated',
|
|
130
|
+
auth: {
|
|
131
|
+
role,
|
|
132
|
+
session,
|
|
133
|
+
sessionRecord,
|
|
134
|
+
token,
|
|
135
|
+
user,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const invalidSession = (): AuthResult => ({
|
|
141
|
+
kind: 'rejected',
|
|
142
|
+
code: 'invalid_session',
|
|
143
|
+
message: 'Session is invalid or expired.',
|
|
144
|
+
status: 401,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const readBearerToken = (request: Request): string | null => {
|
|
148
|
+
const header = request.headers.get('Authorization');
|
|
149
|
+
|
|
150
|
+
if (!header) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const match = /^Bearer\s+(.+)$/i.exec(header);
|
|
155
|
+
return match?.[1]?.trim() || null;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const setAuthContext = (c: Context, auth: AuthHonoAuthContext): void => {
|
|
159
|
+
const typedContext = c as Context<AuthHonoMiddlewareEnv>;
|
|
160
|
+
typedContext.set('auth', auth);
|
|
161
|
+
typedContext.set('session', auth.session);
|
|
162
|
+
typedContext.set('user', auth.user);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const authError = (
|
|
166
|
+
c: Context,
|
|
167
|
+
error: { code: string; message: string; status: 401 | 403 }
|
|
168
|
+
): Response => {
|
|
169
|
+
return c.json(
|
|
170
|
+
{
|
|
171
|
+
error: {
|
|
172
|
+
code: error.code,
|
|
173
|
+
message: error.message,
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
error.status
|
|
177
|
+
);
|
|
178
|
+
};
|
package/src/ports.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
export type AuthHonoAccountStatus =
|
|
2
|
+
| 'active'
|
|
3
|
+
| 'pending_admin_approval'
|
|
4
|
+
| 'approval_expired_readonly'
|
|
5
|
+
| 'disabled_by_user'
|
|
6
|
+
| 'disabled_by_admin'
|
|
7
|
+
| (string & {});
|
|
8
|
+
|
|
9
|
+
export type AuthHonoChallengeType = 'registration' | 'authentication';
|
|
10
|
+
|
|
11
|
+
export interface AuthHonoDeviceInfo {
|
|
12
|
+
name?: string;
|
|
13
|
+
ipAddress?: string;
|
|
14
|
+
userAgent?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AuthHonoUserRecord {
|
|
18
|
+
id: string;
|
|
19
|
+
email: string | null;
|
|
20
|
+
displayName: string | null;
|
|
21
|
+
role: string;
|
|
22
|
+
emailVerified: boolean;
|
|
23
|
+
accountStatus: AuthHonoAccountStatus;
|
|
24
|
+
approvalDueAt: Date | null;
|
|
25
|
+
createdAt: Date;
|
|
26
|
+
updatedAt: Date;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AuthHonoCreateUserInput {
|
|
30
|
+
email: string;
|
|
31
|
+
displayName?: string | null;
|
|
32
|
+
role: string;
|
|
33
|
+
emailVerified?: boolean;
|
|
34
|
+
accountStatus?: AuthHonoAccountStatus;
|
|
35
|
+
approvalDueAt?: Date | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AuthHonoUpdateUserInput {
|
|
39
|
+
email?: string | null;
|
|
40
|
+
displayName?: string | null;
|
|
41
|
+
role?: string;
|
|
42
|
+
emailVerified?: boolean;
|
|
43
|
+
accountStatus?: AuthHonoAccountStatus;
|
|
44
|
+
approvalDueAt?: Date | null;
|
|
45
|
+
updatedAt?: Date;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AuthHonoUserPort {
|
|
49
|
+
findById(userId: string): Promise<AuthHonoUserRecord | null>;
|
|
50
|
+
findByEmail(email: string): Promise<AuthHonoUserRecord | null>;
|
|
51
|
+
create(input: AuthHonoCreateUserInput): Promise<AuthHonoUserRecord>;
|
|
52
|
+
update(userId: string, input: AuthHonoUpdateUserInput): Promise<AuthHonoUserRecord | null>;
|
|
53
|
+
count(): Promise<number>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AuthHonoCredentialRecord {
|
|
57
|
+
id: string;
|
|
58
|
+
userId: string;
|
|
59
|
+
credentialId: string;
|
|
60
|
+
publicKey: Uint8Array | ArrayBuffer | string;
|
|
61
|
+
counter: number;
|
|
62
|
+
transports: string[] | null;
|
|
63
|
+
name: string | null;
|
|
64
|
+
deviceType: string | null;
|
|
65
|
+
backedUp: boolean | null;
|
|
66
|
+
lastUsedAt: Date | null;
|
|
67
|
+
createdAt: Date;
|
|
68
|
+
revokedAt: Date | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AuthHonoCreateCredentialInput {
|
|
72
|
+
userId: string;
|
|
73
|
+
credentialId: string;
|
|
74
|
+
publicKey: Uint8Array | ArrayBuffer | string;
|
|
75
|
+
counter: number;
|
|
76
|
+
transports?: string[] | null;
|
|
77
|
+
name?: string | null;
|
|
78
|
+
deviceType?: string | null;
|
|
79
|
+
backedUp?: boolean | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AuthHonoCredentialPort {
|
|
83
|
+
findById(credentialRecordId: string): Promise<AuthHonoCredentialRecord | null>;
|
|
84
|
+
findByCredentialId(credentialId: string): Promise<AuthHonoCredentialRecord | null>;
|
|
85
|
+
listForUser(userId: string): Promise<AuthHonoCredentialRecord[]>;
|
|
86
|
+
create(input: AuthHonoCreateCredentialInput): Promise<AuthHonoCredentialRecord>;
|
|
87
|
+
updateCounter(credentialId: string, counter: number, lastUsedAt?: Date): Promise<void>;
|
|
88
|
+
rename(credentialRecordId: string, userId: string, name: string): Promise<AuthHonoCredentialRecord | null>;
|
|
89
|
+
revoke(credentialRecordId: string, userId: string): Promise<boolean>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface AuthHonoChallengeRecord {
|
|
93
|
+
id: string;
|
|
94
|
+
challenge: string;
|
|
95
|
+
userId: string | null;
|
|
96
|
+
type: AuthHonoChallengeType;
|
|
97
|
+
expiresAt: Date;
|
|
98
|
+
used: boolean;
|
|
99
|
+
createdAt: Date;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface AuthHonoCreateChallengeInput {
|
|
103
|
+
challenge: string;
|
|
104
|
+
userId?: string | null;
|
|
105
|
+
type: AuthHonoChallengeType;
|
|
106
|
+
expiresAt: Date;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface AuthHonoChallengePort {
|
|
110
|
+
create(input: AuthHonoCreateChallengeInput): Promise<AuthHonoChallengeRecord>;
|
|
111
|
+
findValid(challenge: string, type: AuthHonoChallengeType): Promise<AuthHonoChallengeRecord | null>;
|
|
112
|
+
markUsed(challenge: string): Promise<void>;
|
|
113
|
+
purgeExpired(now: Date): Promise<number>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface AuthHonoSessionRecord {
|
|
117
|
+
id: string;
|
|
118
|
+
userId: string;
|
|
119
|
+
sessionTokenHash: string;
|
|
120
|
+
refreshTokenHash: string | null;
|
|
121
|
+
deviceName: string | null;
|
|
122
|
+
ipAddress: string | null;
|
|
123
|
+
userAgent: string | null;
|
|
124
|
+
mfaVerified: boolean;
|
|
125
|
+
expiresAt: Date;
|
|
126
|
+
createdAt: Date;
|
|
127
|
+
lastActivityAt: Date;
|
|
128
|
+
revokedAt: Date | null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface AuthHonoCreateSessionInput {
|
|
132
|
+
id: string;
|
|
133
|
+
userId: string;
|
|
134
|
+
sessionTokenHash: string;
|
|
135
|
+
refreshTokenHash?: string | null;
|
|
136
|
+
deviceInfo?: AuthHonoDeviceInfo;
|
|
137
|
+
mfaVerified?: boolean;
|
|
138
|
+
expiresAt: Date;
|
|
139
|
+
now: Date;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface AuthHonoSessionPort {
|
|
143
|
+
create(input: AuthHonoCreateSessionInput): Promise<AuthHonoSessionRecord>;
|
|
144
|
+
findById(sessionId: string): Promise<AuthHonoSessionRecord | null>;
|
|
145
|
+
findByTokenHash(sessionTokenHash: string): Promise<AuthHonoSessionRecord | null>;
|
|
146
|
+
findByRefreshTokenHash(refreshTokenHash: string): Promise<AuthHonoSessionRecord | null>;
|
|
147
|
+
touch(sessionId: string, now: Date): Promise<void>;
|
|
148
|
+
updateTokens(input: {
|
|
149
|
+
expiresAt: Date;
|
|
150
|
+
refreshTokenHash: string;
|
|
151
|
+
sessionId: string;
|
|
152
|
+
sessionTokenHash: string;
|
|
153
|
+
}): Promise<AuthHonoSessionRecord | null>;
|
|
154
|
+
revoke(sessionId: string): Promise<boolean>;
|
|
155
|
+
revokeAllForUser(userId: string): Promise<number>;
|
|
156
|
+
listForUser(userId: string): Promise<AuthHonoSessionRecord[]>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface AuthHonoEmailVerificationRecord {
|
|
160
|
+
id: string;
|
|
161
|
+
email: string;
|
|
162
|
+
codeHash: string;
|
|
163
|
+
verificationToken: string | null;
|
|
164
|
+
expiresAt: Date;
|
|
165
|
+
used: boolean;
|
|
166
|
+
createdAt: Date;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface AuthHonoEmailVerificationPort {
|
|
170
|
+
countRecent(email: string, since: Date): Promise<number>;
|
|
171
|
+
createCode(input: {
|
|
172
|
+
email: string;
|
|
173
|
+
codeHash: string;
|
|
174
|
+
expiresAt: Date;
|
|
175
|
+
now: Date;
|
|
176
|
+
}): Promise<AuthHonoEmailVerificationRecord>;
|
|
177
|
+
findLatestValidCode(email: string, codeHash: string, now: Date): Promise<AuthHonoEmailVerificationRecord | null>;
|
|
178
|
+
markUsedWithVerificationToken(id: string, verificationToken: string): Promise<void>;
|
|
179
|
+
verifyToken(email: string, verificationToken: string, now: Date): Promise<boolean>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface AuthHonoMagicLinkRecord {
|
|
183
|
+
id: string;
|
|
184
|
+
tokenHash: string;
|
|
185
|
+
email: string;
|
|
186
|
+
userId: string | null;
|
|
187
|
+
expiresAt: Date;
|
|
188
|
+
used: boolean;
|
|
189
|
+
createdAt: Date;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface AuthHonoMagicLinkPort {
|
|
193
|
+
create(input: {
|
|
194
|
+
email: string;
|
|
195
|
+
tokenHash: string;
|
|
196
|
+
userId?: string | null;
|
|
197
|
+
expiresAt: Date;
|
|
198
|
+
now: Date;
|
|
199
|
+
}): Promise<AuthHonoMagicLinkRecord>;
|
|
200
|
+
findValidByTokenHash(tokenHash: string, now: Date): Promise<AuthHonoMagicLinkRecord | null>;
|
|
201
|
+
markUsed(id: string, userId?: string | null): Promise<void>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface AuthHonoEmailDeliveryPort {
|
|
205
|
+
sendVerificationCode(input: { email: string; code: string; expiresAt: Date }): Promise<void>;
|
|
206
|
+
sendMagicLink(input: { email: string; token: string; expiresAt: Date; url: string }): Promise<void>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface AuthHonoCookiePort {
|
|
210
|
+
readSessionToken(request: Request): string | null;
|
|
211
|
+
readRefreshToken(request: Request): string | null;
|
|
212
|
+
serializeSessionCookie(input: { token: string; expiresAt: Date }): string;
|
|
213
|
+
serializeRefreshCookie(input: { token: string; expiresAt: Date }): string;
|
|
214
|
+
serializeClearedSessionCookie(): string;
|
|
215
|
+
serializeClearedRefreshCookie(): string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface AuthHonoSessionClaims {
|
|
219
|
+
userId: string;
|
|
220
|
+
sessionId: string;
|
|
221
|
+
role: string;
|
|
222
|
+
email?: string | null;
|
|
223
|
+
displayName?: string | null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface AuthHonoTokenPort {
|
|
227
|
+
hashSecret(secret: string): Promise<string> | string;
|
|
228
|
+
signSessionToken(claims: AuthHonoSessionClaims, expiresAt: Date): Promise<string>;
|
|
229
|
+
verifySessionToken(token: string): Promise<AuthHonoSessionClaims | null>;
|
|
230
|
+
signVerificationToken(input: { email: string; expiresAt: Date }): Promise<string>;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export type AuthHonoAuditLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
234
|
+
|
|
235
|
+
export interface AuthHonoAuditLogPort {
|
|
236
|
+
record(level: AuthHonoAuditLevel, event: string, fields?: Record<string, unknown>): Promise<void> | void;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface AuthHonoClockPort {
|
|
240
|
+
now(): Date;
|
|
241
|
+
addSeconds(date: Date, seconds: number): Date;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface AuthHonoRandomPort {
|
|
245
|
+
uuid(): string;
|
|
246
|
+
bytes(length: number): Uint8Array;
|
|
247
|
+
numericCode(length: number): string;
|
|
248
|
+
token(bytes: number): string;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface AuthHonoAccountPolicyDecision {
|
|
252
|
+
allowed: boolean;
|
|
253
|
+
status?: number;
|
|
254
|
+
code?: string;
|
|
255
|
+
message?: string;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface AuthHonoAccountPolicyPort {
|
|
259
|
+
normalizeEmail(email: string): string;
|
|
260
|
+
deriveDisplayName(email: string): string;
|
|
261
|
+
resolveUserVerification?(user: AuthHonoUserRecord): 'discouraged' | 'preferred' | 'required';
|
|
262
|
+
roleForNewUser(input: { email: string; isFirstUser: boolean }): Promise<string> | string;
|
|
263
|
+
statusForNewUser(input: { email: string; isFirstUser: boolean; now: Date }): Promise<{
|
|
264
|
+
accountStatus: AuthHonoAccountStatus;
|
|
265
|
+
approvalDueAt: Date | null;
|
|
266
|
+
}> | {
|
|
267
|
+
accountStatus: AuthHonoAccountStatus;
|
|
268
|
+
approvalDueAt: Date | null;
|
|
269
|
+
};
|
|
270
|
+
canAuthenticate(user: AuthHonoUserRecord, now: Date): Promise<AuthHonoAccountPolicyDecision> | AuthHonoAccountPolicyDecision;
|
|
271
|
+
resolveSessionRole(user: AuthHonoUserRecord, now: Date): Promise<string> | string;
|
|
272
|
+
afterUserCreated?(user: AuthHonoUserRecord): Promise<void> | void;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface AuthHonoPorts {
|
|
276
|
+
users: AuthHonoUserPort;
|
|
277
|
+
credentials: AuthHonoCredentialPort;
|
|
278
|
+
challenges: AuthHonoChallengePort;
|
|
279
|
+
sessions: AuthHonoSessionPort;
|
|
280
|
+
emailVerification: AuthHonoEmailVerificationPort;
|
|
281
|
+
magicLinks: AuthHonoMagicLinkPort;
|
|
282
|
+
emailDelivery: AuthHonoEmailDeliveryPort;
|
|
283
|
+
cookies: AuthHonoCookiePort;
|
|
284
|
+
tokens: AuthHonoTokenPort;
|
|
285
|
+
auditLog: AuthHonoAuditLogPort;
|
|
286
|
+
clock: AuthHonoClockPort;
|
|
287
|
+
random: AuthHonoRandomPort;
|
|
288
|
+
accountPolicy: AuthHonoAccountPolicyPort;
|
|
289
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import type { AuthHonoEmailVerificationService } from './email-verification.js';
|
|
6
|
+
import type { AuthHonoMagicLinkService, AuthHonoRequestMagicLinkResult } from './magic-link.js';
|
|
7
|
+
import type { AuthHonoRouteHandlers } from './router.js';
|
|
8
|
+
|
|
9
|
+
export interface CreateAuthEmailRouteHandlersOptions {
|
|
10
|
+
service: AuthHonoEmailVerificationService;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CreateAuthMagicLinkRouteHandlersOptions {
|
|
14
|
+
formatRequestMagicLinkSuccess?: (
|
|
15
|
+
result: Extract<AuthHonoRequestMagicLinkResult, { success: true }>
|
|
16
|
+
) => AuthHonoMagicLinkRequestSuccessResponse;
|
|
17
|
+
service: AuthHonoMagicLinkService;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AuthHonoMagicLinkRequestSuccessResponse {
|
|
21
|
+
success: true;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface AuthHonoHttpServiceError {
|
|
26
|
+
code: string;
|
|
27
|
+
message: string;
|
|
28
|
+
status: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const requestEmailCodeSchema = z.object({
|
|
32
|
+
email: z.string().email(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const verifyEmailCodeSchema = z.object({
|
|
36
|
+
code: z.string().regex(/^\d{6}$/),
|
|
37
|
+
email: z.string().email(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const requestMagicLinkSchema = z.object({
|
|
41
|
+
email: z.string().email(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const verifyMagicLinkSchema = z.object({
|
|
45
|
+
token: z.string().min(1),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const createAuthEmailRouteHandlers = (
|
|
49
|
+
options: CreateAuthEmailRouteHandlersOptions
|
|
50
|
+
): AuthHonoRouteHandlers => ({
|
|
51
|
+
async requestEmailCode(c) {
|
|
52
|
+
const input = await parseJson(c, requestEmailCodeSchema);
|
|
53
|
+
|
|
54
|
+
if (!input.ok) {
|
|
55
|
+
return invalidRequest(c, input.error);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = await options.service.requestEmailCode(input.value);
|
|
59
|
+
|
|
60
|
+
if (!result.success) {
|
|
61
|
+
return serviceError(c, result.error);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return c.json({
|
|
65
|
+
delivery: 'email',
|
|
66
|
+
expiresAt: result.expiresAt.toISOString(),
|
|
67
|
+
success: true,
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async verifyEmailCode(c) {
|
|
72
|
+
const input = await parseJson(c, verifyEmailCodeSchema);
|
|
73
|
+
|
|
74
|
+
if (!input.ok) {
|
|
75
|
+
return invalidRequest(c, input.error);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const result = await options.service.verifyEmailCode(input.value);
|
|
79
|
+
|
|
80
|
+
if (!result.valid) {
|
|
81
|
+
return serviceError(c, result.error);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return c.json({
|
|
85
|
+
success: true,
|
|
86
|
+
verificationToken: result.verificationToken,
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export const createAuthMagicLinkRouteHandlers = (
|
|
92
|
+
options: CreateAuthMagicLinkRouteHandlersOptions
|
|
93
|
+
): AuthHonoRouteHandlers => ({
|
|
94
|
+
async requestMagicLink(c) {
|
|
95
|
+
const input = await parseJson(c, requestMagicLinkSchema);
|
|
96
|
+
|
|
97
|
+
if (!input.ok) {
|
|
98
|
+
return invalidRequest(c, input.error);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result = await options.service.requestMagicLink(input.value);
|
|
102
|
+
|
|
103
|
+
if (!result.success) {
|
|
104
|
+
return serviceError(c, result.error);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return c.json(formatRequestMagicLinkSuccess(options, result));
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
async verifyMagicLink(c) {
|
|
111
|
+
const input = await parseJson(c, verifyMagicLinkSchema);
|
|
112
|
+
|
|
113
|
+
if (!input.ok) {
|
|
114
|
+
return invalidRequest(c, input.error);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const result = await options.service.verifyMagicLink(input.value);
|
|
118
|
+
|
|
119
|
+
if (!result.valid) {
|
|
120
|
+
return serviceError(c, result.error);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return c.json({
|
|
124
|
+
success: true,
|
|
125
|
+
user: {
|
|
126
|
+
displayName: result.user.displayName,
|
|
127
|
+
email: result.email,
|
|
128
|
+
id: result.user.id,
|
|
129
|
+
role: result.user.role,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const formatRequestMagicLinkSuccess = (
|
|
136
|
+
options: CreateAuthMagicLinkRouteHandlersOptions,
|
|
137
|
+
result: Extract<AuthHonoRequestMagicLinkResult, { success: true }>
|
|
138
|
+
): AuthHonoMagicLinkRequestSuccessResponse =>
|
|
139
|
+
options.formatRequestMagicLinkSuccess
|
|
140
|
+
? options.formatRequestMagicLinkSuccess(result)
|
|
141
|
+
: {
|
|
142
|
+
delivery: 'magic_link',
|
|
143
|
+
expiresAt: result.expiresAt.toISOString(),
|
|
144
|
+
success: true,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const parseJson = async <T extends z.ZodTypeAny>(
|
|
148
|
+
c: Context,
|
|
149
|
+
schema: T
|
|
150
|
+
): Promise<{ ok: true; value: z.infer<T> } | { error: z.ZodError; ok: false }> => {
|
|
151
|
+
const body = await c.req.json().catch(() => null);
|
|
152
|
+
const parsed = schema.safeParse(body);
|
|
153
|
+
|
|
154
|
+
if (!parsed.success) {
|
|
155
|
+
return { error: parsed.error, ok: false };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { ok: true, value: parsed.data };
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const invalidRequest = (c: Context, error: z.ZodError): Response =>
|
|
162
|
+
c.json(
|
|
163
|
+
{
|
|
164
|
+
error: {
|
|
165
|
+
code: 'invalid_input',
|
|
166
|
+
details: error.errors,
|
|
167
|
+
message: 'Invalid request data.',
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
400
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const serviceError = (c: Context, error: AuthHonoHttpServiceError): Response =>
|
|
174
|
+
c.json(
|
|
175
|
+
{
|
|
176
|
+
error: {
|
|
177
|
+
code: error.code,
|
|
178
|
+
message: error.message,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
error.status as ContentfulStatusCode
|
|
182
|
+
);
|