my-crud-lib 2.0.0 → 2.1.0
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/CHANGELOG.md +45 -0
- package/README.md +41 -1
- package/RELEASE.md +4 -0
- package/dist/adapter-prisma.d.ts +1 -1
- package/dist/adapter-prisma.js +1 -1
- package/dist/adapters/prisma.d.ts +5 -0
- package/dist/adapters/prisma.js +125 -0
- package/dist/auth.d.ts +3 -3
- package/dist/auth.js +1 -1
- package/dist/core/ports/user.repo.d.ts +2 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/modules/auth/auth.controller.js +73 -1
- package/dist/modules/auth/auth.schemas.d.ts +35 -0
- package/dist/modules/auth/auth.schemas.js +13 -0
- package/dist/modules/auth/auth.service.d.ts +19 -1
- package/dist/modules/auth/auth.service.js +219 -8
- package/dist/modules/auth/auth.types.d.ts +130 -1
- package/dist/modules/user/user.types.d.ts +1 -0
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.js +1 -1
- package/dist/utils/jwt.d.ts +2 -0
- package/docs/auth-extensions.md +120 -0
- package/docs/github-self-hosted-runner.md +76 -0
- package/docs/migration-v2.md +131 -0
- package/examples/express-prisma/prisma/schema.prisma +69 -8
- package/package.json +5 -5
- package/prisma/schema.prisma +69 -8
- package/prisma.config.ts +6 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import bcrypt from 'bcryptjs';
|
|
2
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
3
|
import { signAccessToken, signRefreshToken, verifyToken } from '../../utils/jwt.js';
|
|
3
4
|
import { resolveRegisterRole } from './auth.defaults.js';
|
|
5
|
+
const DEFAULT_REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
6
|
+
const DEFAULT_PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
|
|
7
|
+
const DEFAULT_EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
4
8
|
function resolvePasswordHashRounds(configured) {
|
|
5
9
|
const rounds = configured ?? Number(process.env.BCRYPT_SALT);
|
|
6
10
|
return Number.isInteger(rounds) && rounds > 0 ? rounds : 10;
|
|
@@ -9,15 +13,185 @@ function toAuthUser(user) {
|
|
|
9
13
|
const { passwordHash: _passwordHash, ...safeUser } = user;
|
|
10
14
|
return safeUser;
|
|
11
15
|
}
|
|
12
|
-
function
|
|
16
|
+
function getNow(deps) {
|
|
17
|
+
return deps.now?.() ?? new Date();
|
|
18
|
+
}
|
|
19
|
+
function addMs(date, ms) {
|
|
20
|
+
return new Date(date.getTime() + ms);
|
|
21
|
+
}
|
|
22
|
+
function isExpired(value, now) {
|
|
23
|
+
return new Date(value).getTime() <= now.getTime();
|
|
24
|
+
}
|
|
25
|
+
function createId(deps) {
|
|
26
|
+
return deps.idFactory?.() ?? randomUUID();
|
|
27
|
+
}
|
|
28
|
+
function createSecret() {
|
|
29
|
+
return randomBytes(32).toString('base64url');
|
|
30
|
+
}
|
|
31
|
+
function createOpaqueToken(deps) {
|
|
32
|
+
const id = createId(deps);
|
|
33
|
+
return { id, token: `${id}.${createSecret()}` };
|
|
34
|
+
}
|
|
35
|
+
function parseOpaqueToken(token) {
|
|
36
|
+
const [id, secret, ...rest] = token.split('.');
|
|
37
|
+
if (!id || !secret || rest.length > 0)
|
|
38
|
+
return null;
|
|
39
|
+
return { id };
|
|
40
|
+
}
|
|
41
|
+
function hashToken(token, secret) {
|
|
42
|
+
if (secret)
|
|
43
|
+
return createHmac('sha256', secret).update(token).digest('hex');
|
|
44
|
+
return createHash('sha256').update(token).digest('hex');
|
|
45
|
+
}
|
|
46
|
+
function safeEqual(a, b) {
|
|
47
|
+
const left = Buffer.from(a);
|
|
48
|
+
const right = Buffer.from(b);
|
|
49
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
50
|
+
}
|
|
51
|
+
async function issueTokens(user, deps, familyId) {
|
|
13
52
|
const payload = { sub: user.id, role: user.role };
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
53
|
+
const accessToken = signAccessToken(payload);
|
|
54
|
+
if (!deps.refreshTokenRepo) {
|
|
55
|
+
return {
|
|
56
|
+
accessToken,
|
|
57
|
+
refreshToken: signRefreshToken(payload),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const refreshTokenId = createId(deps);
|
|
61
|
+
const resolvedFamilyId = familyId ?? createId(deps);
|
|
62
|
+
const refreshToken = signRefreshToken({ ...payload, jti: refreshTokenId, fam: resolvedFamilyId });
|
|
63
|
+
const now = getNow(deps);
|
|
64
|
+
const expiresAt = addMs(now, deps.refreshTokenTtlMs ?? DEFAULT_REFRESH_TOKEN_TTL_MS);
|
|
65
|
+
await deps.refreshTokenRepo.create({
|
|
66
|
+
id: refreshTokenId,
|
|
67
|
+
userId: user.id,
|
|
68
|
+
tokenHash: hashToken(refreshToken, deps.tokenHashSecret),
|
|
69
|
+
familyId: resolvedFamilyId,
|
|
70
|
+
expiresAt,
|
|
71
|
+
});
|
|
72
|
+
return { accessToken, refreshToken, refreshTokenId, familyId: resolvedFamilyId };
|
|
18
73
|
}
|
|
19
74
|
export function makeAuthService(deps) {
|
|
20
75
|
const { userRepo } = deps;
|
|
76
|
+
async function requestPasswordReset(params) {
|
|
77
|
+
if (!deps.passwordResetTokenRepo || !deps.sendPasswordReset) {
|
|
78
|
+
throw new Error('PASSWORD_RESET_UNSUPPORTED');
|
|
79
|
+
}
|
|
80
|
+
const user = await userRepo.findByEmail(params.email);
|
|
81
|
+
if (!user)
|
|
82
|
+
return { ok: true };
|
|
83
|
+
const now = getNow(deps);
|
|
84
|
+
const expiresAt = addMs(now, deps.passwordResetTtlMs ?? DEFAULT_PASSWORD_RESET_TTL_MS);
|
|
85
|
+
const { id, token } = createOpaqueToken(deps);
|
|
86
|
+
await deps.passwordResetTokenRepo.create({
|
|
87
|
+
id,
|
|
88
|
+
userId: user.id,
|
|
89
|
+
tokenHash: hashToken(token, deps.tokenHashSecret),
|
|
90
|
+
expiresAt,
|
|
91
|
+
});
|
|
92
|
+
await deps.sendPasswordReset({ user: toAuthUser(user), token, expiresAt });
|
|
93
|
+
return { ok: true };
|
|
94
|
+
}
|
|
95
|
+
async function confirmPasswordReset(params) {
|
|
96
|
+
if (!deps.passwordResetTokenRepo || !userRepo.updatePassword) {
|
|
97
|
+
throw new Error('PASSWORD_RESET_UNSUPPORTED');
|
|
98
|
+
}
|
|
99
|
+
const parsed = parseOpaqueToken(params.token);
|
|
100
|
+
if (!parsed)
|
|
101
|
+
throw new Error('INVALID_PASSWORD_RESET_TOKEN');
|
|
102
|
+
const record = await deps.passwordResetTokenRepo.findById(parsed.id);
|
|
103
|
+
const now = getNow(deps);
|
|
104
|
+
if (!record ||
|
|
105
|
+
record.usedAt ||
|
|
106
|
+
record.revokedAt ||
|
|
107
|
+
isExpired(record.expiresAt, now) ||
|
|
108
|
+
!safeEqual(record.tokenHash, hashToken(params.token, deps.tokenHashSecret))) {
|
|
109
|
+
throw new Error('INVALID_PASSWORD_RESET_TOKEN');
|
|
110
|
+
}
|
|
111
|
+
const passwordHash = await bcrypt.hash(params.password, resolvePasswordHashRounds(deps.passwordHashRounds));
|
|
112
|
+
await userRepo.updatePassword(record.userId, passwordHash);
|
|
113
|
+
await deps.passwordResetTokenRepo.markUsed(record.id, { usedAt: now });
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
116
|
+
async function requestEmailVerification(params) {
|
|
117
|
+
if (!deps.emailVerificationTokenRepo || !deps.sendEmailVerification) {
|
|
118
|
+
throw new Error('EMAIL_VERIFICATION_UNSUPPORTED');
|
|
119
|
+
}
|
|
120
|
+
const user = await userRepo.findByEmail(params.email);
|
|
121
|
+
if (!user || user.emailVerifiedAt)
|
|
122
|
+
return { ok: true };
|
|
123
|
+
const now = getNow(deps);
|
|
124
|
+
const expiresAt = addMs(now, deps.emailVerificationTtlMs ?? DEFAULT_EMAIL_VERIFICATION_TTL_MS);
|
|
125
|
+
const { id, token } = createOpaqueToken(deps);
|
|
126
|
+
await deps.emailVerificationTokenRepo.create({
|
|
127
|
+
id,
|
|
128
|
+
userId: user.id,
|
|
129
|
+
tokenHash: hashToken(token, deps.tokenHashSecret),
|
|
130
|
+
expiresAt,
|
|
131
|
+
});
|
|
132
|
+
await deps.sendEmailVerification({ user: toAuthUser(user), token, expiresAt });
|
|
133
|
+
return { ok: true };
|
|
134
|
+
}
|
|
135
|
+
async function confirmEmailVerification(params) {
|
|
136
|
+
if (!deps.emailVerificationTokenRepo || !userRepo.markEmailVerified) {
|
|
137
|
+
throw new Error('EMAIL_VERIFICATION_UNSUPPORTED');
|
|
138
|
+
}
|
|
139
|
+
const parsed = parseOpaqueToken(params.token);
|
|
140
|
+
if (!parsed)
|
|
141
|
+
throw new Error('INVALID_EMAIL_VERIFICATION_TOKEN');
|
|
142
|
+
const record = await deps.emailVerificationTokenRepo.findById(parsed.id);
|
|
143
|
+
const now = getNow(deps);
|
|
144
|
+
if (!record ||
|
|
145
|
+
record.usedAt ||
|
|
146
|
+
record.revokedAt ||
|
|
147
|
+
isExpired(record.expiresAt, now) ||
|
|
148
|
+
!safeEqual(record.tokenHash, hashToken(params.token, deps.tokenHashSecret))) {
|
|
149
|
+
throw new Error('INVALID_EMAIL_VERIFICATION_TOKEN');
|
|
150
|
+
}
|
|
151
|
+
const updated = await userRepo.markEmailVerified(record.userId, now);
|
|
152
|
+
const user = updated ?? await userRepo.findById(record.userId);
|
|
153
|
+
if (!user)
|
|
154
|
+
throw new Error('USER_NOT_FOUND');
|
|
155
|
+
await deps.emailVerificationTokenRepo.markUsed(record.id, { usedAt: now });
|
|
156
|
+
return { ok: true, user: toAuthUser(user) };
|
|
157
|
+
}
|
|
158
|
+
async function signInWithOAuthProfile(profile) {
|
|
159
|
+
if (!deps.oauthAccountRepo)
|
|
160
|
+
throw new Error('OAUTH_UNSUPPORTED');
|
|
161
|
+
if (!profile.provider || !profile.providerAccountId)
|
|
162
|
+
throw new Error('INVALID_OAUTH_PROFILE');
|
|
163
|
+
const linkedAccount = await deps.oauthAccountRepo.findByProviderAccount(profile.provider, profile.providerAccountId);
|
|
164
|
+
if (linkedAccount) {
|
|
165
|
+
const existingUser = await userRepo.findById(linkedAccount.userId);
|
|
166
|
+
if (!existingUser)
|
|
167
|
+
throw new Error('USER_NOT_FOUND');
|
|
168
|
+
return { user: existingUser, ...(await issueTokens(existingUser, deps)) };
|
|
169
|
+
}
|
|
170
|
+
let user = null;
|
|
171
|
+
const canLinkByEmail = deps.linkOAuthByVerifiedEmail !== false && profile.email && profile.emailVerified === true;
|
|
172
|
+
if (canLinkByEmail && profile.email) {
|
|
173
|
+
user = await userRepo.findByEmail(profile.email);
|
|
174
|
+
}
|
|
175
|
+
if (!user) {
|
|
176
|
+
if (!profile.email)
|
|
177
|
+
throw new Error('OAUTH_EMAIL_REQUIRED');
|
|
178
|
+
const passwordHash = await bcrypt.hash(createSecret(), resolvePasswordHashRounds(deps.passwordHashRounds));
|
|
179
|
+
user = await userRepo.create({
|
|
180
|
+
email: profile.email,
|
|
181
|
+
passwordHash,
|
|
182
|
+
name: profile.name ?? null,
|
|
183
|
+
role: resolveRegisterRole(deps.defaultRegisterRole),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
await deps.oauthAccountRepo.create({
|
|
187
|
+
provider: profile.provider,
|
|
188
|
+
providerAccountId: profile.providerAccountId,
|
|
189
|
+
userId: user.id,
|
|
190
|
+
email: profile.email ?? null,
|
|
191
|
+
});
|
|
192
|
+
const authUser = toAuthUser(user);
|
|
193
|
+
return { user: authUser, ...(await issueTokens(authUser, deps)) };
|
|
194
|
+
}
|
|
21
195
|
return {
|
|
22
196
|
async registerUser(params) {
|
|
23
197
|
const exists = await userRepo.findByEmail(params.email);
|
|
@@ -32,7 +206,7 @@ export function makeAuthService(deps) {
|
|
|
32
206
|
role,
|
|
33
207
|
});
|
|
34
208
|
const authUser = toAuthUser(user);
|
|
35
|
-
return { user: authUser, ...issueTokens(authUser) };
|
|
209
|
+
return { user: authUser, ...(await issueTokens(authUser, deps)) };
|
|
36
210
|
},
|
|
37
211
|
async loginUser(params) {
|
|
38
212
|
const user = await userRepo.findByEmail(params.email);
|
|
@@ -42,7 +216,7 @@ export function makeAuthService(deps) {
|
|
|
42
216
|
if (!ok)
|
|
43
217
|
throw new Error('INVALID_CREDENTIALS');
|
|
44
218
|
const authUser = toAuthUser(user);
|
|
45
|
-
return { user: authUser, ...issueTokens(authUser) };
|
|
219
|
+
return { user: authUser, ...(await issueTokens(authUser, deps)) };
|
|
46
220
|
},
|
|
47
221
|
async refreshSession(refreshToken) {
|
|
48
222
|
const payload = verifyToken(refreshToken);
|
|
@@ -51,10 +225,47 @@ export function makeAuthService(deps) {
|
|
|
51
225
|
const user = await userRepo.findById(payload.sub);
|
|
52
226
|
if (!user)
|
|
53
227
|
throw new Error('USER_NOT_FOUND');
|
|
54
|
-
|
|
228
|
+
if (!deps.refreshTokenRepo)
|
|
229
|
+
return issueTokens(user, deps);
|
|
230
|
+
if (!payload.jti || !payload.fam)
|
|
231
|
+
throw new Error('INVALID_REFRESH_TOKEN');
|
|
232
|
+
const record = await deps.refreshTokenRepo.findById(payload.jti);
|
|
233
|
+
const now = getNow(deps);
|
|
234
|
+
if (!record || record.userId.toString() !== payload.sub.toString() || record.familyId !== payload.fam) {
|
|
235
|
+
throw new Error('INVALID_REFRESH_TOKEN');
|
|
236
|
+
}
|
|
237
|
+
if (record.revokedAt) {
|
|
238
|
+
await deps.refreshTokenRepo.revokeFamily?.(record.familyId, { revokedAt: now, reason: 'refresh-token-replay' });
|
|
239
|
+
throw new Error('REFRESH_TOKEN_REPLAYED');
|
|
240
|
+
}
|
|
241
|
+
if (isExpired(record.expiresAt, now) || !safeEqual(record.tokenHash, hashToken(refreshToken, deps.tokenHashSecret))) {
|
|
242
|
+
throw new Error('INVALID_REFRESH_TOKEN');
|
|
243
|
+
}
|
|
244
|
+
const nextTokens = await issueTokens(user, deps, record.familyId);
|
|
245
|
+
await deps.refreshTokenRepo.revoke(record.id, {
|
|
246
|
+
revokedAt: now,
|
|
247
|
+
replacedByTokenId: nextTokens.refreshTokenId ?? null,
|
|
248
|
+
reason: 'rotated',
|
|
249
|
+
});
|
|
250
|
+
return { accessToken: nextTokens.accessToken, refreshToken: nextTokens.refreshToken };
|
|
251
|
+
},
|
|
252
|
+
async revokeRefreshToken(refreshToken) {
|
|
253
|
+
const payload = verifyToken(refreshToken);
|
|
254
|
+
if (payload.typ !== 'refresh')
|
|
255
|
+
throw new Error('INVALID_REFRESH_TOKEN');
|
|
256
|
+
if (deps.refreshTokenRepo && payload.jti) {
|
|
257
|
+
await deps.refreshTokenRepo.revoke(payload.jti, { revokedAt: getNow(deps), reason: 'revoked' });
|
|
258
|
+
}
|
|
259
|
+
return { ok: true };
|
|
55
260
|
},
|
|
56
261
|
getMe(userId) {
|
|
57
262
|
return userRepo.findById(userId);
|
|
58
263
|
},
|
|
264
|
+
requestPasswordReset,
|
|
265
|
+
confirmPasswordReset,
|
|
266
|
+
requestEmailVerification,
|
|
267
|
+
confirmEmailVerification,
|
|
268
|
+
signInWithOAuthProfile,
|
|
269
|
+
loginWithOAuthProfile: signInWithOAuthProfile,
|
|
59
270
|
};
|
|
60
271
|
}
|
|
@@ -1,10 +1,126 @@
|
|
|
1
1
|
import type { UserRepo } from '../../core/ports/user.repo.js';
|
|
2
2
|
import type { Role, UserListItem } from '../user/user.types.js';
|
|
3
|
-
export type AuthUserRepo = Pick<UserRepo, 'create' | 'findByEmail' | 'findById'>;
|
|
3
|
+
export type AuthUserRepo = Pick<UserRepo, 'create' | 'findByEmail' | 'findById'> & Pick<Partial<UserRepo>, 'updatePassword' | 'markEmailVerified'>;
|
|
4
|
+
export type RefreshTokenRecord = {
|
|
5
|
+
id: string;
|
|
6
|
+
userId: number | string;
|
|
7
|
+
tokenHash: string;
|
|
8
|
+
familyId: string;
|
|
9
|
+
expiresAt: Date | string;
|
|
10
|
+
revokedAt?: Date | string | null;
|
|
11
|
+
replacedByTokenId?: string | null;
|
|
12
|
+
};
|
|
13
|
+
export type RefreshTokenRepo = {
|
|
14
|
+
create(input: {
|
|
15
|
+
id: string;
|
|
16
|
+
userId: number | string;
|
|
17
|
+
tokenHash: string;
|
|
18
|
+
familyId: string;
|
|
19
|
+
expiresAt: Date;
|
|
20
|
+
}): Promise<RefreshTokenRecord | void>;
|
|
21
|
+
findById(id: string): Promise<RefreshTokenRecord | null>;
|
|
22
|
+
revoke(id: string, input?: {
|
|
23
|
+
revokedAt?: Date;
|
|
24
|
+
replacedByTokenId?: string | null;
|
|
25
|
+
reason?: string;
|
|
26
|
+
}): Promise<void>;
|
|
27
|
+
revokeFamily?(familyId: string, input?: {
|
|
28
|
+
revokedAt?: Date;
|
|
29
|
+
reason?: string;
|
|
30
|
+
}): Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
export type PasswordResetTokenRecord = {
|
|
33
|
+
id: string;
|
|
34
|
+
userId: number | string;
|
|
35
|
+
tokenHash: string;
|
|
36
|
+
expiresAt: Date | string;
|
|
37
|
+
usedAt?: Date | string | null;
|
|
38
|
+
revokedAt?: Date | string | null;
|
|
39
|
+
};
|
|
40
|
+
export type PasswordResetTokenRepo = {
|
|
41
|
+
create(input: {
|
|
42
|
+
id: string;
|
|
43
|
+
userId: number | string;
|
|
44
|
+
tokenHash: string;
|
|
45
|
+
expiresAt: Date;
|
|
46
|
+
}): Promise<PasswordResetTokenRecord | void>;
|
|
47
|
+
findById(id: string): Promise<PasswordResetTokenRecord | null>;
|
|
48
|
+
markUsed(id: string, input?: {
|
|
49
|
+
usedAt?: Date;
|
|
50
|
+
}): Promise<void>;
|
|
51
|
+
revoke?(id: string, input?: {
|
|
52
|
+
revokedAt?: Date;
|
|
53
|
+
reason?: string;
|
|
54
|
+
}): Promise<void>;
|
|
55
|
+
};
|
|
56
|
+
export type EmailVerificationTokenRecord = {
|
|
57
|
+
id: string;
|
|
58
|
+
userId: number | string;
|
|
59
|
+
tokenHash: string;
|
|
60
|
+
expiresAt: Date | string;
|
|
61
|
+
usedAt?: Date | string | null;
|
|
62
|
+
revokedAt?: Date | string | null;
|
|
63
|
+
};
|
|
64
|
+
export type EmailVerificationTokenRepo = {
|
|
65
|
+
create(input: {
|
|
66
|
+
id: string;
|
|
67
|
+
userId: number | string;
|
|
68
|
+
tokenHash: string;
|
|
69
|
+
expiresAt: Date;
|
|
70
|
+
}): Promise<EmailVerificationTokenRecord | void>;
|
|
71
|
+
findById(id: string): Promise<EmailVerificationTokenRecord | null>;
|
|
72
|
+
markUsed(id: string, input?: {
|
|
73
|
+
usedAt?: Date;
|
|
74
|
+
}): Promise<void>;
|
|
75
|
+
revoke?(id: string, input?: {
|
|
76
|
+
revokedAt?: Date;
|
|
77
|
+
reason?: string;
|
|
78
|
+
}): Promise<void>;
|
|
79
|
+
};
|
|
80
|
+
export type OAuthProviderProfile = {
|
|
81
|
+
provider: string;
|
|
82
|
+
providerAccountId: string;
|
|
83
|
+
email?: string | null;
|
|
84
|
+
emailVerified?: boolean;
|
|
85
|
+
name?: string | null;
|
|
86
|
+
avatarUrl?: string | null;
|
|
87
|
+
};
|
|
88
|
+
export type OAuthAccount = {
|
|
89
|
+
provider: string;
|
|
90
|
+
providerAccountId: string;
|
|
91
|
+
userId: number | string;
|
|
92
|
+
email?: string | null;
|
|
93
|
+
};
|
|
94
|
+
export type OAuthAccountRepo = {
|
|
95
|
+
findByProviderAccount(provider: string, providerAccountId: string): Promise<OAuthAccount | null>;
|
|
96
|
+
findByUserAndProvider?(userId: number | string, provider: string): Promise<OAuthAccount | null>;
|
|
97
|
+
create(input: OAuthAccount): Promise<OAuthAccount | void>;
|
|
98
|
+
};
|
|
4
99
|
export type AuthServiceDeps = {
|
|
5
100
|
userRepo: AuthUserRepo;
|
|
6
101
|
passwordHashRounds?: number;
|
|
7
102
|
defaultRegisterRole?: Role;
|
|
103
|
+
refreshTokenRepo?: RefreshTokenRepo;
|
|
104
|
+
refreshTokenTtlMs?: number;
|
|
105
|
+
passwordResetTokenRepo?: PasswordResetTokenRepo;
|
|
106
|
+
passwordResetTtlMs?: number;
|
|
107
|
+
sendPasswordReset?: (input: {
|
|
108
|
+
user: AuthUser;
|
|
109
|
+
token: string;
|
|
110
|
+
expiresAt: Date;
|
|
111
|
+
}) => Promise<void> | void;
|
|
112
|
+
emailVerificationTokenRepo?: EmailVerificationTokenRepo;
|
|
113
|
+
emailVerificationTtlMs?: number;
|
|
114
|
+
sendEmailVerification?: (input: {
|
|
115
|
+
user: AuthUser;
|
|
116
|
+
token: string;
|
|
117
|
+
expiresAt: Date;
|
|
118
|
+
}) => Promise<void> | void;
|
|
119
|
+
oauthAccountRepo?: OAuthAccountRepo;
|
|
120
|
+
linkOAuthByVerifiedEmail?: boolean;
|
|
121
|
+
tokenHashSecret?: string;
|
|
122
|
+
idFactory?: () => string;
|
|
123
|
+
now?: () => Date;
|
|
8
124
|
};
|
|
9
125
|
export type AuthUser = UserListItem;
|
|
10
126
|
export type AuthTokens = {
|
|
@@ -14,3 +130,16 @@ export type AuthTokens = {
|
|
|
14
130
|
export type AuthResult = AuthTokens & {
|
|
15
131
|
user: AuthUser;
|
|
16
132
|
};
|
|
133
|
+
export type PasswordResetRequestInput = {
|
|
134
|
+
email: string;
|
|
135
|
+
};
|
|
136
|
+
export type PasswordResetConfirmInput = {
|
|
137
|
+
token: string;
|
|
138
|
+
password: string;
|
|
139
|
+
};
|
|
140
|
+
export type EmailVerificationRequestInput = {
|
|
141
|
+
email: string;
|
|
142
|
+
};
|
|
143
|
+
export type EmailVerificationConfirmInput = {
|
|
144
|
+
token: string;
|
|
145
|
+
};
|
package/dist/schemas.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { loginSchema, registerSchema } from './modules/auth/auth.schemas.js';
|
|
1
|
+
export { emailVerificationConfirmSchema, emailVerificationRequestSchema, loginSchema, passwordResetConfirmSchema, passwordResetRequestSchema, registerSchema, } from './modules/auth/auth.schemas.js';
|
|
2
2
|
export { SortEnum, adminCreateUserSchema, adminUpdateUserSchema, listUsersQuerySchema, updateMeSchema, } from './modules/user/user.schemas.js';
|
package/dist/schemas.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { loginSchema, registerSchema } from './modules/auth/auth.schemas.js';
|
|
1
|
+
export { emailVerificationConfirmSchema, emailVerificationRequestSchema, loginSchema, passwordResetConfirmSchema, passwordResetRequestSchema, registerSchema, } from './modules/auth/auth.schemas.js';
|
|
2
2
|
export { SortEnum, adminCreateUserSchema, adminUpdateUserSchema, listUsersQuerySchema, updateMeSchema, } from './modules/user/user.schemas.js';
|
package/dist/utils/jwt.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ export type JwtPayload = {
|
|
|
2
2
|
sub: number | string;
|
|
3
3
|
role: "USER" | "ADMIN";
|
|
4
4
|
typ?: "refresh";
|
|
5
|
+
jti?: string;
|
|
6
|
+
fam?: string;
|
|
5
7
|
};
|
|
6
8
|
export declare function signAccessToken(payload: JwtPayload): string;
|
|
7
9
|
export declare function signRefreshToken(payload: JwtPayload): string;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Auth extensions
|
|
2
|
+
|
|
3
|
+
The core auth flow remains stateless and adapter-driven. Advanced auth features are enabled only when the corresponding repository and hook dependencies are passed to `makeAuthService()` or `createAuthRouter()`.
|
|
4
|
+
|
|
5
|
+
## Persistent refresh token rotation
|
|
6
|
+
|
|
7
|
+
By default, refresh tokens are stateless JWTs. This is simple and works for small apps, but a stolen refresh token remains valid until expiry unless you rotate the signing secret.
|
|
8
|
+
|
|
9
|
+
Persistent refresh tokens add server-side state:
|
|
10
|
+
|
|
11
|
+
- Refresh JWTs include a token ID and token family ID.
|
|
12
|
+
- The repository stores a hash of the refresh token, never the raw token.
|
|
13
|
+
- `/auth/refresh` rotates the refresh token and revokes the previous token.
|
|
14
|
+
- Reusing a revoked refresh token is treated as replay and revokes the full token family when the repository supports `revokeFamily`.
|
|
15
|
+
- `/auth/logout` revokes the submitted refresh token.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createAuthRouter } from "my-crud-lib/auth";
|
|
19
|
+
import {
|
|
20
|
+
makePrismaRefreshTokenRepo,
|
|
21
|
+
makePrismaUserRepo,
|
|
22
|
+
} from "my-crud-lib/adapter-prisma";
|
|
23
|
+
|
|
24
|
+
app.use(
|
|
25
|
+
"/auth",
|
|
26
|
+
createAuthRouter({
|
|
27
|
+
userRepo: makePrismaUserRepo(prisma),
|
|
28
|
+
refreshTokenRepo: makePrismaRefreshTokenRepo(prisma),
|
|
29
|
+
})
|
|
30
|
+
);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Password reset
|
|
34
|
+
|
|
35
|
+
Password reset is app-owned for delivery. The library creates a one-time opaque token, stores only its hash, and calls your delivery hook.
|
|
36
|
+
|
|
37
|
+
Routes:
|
|
38
|
+
|
|
39
|
+
- `POST /auth/password-reset/request`
|
|
40
|
+
- `POST /auth/password-reset/confirm`
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import {
|
|
44
|
+
makePrismaPasswordResetTokenRepo,
|
|
45
|
+
makePrismaUserRepo,
|
|
46
|
+
} from "my-crud-lib/adapter-prisma";
|
|
47
|
+
|
|
48
|
+
createAuthRouter({
|
|
49
|
+
userRepo: makePrismaUserRepo(prisma),
|
|
50
|
+
passwordResetTokenRepo: makePrismaPasswordResetTokenRepo(prisma),
|
|
51
|
+
async sendPasswordReset({ user, token, expiresAt }) {
|
|
52
|
+
await emailProvider.sendPasswordReset(user.email, token, expiresAt);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Security expectations:
|
|
58
|
+
|
|
59
|
+
- Return a generic success response for unknown emails.
|
|
60
|
+
- Keep reset token TTLs short.
|
|
61
|
+
- Send the token only through an out-of-band channel controlled by the application.
|
|
62
|
+
- Require HTTPS in production.
|
|
63
|
+
|
|
64
|
+
## Email verification
|
|
65
|
+
|
|
66
|
+
Email verification uses the same one-time token pattern as password reset. Delivery stays app-owned.
|
|
67
|
+
|
|
68
|
+
Routes:
|
|
69
|
+
|
|
70
|
+
- `POST /auth/email-verification/request`
|
|
71
|
+
- `POST /auth/email-verification/confirm`
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import {
|
|
75
|
+
makePrismaEmailVerificationTokenRepo,
|
|
76
|
+
makePrismaUserRepo,
|
|
77
|
+
} from "my-crud-lib/adapter-prisma";
|
|
78
|
+
|
|
79
|
+
createAuthRouter({
|
|
80
|
+
userRepo: makePrismaUserRepo(prisma),
|
|
81
|
+
emailVerificationTokenRepo: makePrismaEmailVerificationTokenRepo(prisma),
|
|
82
|
+
async sendEmailVerification({ user, token, expiresAt }) {
|
|
83
|
+
await emailProvider.sendVerification(user.email, token, expiresAt);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The bundled Prisma schema includes `User.emailVerifiedAt`. Existing Prisma users only need to add that column when they enable email verification. Apps should gate privileged behavior according to their own rules.
|
|
89
|
+
|
|
90
|
+
## OAuth and social login
|
|
91
|
+
|
|
92
|
+
OAuth provider redirects, scopes, and provider SDKs remain outside the core package. The auth service exposes a provider-agnostic account-linking method:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import {
|
|
96
|
+
makeAuthService,
|
|
97
|
+
type OAuthProviderProfile,
|
|
98
|
+
} from "my-crud-lib/auth";
|
|
99
|
+
import {
|
|
100
|
+
makePrismaOAuthAccountRepo,
|
|
101
|
+
makePrismaUserRepo,
|
|
102
|
+
} from "my-crud-lib/adapter-prisma";
|
|
103
|
+
|
|
104
|
+
const auth = makeAuthService({
|
|
105
|
+
userRepo: makePrismaUserRepo(prisma),
|
|
106
|
+
oauthAccountRepo: makePrismaOAuthAccountRepo(prisma),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const profile: OAuthProviderProfile = {
|
|
110
|
+
provider: "github",
|
|
111
|
+
providerAccountId: githubUser.id,
|
|
112
|
+
email: githubUser.email,
|
|
113
|
+
emailVerified: true,
|
|
114
|
+
name: githubUser.name,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const result = await auth.signInWithOAuthProfile(profile);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
If a provider account is already linked, the linked user is used. Otherwise, a verified email can be linked to an existing user. If no user exists, the service creates one with an unusable generated password hash and links the provider account.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# GitHub self-hosted runner
|
|
2
|
+
|
|
3
|
+
Questa configurazione sposta il calcolo delle pipeline da `ubuntu-latest` al server registrato come runner GitHub Actions.
|
|
4
|
+
|
|
5
|
+
Il runner non elimina GitHub Actions: GitHub resta l'orchestratore, ma i job vengono eseguiti su questa macchina.
|
|
6
|
+
|
|
7
|
+
## Strategia consigliata
|
|
8
|
+
|
|
9
|
+
Per usare lo stesso server su piu progetti, registra il runner a livello di organizzazione GitHub e assegna il label custom `local-ci`.
|
|
10
|
+
|
|
11
|
+
Poi in ogni workflow usa:
|
|
12
|
+
|
|
13
|
+
```yaml
|
|
14
|
+
runs-on: [self-hosted, linux, x64, local-ci]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Se i repository sono sotto un account personale e non sotto un'organizzazione, GitHub non offre un runner unico per tutto l'account: va registrato un runner per ogni repository, oppure conviene spostare i progetti dentro una organizzazione.
|
|
18
|
+
|
|
19
|
+
## Registrazione da interfaccia GitHub
|
|
20
|
+
|
|
21
|
+
Organizzazione:
|
|
22
|
+
|
|
23
|
+
1. Vai su `Organization settings > Actions > Runners`.
|
|
24
|
+
2. Crea un nuovo self-hosted runner Linux x64.
|
|
25
|
+
3. Aggiungi il label `local-ci`.
|
|
26
|
+
4. Se usi runner group, limita l'accesso ai repository che devono usare questo server.
|
|
27
|
+
|
|
28
|
+
Repository singolo:
|
|
29
|
+
|
|
30
|
+
1. Vai su `Repository settings > Actions > Runners`.
|
|
31
|
+
2. Crea un nuovo self-hosted runner Linux x64.
|
|
32
|
+
3. Aggiungi il label `local-ci`.
|
|
33
|
+
|
|
34
|
+
## Installazione sul server
|
|
35
|
+
|
|
36
|
+
Genera il token temporaneo dalla pagina GitHub del runner e poi esegui:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
GITHUB_URL="https://github.com/OWNER_OR_ORG" \
|
|
40
|
+
RUNNER_TOKEN="TOKEN_GENERATO_DA_GITHUB" \
|
|
41
|
+
RUNNER_NAME="server-ci-01" \
|
|
42
|
+
scripts/setup-github-runner.sh
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Per un repository singolo:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
GITHUB_URL="https://github.com/OWNER/REPOSITORY" \
|
|
49
|
+
RUNNER_TOKEN="TOKEN_GENERATO_DA_GITHUB" \
|
|
50
|
+
RUNNER_NAME="server-ci-01" \
|
|
51
|
+
scripts/setup-github-runner.sh
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Variabili utili:
|
|
55
|
+
|
|
56
|
+
- `RUNNER_LABELS`: label custom, default `local-ci`.
|
|
57
|
+
- `RUNNER_VERSION`: versione del runner, default `2.334.0`.
|
|
58
|
+
- `RUNNER_USER`: utente Linux del servizio, default `actions-runner`.
|
|
59
|
+
- `INSTALL_DIR`: directory di installazione, default `/opt/actions-runner/$RUNNER_NAME`.
|
|
60
|
+
- `RUNNER_GROUP`: runner group GitHub opzionale, utile nelle organizzazioni.
|
|
61
|
+
|
|
62
|
+
## Requisiti del server
|
|
63
|
+
|
|
64
|
+
Per questo progetto servono Node.js e npm. Il server corrente ha Docker installato; se altri progetti usano container, assicurati che l'utente del runner abbia i permessi necessari.
|
|
65
|
+
|
|
66
|
+
Installa sul server cio che usano le pipeline dei vari repository, ad esempio:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
node --version
|
|
70
|
+
npm --version
|
|
71
|
+
docker --version
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Sicurezza
|
|
75
|
+
|
|
76
|
+
Evita self-hosted runner su repository pubblici che eseguono workflow da pull request non fidate: codice esterno potrebbe girare sul server. Per repo pubblici, usa approvazione manuale dei workflow, runner group ristretti e segreti con scope minimo.
|