@seifer-webapp-factory/authentication 0.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.
Files changed (113) hide show
  1. package/README.md +8 -0
  2. package/backend/templates/config/config-fragment.ts +73 -0
  3. package/backend/templates/mail/templates.ts +84 -0
  4. package/backend/templates/nestjs/auth.controller.ts +274 -0
  5. package/backend/templates/nestjs/auth.module.ts +207 -0
  6. package/backend/templates/nestjs/tokens.ts +24 -0
  7. package/backend/templates/persistence/migrations/0001_auth.sql +36 -0
  8. package/backend/templates/persistence/migrations/index.ts +75 -0
  9. package/backend/templates/persistence/pg-single-use-store.ts +64 -0
  10. package/backend/templates/persistence/pg-token-store.ts +75 -0
  11. package/backend/templates/persistence/pg-user-store.ts +53 -0
  12. package/backend/templates/security/cookies.ts +89 -0
  13. package/backend/templates/security/csrf.ts +44 -0
  14. package/backend/templates/security/headers.ts +30 -0
  15. package/backend/templates/security/redaction.ts +38 -0
  16. package/dist/backend/src/errors.d.ts +12 -0
  17. package/dist/backend/src/errors.d.ts.map +1 -0
  18. package/dist/backend/src/errors.js +55 -0
  19. package/dist/backend/src/errors.js.map +1 -0
  20. package/dist/backend/src/index.d.ts +9 -0
  21. package/dist/backend/src/index.d.ts.map +1 -0
  22. package/dist/backend/src/index.js +8 -0
  23. package/dist/backend/src/index.js.map +1 -0
  24. package/dist/backend/src/ports.d.ts +60 -0
  25. package/dist/backend/src/ports.d.ts.map +1 -0
  26. package/dist/backend/src/ports.js +2 -0
  27. package/dist/backend/src/ports.js.map +1 -0
  28. package/dist/backend/src/services.d.ts +49 -0
  29. package/dist/backend/src/services.d.ts.map +1 -0
  30. package/dist/backend/src/services.js +178 -0
  31. package/dist/backend/src/services.js.map +1 -0
  32. package/dist/contract/endpoints.d.ts +259 -0
  33. package/dist/contract/endpoints.d.ts.map +1 -0
  34. package/dist/contract/endpoints.js +42 -0
  35. package/dist/contract/endpoints.js.map +1 -0
  36. package/dist/contract/errors.d.ts +23 -0
  37. package/dist/contract/errors.d.ts.map +1 -0
  38. package/dist/contract/errors.js +31 -0
  39. package/dist/contract/errors.js.map +1 -0
  40. package/dist/contract/events.d.ts +40 -0
  41. package/dist/contract/events.d.ts.map +1 -0
  42. package/dist/contract/events.js +14 -0
  43. package/dist/contract/events.js.map +1 -0
  44. package/dist/contract/index.d.ts +9 -0
  45. package/dist/contract/index.d.ts.map +1 -0
  46. package/dist/contract/index.js +9 -0
  47. package/dist/contract/index.js.map +1 -0
  48. package/dist/contract/schemas.d.ts +150 -0
  49. package/dist/contract/schemas.d.ts.map +1 -0
  50. package/dist/contract/schemas.js +43 -0
  51. package/dist/contract/schemas.js.map +1 -0
  52. package/dist/frontend/src/client.d.ts +38 -0
  53. package/dist/frontend/src/client.d.ts.map +1 -0
  54. package/dist/frontend/src/client.js +88 -0
  55. package/dist/frontend/src/client.js.map +1 -0
  56. package/dist/frontend/src/composables.d.ts +46 -0
  57. package/dist/frontend/src/composables.d.ts.map +1 -0
  58. package/dist/frontend/src/composables.js +111 -0
  59. package/dist/frontend/src/composables.js.map +1 -0
  60. package/dist/frontend/src/guards.d.ts +10 -0
  61. package/dist/frontend/src/guards.d.ts.map +1 -0
  62. package/dist/frontend/src/guards.js +9 -0
  63. package/dist/frontend/src/guards.js.map +1 -0
  64. package/dist/frontend/src/index.d.ts +12 -0
  65. package/dist/frontend/src/index.d.ts.map +1 -0
  66. package/dist/frontend/src/index.js +9 -0
  67. package/dist/frontend/src/index.js.map +1 -0
  68. package/dist/manifest.d.ts +80 -0
  69. package/dist/manifest.d.ts.map +1 -0
  70. package/dist/manifest.js +126 -0
  71. package/dist/manifest.js.map +1 -0
  72. package/dist/scaffolder/core/config.d.ts +213 -0
  73. package/dist/scaffolder/core/config.d.ts.map +1 -0
  74. package/dist/scaffolder/core/config.js +132 -0
  75. package/dist/scaffolder/core/config.js.map +1 -0
  76. package/dist/scaffolder/core/errors.d.ts +37 -0
  77. package/dist/scaffolder/core/errors.d.ts.map +1 -0
  78. package/dist/scaffolder/core/errors.js +46 -0
  79. package/dist/scaffolder/core/errors.js.map +1 -0
  80. package/dist/scaffolder/core/extend.d.ts +115 -0
  81. package/dist/scaffolder/core/extend.d.ts.map +1 -0
  82. package/dist/scaffolder/core/extend.js +116 -0
  83. package/dist/scaffolder/core/extend.js.map +1 -0
  84. package/dist/scaffolder/core/materialize.d.ts +71 -0
  85. package/dist/scaffolder/core/materialize.d.ts.map +1 -0
  86. package/dist/scaffolder/core/materialize.js +47 -0
  87. package/dist/scaffolder/core/materialize.js.map +1 -0
  88. package/dist/scaffolder/core/ports.d.ts +39 -0
  89. package/dist/scaffolder/core/ports.d.ts.map +1 -0
  90. package/dist/scaffolder/core/ports.js +33 -0
  91. package/dist/scaffolder/core/ports.js.map +1 -0
  92. package/dist/scaffolder/core/three-way-merge.d.ts +113 -0
  93. package/dist/scaffolder/core/three-way-merge.d.ts.map +1 -0
  94. package/dist/scaffolder/core/three-way-merge.js +184 -0
  95. package/dist/scaffolder/core/three-way-merge.js.map +1 -0
  96. package/dist/scaffolder/index.d.ts +21 -0
  97. package/dist/scaffolder/index.d.ts.map +1 -0
  98. package/dist/scaffolder/index.js +20 -0
  99. package/dist/scaffolder/index.js.map +1 -0
  100. package/frontend/templates/components/AuthField.vue +68 -0
  101. package/frontend/templates/i18n/en.json +70 -0
  102. package/frontend/templates/i18n/nl.json +70 -0
  103. package/frontend/templates/middleware/auth.ts +25 -0
  104. package/frontend/templates/middleware/guest.ts +25 -0
  105. package/frontend/templates/pages/forgot-password.vue +89 -0
  106. package/frontend/templates/pages/login.vue +90 -0
  107. package/frontend/templates/pages/logout.vue +46 -0
  108. package/frontend/templates/pages/register.vue +100 -0
  109. package/frontend/templates/pages/reset-password.vue +105 -0
  110. package/frontend/templates/pages/verify-email.vue +76 -0
  111. package/frontend/templates/plugins/auth.client.ts +111 -0
  112. package/frontend/templates/runtime.ts +60 -0
  113. package/package.json +71 -0
package/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # @seifer-webapp-factory/authentication
2
+
3
+ Eerste **capability-module**: verticale, pluggable full-stack auth-feature (register/login/logout/verify/refresh/reset) die de `@seifer-webapp-factory/kits` samenbindt via één contract.
4
+
5
+ - Mechanisme (pinned dependency): `@seifer-webapp-factory/authentication/{contract,backend,frontend,manifest,scaffolder}`.
6
+ - Surface (gematerialiseerd, project-eigendom): `backend/templates`, `frontend/templates`.
7
+
8
+ Zie `../authentication.md` (plan) en `./` (backlog) in de monorepo.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * US-A0603 — Config-fragment voor de authentication-capability. Dit is een Zod-schema dat de host in
3
+ * zijn eigen app-config-schema mengt (config-kit). Het bevat GEEN secrets rechtstreeks: het
4
+ * signing-key-materiaal wordt via een *referentie* (`signingKeyRef`) uit de secret-store geladen, zodat
5
+ * het nooit in gewone config-dumps belandt (US-A0704).
6
+ */
7
+ import { z } from 'zod';
8
+
9
+ /** Wachtwoord-beleid (lengte); details buiten het contract-schema (US-A0603). */
10
+ export const passwordPolicySchema = z.object({
11
+ minLength: z.number().int().min(8).default(8),
12
+ maxLength: z.number().int().max(1024).default(200),
13
+ });
14
+
15
+ /** Token-bucket-drempel per beschermde route (US-A0701). */
16
+ export const rateLimitThresholdSchema = z.object({
17
+ /** Maximale burst (aantal pogingen). */
18
+ capacity: z.number().int().positive().default(5),
19
+ /** Bijvul-rate in pogingen per seconde. */
20
+ refillPerSecond: z.number().positive().default(0.2),
21
+ });
22
+
23
+ /** Het volledige auth-config-fragment. */
24
+ export const authConfigFragment = z.object({
25
+ /** Referentie (naam) naar de JWT-signing-key in de secret-store — NIET de sleutel zelf. */
26
+ signingKeyRef: z.string().min(1).default('AUTH_SIGNING_KEY'),
27
+ /** Levensduur van het access-token (JWT) in seconden. Default 15 min. */
28
+ accessTtlSeconds: z.number().int().positive().default(15 * 60),
29
+ /** Levensduur van het roterende refresh-token in seconden. Default 14 dagen. */
30
+ refreshTtlSeconds: z.number().int().positive().default(14 * 24 * 60 * 60),
31
+ /** Issuer-claim (bv. app-naam). */
32
+ issuer: z.string().min(1).default('webapp-factory'),
33
+ /** Wachtwoord-beleid. */
34
+ password: passwordPolicySchema.default({}),
35
+ /** Feature-toggles. */
36
+ toggles: z
37
+ .object({
38
+ /** Vereist e-mailverificatie vóór login (US-A0203/US-A0301). */
39
+ emailVerification: z.boolean().default(true),
40
+ /** Sta open zelfregistratie toe (US-A0201). */
41
+ selfRegistration: z.boolean().default(true),
42
+ })
43
+ .default({}),
44
+ /** Redirect-doelen die de frontend na verify/reset gebruikt. */
45
+ redirects: z
46
+ .object({
47
+ afterVerify: z.string().default('/login?verified=1'),
48
+ afterReset: z.string().default('/login?reset=1'),
49
+ })
50
+ .default({}),
51
+ /** Rate-limit-drempels voor de misbruikgevoelige routes (US-A0701). */
52
+ rateLimit: z
53
+ .object({
54
+ login: rateLimitThresholdSchema.default({}),
55
+ forgot: rateLimitThresholdSchema.default({}),
56
+ })
57
+ .default({}),
58
+ /** Cookie-gedrag (US-A0703). `secure` hoort in productie `true` te zijn. */
59
+ cookie: z
60
+ .object({
61
+ secure: z.boolean().default(true),
62
+ sameSite: z.enum(['Strict', 'Lax', 'None']).default('Strict'),
63
+ })
64
+ .default({}),
65
+ });
66
+
67
+ /** Het afgeleide, gevalideerde config-type. */
68
+ export type AuthConfig = z.infer<typeof authConfigFragment>;
69
+
70
+ /** Parse (met defaults) een ruwe config-invoer tot een `AuthConfig`. */
71
+ export function parseAuthConfig(input: unknown = {}): AuthConfig {
72
+ return authConfigFragment.parse(input);
73
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * US-A0202 — E-mailtemplates voor de auth-keten (verificatie + wachtwoord-reset). Data-gedreven en
3
+ * i18n-klaar: per taal een `subject` + `body` met `{token}`/`{link}`-placeholders. De mechanism-laag
4
+ * roept `mailer.send({ to, subject, data: { token, purpose } })` aan; deze module vertaalt een
5
+ * `purpose` naar een gerenderd bericht. NL is de projectdefault; EN is meegeleverd als voorbeeld.
6
+ */
7
+ import type { MailMessage } from '@seifer-webapp-factory/kits/backend/auth';
8
+
9
+ /** De twee purposes die de mechanism-laag uitgeeft (sluiten aan op de kit-defaults). */
10
+ export const MAIL_PURPOSES = {
11
+ emailVerify: 'email-verify',
12
+ passwordReset: 'password-reset',
13
+ } as const;
14
+
15
+ export type Locale = 'nl' | 'en';
16
+
17
+ interface MailTemplate {
18
+ subject: string;
19
+ /** `{token}` en `{link}` worden ingevuld bij het renderen. */
20
+ body: string;
21
+ }
22
+
23
+ /** Templatecatalogus per purpose per taal. */
24
+ export const MAIL_TEMPLATES: Record<string, Record<Locale, MailTemplate>> = {
25
+ [MAIL_PURPOSES.emailVerify]: {
26
+ nl: {
27
+ subject: 'Bevestig je account',
28
+ body:
29
+ 'Welkom!\n\nBevestig je account met deze code:\n\n{token}\n\n' +
30
+ 'Of open de link: {link}\n\nHeb je dit niet aangevraagd? Negeer deze e-mail.',
31
+ },
32
+ en: {
33
+ subject: 'Confirm your account',
34
+ body:
35
+ 'Welcome!\n\nConfirm your account with this code:\n\n{token}\n\n' +
36
+ 'Or open the link: {link}\n\nDidn’t request this? Ignore this email.',
37
+ },
38
+ },
39
+ [MAIL_PURPOSES.passwordReset]: {
40
+ nl: {
41
+ subject: 'Reset je wachtwoord',
42
+ body:
43
+ 'Je hebt een wachtwoord-reset aangevraagd.\n\nGebruik deze code:\n\n{token}\n\n' +
44
+ 'Of open de link: {link}\n\nHeb je dit niet aangevraagd? Negeer deze e-mail.',
45
+ },
46
+ en: {
47
+ subject: 'Reset your password',
48
+ body:
49
+ 'You requested a password reset.\n\nUse this code:\n\n{token}\n\n' +
50
+ 'Or open the link: {link}\n\nDidn’t request this? Ignore this email.',
51
+ },
52
+ },
53
+ };
54
+
55
+ export interface RenderOptions {
56
+ locale?: Locale;
57
+ /** Basis-URL om een klikbare `{link}` te bouwen (bv. `https://app/verify?token=`). */
58
+ linkBaseByPurpose?: Record<string, string>;
59
+ }
60
+
61
+ export interface RenderedMail {
62
+ subject: string;
63
+ text: string;
64
+ }
65
+
66
+ /**
67
+ * Render een `MailMessage` (met `data.token` + `data.purpose`) tot een concreet bericht. Onbekende
68
+ * purposes vallen terug op het meegegeven `subject` + de kale token-tekst (geen stille lege body).
69
+ */
70
+ export function renderAuthMail(message: MailMessage, options: RenderOptions = {}): RenderedMail {
71
+ const locale = options.locale ?? 'nl';
72
+ const purpose = String(message.data?.['purpose'] ?? '');
73
+ const token = String(message.data?.['token'] ?? '');
74
+ const template = MAIL_TEMPLATES[purpose]?.[locale];
75
+
76
+ if (!template) {
77
+ return { subject: message.subject, text: message.text ?? token };
78
+ }
79
+
80
+ const base = options.linkBaseByPurpose?.[purpose];
81
+ const link = base ? `${base}${encodeURIComponent(token)}` : '';
82
+ const text = template.body.replaceAll('{token}', token).replaceAll('{link}', link);
83
+ return { subject: template.subject, text };
84
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * De NestJS-surface voor de 8 contract-endpoints. Puur *plumbing*: valideert de body met de contract-
3
+ * Zod-schema's, roept de framework-vrije mechanism-services (backend/src) aan, en vertaalt het resultaat
4
+ * naar HTTP. Security-invarianten die hier (en niet in het mechanisme) horen:
5
+ * - httpOnly+Secure+SameSite refresh-cookie; access-token alleen in de body (US-A0703)
6
+ * - CSRF double-submit op state-changing routes (US-A0703)
7
+ * - security-headers + generieke, niet-lekkende foutbodies `{ code, message, fields? }` (US-A0704)
8
+ * - rate-limit op login + forgot (US-A0701)
9
+ *
10
+ * DI is EXPLICIET via `@Inject(TOKEN)` (geen type-based DI) zodat de controller onder vitest/esbuild
11
+ * draait zonder decorator-metadata.
12
+ */
13
+ import {
14
+ Controller,
15
+ Get,
16
+ HttpCode,
17
+ HttpException,
18
+ Inject,
19
+ Post,
20
+ Req,
21
+ Res,
22
+ } from '@nestjs/common';
23
+ import { ZodError, type ZodTypeAny, type z } from 'zod';
24
+ import { enforceRateLimit, type RateLimiter } from '@seifer-webapp-factory/kits/backend/rate-limit';
25
+ import type { UserRecord } from '@seifer-webapp-factory/kits/backend/auth';
26
+ import {
27
+ registerRequestSchema,
28
+ verifyEmailRequestSchema,
29
+ loginRequestSchema,
30
+ forgotPasswordRequestSchema,
31
+ resetPasswordRequestSchema,
32
+ AUTH_ERROR_TAXONOMY,
33
+ type AuthErrorCode,
34
+ type MeResponse,
35
+ type PublicUser,
36
+ } from '../../../contract/index.js';
37
+ import {
38
+ registerUser,
39
+ verifyEmail,
40
+ login,
41
+ refresh,
42
+ logout,
43
+ forgotPassword,
44
+ resetPassword,
45
+ AuthModuleError,
46
+ type AuthModuleDeps,
47
+ } from '../../src/index.js';
48
+ import {
49
+ REFRESH_COOKIE,
50
+ parseCookies,
51
+ refreshSetCookie,
52
+ clearRefreshSetCookie,
53
+ csrfSetCookie,
54
+ type CookieOptions,
55
+ } from '../security/cookies.js';
56
+ import { generateCsrfToken, isCsrfValid, csrfCookieValue } from '../security/csrf.js';
57
+ import { applySecurityHeaders } from '../security/headers.js';
58
+ import {
59
+ AUTH_MODULE_DEPS,
60
+ AUTH_LOGIN_LIMITER,
61
+ AUTH_FORGOT_LIMITER,
62
+ AUTH_COOKIE_OPTIONS,
63
+ AUTH_SURFACE_CONFIG,
64
+ type AuthSurfaceConfig,
65
+ } from './tokens.js';
66
+
67
+ /** Minimale express-request-vorm die de controller nodig heeft (geen harde type-dep). */
68
+ interface AuthRequest {
69
+ headers: Record<string, string | string[] | undefined>;
70
+ body: unknown;
71
+ }
72
+ /** Minimale express-response-vorm (setHeader + append voor meerdere Set-Cookie's). */
73
+ interface AuthResponse {
74
+ setHeader(name: string, value: string): unknown;
75
+ append(name: string, value: string | string[]): unknown;
76
+ }
77
+
78
+ @Controller('auth')
79
+ export class AuthController {
80
+ constructor(
81
+ @Inject(AUTH_MODULE_DEPS) private readonly deps: AuthModuleDeps,
82
+ @Inject(AUTH_LOGIN_LIMITER) private readonly loginLimiter: RateLimiter,
83
+ @Inject(AUTH_FORGOT_LIMITER) private readonly forgotLimiter: RateLimiter,
84
+ @Inject(AUTH_COOKIE_OPTIONS) private readonly cookie: CookieOptions,
85
+ @Inject(AUTH_SURFACE_CONFIG) private readonly surface: AuthSurfaceConfig,
86
+ ) {}
87
+
88
+ // --- US-A0201 register ---
89
+ @Post('register')
90
+ @HttpCode(200)
91
+ async register(@Req() req: AuthRequest, @Res({ passthrough: true }) res: AuthResponse) {
92
+ try {
93
+ this.prepare(req, res, true);
94
+ const body = this.parse(registerRequestSchema, req.body);
95
+ return await registerUser(this.deps, body);
96
+ } catch (err) {
97
+ this.toHttp(err);
98
+ }
99
+ }
100
+
101
+ // --- US-A0203 verify-email ---
102
+ @Post('verify-email')
103
+ @HttpCode(200)
104
+ async verifyEmail(@Req() req: AuthRequest, @Res({ passthrough: true }) res: AuthResponse) {
105
+ try {
106
+ this.prepare(req, res, true);
107
+ const body = this.parse(verifyEmailRequestSchema, req.body);
108
+ return await verifyEmail(this.deps, body.token);
109
+ } catch (err) {
110
+ this.toHttp(err);
111
+ }
112
+ }
113
+
114
+ // --- US-A0301 login ---
115
+ @Post('login')
116
+ @HttpCode(200)
117
+ async login(@Req() req: AuthRequest, @Res({ passthrough: true }) res: AuthResponse) {
118
+ try {
119
+ this.prepare(req, res, true);
120
+ const body = this.parse(loginRequestSchema, req.body);
121
+ await this.enforce(this.loginLimiter, `login:${body.email}`, res);
122
+ const result = await login(this.deps, body);
123
+ this.setRefresh(res, result.refreshToken);
124
+ return result.session;
125
+ } catch (err) {
126
+ this.toHttp(err);
127
+ }
128
+ }
129
+
130
+ // --- US-A0401 refresh (stille rotatie) ---
131
+ @Post('refresh')
132
+ @HttpCode(200)
133
+ async refresh(@Req() req: AuthRequest, @Res({ passthrough: true }) res: AuthResponse) {
134
+ try {
135
+ this.prepare(req, res, true);
136
+ const result = await refresh(this.deps, this.readRefresh(req));
137
+ this.setRefresh(res, result.refreshToken);
138
+ return result.session;
139
+ } catch (err) {
140
+ this.toHttp(err);
141
+ }
142
+ }
143
+
144
+ // --- US-A0403 logout (idempotent) ---
145
+ @Post('logout')
146
+ @HttpCode(200)
147
+ async logout(@Req() req: AuthRequest, @Res({ passthrough: true }) res: AuthResponse) {
148
+ try {
149
+ this.prepare(req, res, true);
150
+ const ack = await logout(this.deps, this.readRefresh(req));
151
+ res.append('Set-Cookie', clearRefreshSetCookie(this.cookie));
152
+ return ack;
153
+ } catch (err) {
154
+ this.toHttp(err);
155
+ }
156
+ }
157
+
158
+ // --- US-A0501 forgot-password (altijd 200, no-enumeration) ---
159
+ @Post('forgot-password')
160
+ @HttpCode(200)
161
+ async forgotPassword(@Req() req: AuthRequest, @Res({ passthrough: true }) res: AuthResponse) {
162
+ try {
163
+ this.prepare(req, res, true);
164
+ const body = this.parse(forgotPasswordRequestSchema, req.body);
165
+ await this.enforce(this.forgotLimiter, `forgot:${body.email}`, res);
166
+ return await forgotPassword(this.deps, body);
167
+ } catch (err) {
168
+ this.toHttp(err);
169
+ }
170
+ }
171
+
172
+ // --- US-A0502 reset-password (verbruikt token + trekt sessies in) ---
173
+ @Post('reset-password')
174
+ @HttpCode(200)
175
+ async resetPassword(@Req() req: AuthRequest, @Res({ passthrough: true }) res: AuthResponse) {
176
+ try {
177
+ this.prepare(req, res, true);
178
+ const body = this.parse(resetPasswordRequestSchema, req.body);
179
+ return await resetPassword(this.deps, body);
180
+ } catch (err) {
181
+ this.toHttp(err);
182
+ }
183
+ }
184
+
185
+ // --- US-A0302 GET /auth/me ---
186
+ @Get('me')
187
+ @HttpCode(200)
188
+ async me(@Req() req: AuthRequest, @Res({ passthrough: true }) res: AuthResponse): Promise<MeResponse> {
189
+ try {
190
+ this.prepare(req, res, false);
191
+ const token = this.bearer(req);
192
+ if (!token) throw new AuthModuleError('unauthenticated');
193
+ const verified = await this.deps.tokenService.verify(token);
194
+ const user = await this.deps.userStore.findById(verified.subject);
195
+ if (!user) throw new AuthModuleError('unauthenticated');
196
+ return { user: toPublicUser(user), roles: [], permissions: [] };
197
+ } catch (err) {
198
+ if (err instanceof HttpException) throw err;
199
+ // Elke fout in de me-flow (ontbrekend/ongeldig/verlopen access-token) → generieke 401 (geen leak).
200
+ this.toHttp(new AuthModuleError('unauthenticated'));
201
+ }
202
+ }
203
+
204
+ // --- Interne surface-helpers ---
205
+
206
+ /** Security-headers + CSRF-seed + (optioneel) double-submit-controle op state-changing routes. */
207
+ private prepare(req: AuthRequest, res: AuthResponse, csrf: boolean): void {
208
+ applySecurityHeaders(res);
209
+ // Zaai een leesbare CSRF-cookie zodra er nog geen is (double-submit bootstrap, US-A0703).
210
+ if (csrfCookieValue(req) === undefined) {
211
+ res.append('Set-Cookie', csrfSetCookie(generateCsrfToken(), this.cookie));
212
+ }
213
+ if (csrf && !isCsrfValid(req)) this.fail('csrf_failed');
214
+ }
215
+
216
+ /** Rate-limit-enforcement (US-A0701): zet `X-RateLimit-*`-headers; bij weigering → `rate_limited`. */
217
+ private async enforce(limiter: RateLimiter, key: string, res: AuthResponse): Promise<void> {
218
+ const decision = await enforceRateLimit(key, { limiter });
219
+ for (const [name, value] of Object.entries(decision.headers)) res.setHeader(name, value);
220
+ if (!decision.allowed) this.fail('rate_limited');
221
+ }
222
+
223
+ private parse<S extends ZodTypeAny>(schema: S, body: unknown): z.infer<S> {
224
+ return schema.parse(body);
225
+ }
226
+
227
+ /** Zet het roterende refresh-token als httpOnly-cookie (nooit in de body). */
228
+ private setRefresh(res: AuthResponse, token: string): void {
229
+ res.append('Set-Cookie', refreshSetCookie(token, this.surface.refreshTtlSeconds, this.cookie));
230
+ }
231
+
232
+ private readRefresh(req: AuthRequest): string | undefined {
233
+ const raw = req.headers['cookie'];
234
+ const header = Array.isArray(raw) ? raw.join('; ') : raw;
235
+ return parseCookies(header)[REFRESH_COOKIE];
236
+ }
237
+
238
+ private bearer(req: AuthRequest): string | undefined {
239
+ const raw = req.headers['authorization'] ?? req.headers['Authorization'];
240
+ const value = Array.isArray(raw) ? raw[0] : raw;
241
+ return value?.startsWith('Bearer ') ? value.slice('Bearer '.length) : undefined;
242
+ }
243
+
244
+ /** Gooi een generieke, contract-conforme foutrespons voor een code (nooit secret-materiaal). */
245
+ private fail(code: AuthErrorCode): never {
246
+ throw new AuthModuleError(code);
247
+ }
248
+
249
+ /**
250
+ * Vertaal een fout naar HTTP. Zod → `validation_failed` (422, met veldpaden zonder waarden);
251
+ * `AuthModuleError` → `AUTH_ERROR_TAXONOMY`-status; al-gevormde `HttpException` blijft; onbekend
252
+ * bubbelt (Nest → 500, geen leak).
253
+ */
254
+ private toHttp(err: unknown): never {
255
+ if (err instanceof HttpException) throw err;
256
+ if (err instanceof ZodError) {
257
+ const fields = err.flatten().fieldErrors as Record<string, string[]>;
258
+ throw new HttpException(
259
+ { code: 'validation_failed', message: AUTH_ERROR_TAXONOMY.validation_failed.i18nKey, fields },
260
+ AUTH_ERROR_TAXONOMY.validation_failed.httpStatus,
261
+ );
262
+ }
263
+ if (err instanceof AuthModuleError) {
264
+ const descriptor = AUTH_ERROR_TAXONOMY[err.code];
265
+ throw new HttpException({ code: err.code, message: err.message }, descriptor.httpStatus);
266
+ }
267
+ throw err;
268
+ }
269
+ }
270
+
271
+ /** Kit-`UserRecord` → veilige contract-`PublicUser` (geen credential-materiaal). */
272
+ function toPublicUser(user: UserRecord): PublicUser {
273
+ return { id: user.id, email: user.identifier, emailVerified: user.verified === true };
274
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `AuthModule.forRoot(options)` — de NestJS-surface die de framework-vrije mechanism-laag (backend/src)
3
+ * samenbindt met de pg-adapters, de rate-limit-kit, de audit-log-kit en de security-helpers. Alles wordt
4
+ * met EXPLICIETE providers (`useValue`/token) gewired — geen type-based DI — zodat de module ook onder
5
+ * vitest/esbuild draait (geen decorator-metadata). Fail-fast op ontbrekende poorten, in de stijl van de
6
+ * kit-`AuthKitModule`.
7
+ */
8
+ import { Module, type DynamicModule, type Provider } from '@nestjs/common';
9
+ import {
10
+ TokenService,
11
+ SingleUseTokenService,
12
+ argon2idHasher,
13
+ type Argon2idParams,
14
+ type Clock,
15
+ type Mailer,
16
+ } from '@seifer-webapp-factory/kits/backend/auth';
17
+ import type { Pool } from '@seifer-webapp-factory/kits/backend/persistence';
18
+ import {
19
+ tokenBucketLimiter,
20
+ inMemoryRateLimitStore,
21
+ type RateLimiter,
22
+ } from '@seifer-webapp-factory/kits/backend/rate-limit';
23
+ import { createAuditLog, inMemoryAuditStore, type AuditLog } from '@seifer-webapp-factory/kits/backend/audit-log';
24
+ import { AUTH_EVENTS, type AuthEvent, type AuthEventSink } from '../../../contract/index.js';
25
+ import type { AuthModuleConfig, AuthModuleDeps } from '../../src/index.js';
26
+ import { pgUserStore } from '../persistence/pg-user-store.js';
27
+ import { pgTokenStore } from '../persistence/pg-token-store.js';
28
+ import { pgSingleUseStore } from '../persistence/pg-single-use-store.js';
29
+ import { DEFAULT_COOKIE_OPTIONS, type CookieOptions } from '../security/cookies.js';
30
+ import { AuthController } from './auth.controller.js';
31
+ import {
32
+ AUTH_MODULE_DEPS,
33
+ AUTH_LOGIN_LIMITER,
34
+ AUTH_FORGOT_LIMITER,
35
+ AUTH_AUDIT_LOG,
36
+ AUTH_COOKIE_OPTIONS,
37
+ AUTH_SURFACE_CONFIG,
38
+ type AuthSurfaceConfig,
39
+ type RateLimitThreshold,
40
+ } from './tokens.js';
41
+
42
+ // Her-exporteer de tokens/typen zodat consumenten alles vanuit de module kunnen importeren.
43
+ export {
44
+ AUTH_MODULE_DEPS,
45
+ AUTH_LOGIN_LIMITER,
46
+ AUTH_FORGOT_LIMITER,
47
+ AUTH_AUDIT_LOG,
48
+ AUTH_COOKIE_OPTIONS,
49
+ AUTH_SURFACE_CONFIG,
50
+ type AuthSurfaceConfig,
51
+ type RateLimitThreshold,
52
+ } from './tokens.js';
53
+
54
+ export interface AuthModuleOptions {
55
+ /** Persistence-kit-pool waarover de pg-adapters draaien (verplicht). */
56
+ pool: Pool;
57
+ /** Mailer-poort (kit) waarlangs verify/reset-mails gaan (verplicht). */
58
+ mailer: Mailer;
59
+ /** JWT-signing-secret (reeds uit de secret-store ge-`reveal()`-d) (verplicht). */
60
+ signingSecret: string;
61
+ issuer?: string;
62
+ accessTtlSeconds?: number;
63
+ refreshTtlSeconds?: number;
64
+ password?: { minLength: number; maxLength: number };
65
+ purposes?: { emailVerify: string; passwordReset: string };
66
+ rateLimit?: { login?: RateLimitThreshold; forgot?: RateLimitThreshold };
67
+ cookie?: Partial<CookieOptions>;
68
+ /** Argon2id-parameters (tests gebruiken lichtere kosten voor snelheid). */
69
+ argon2?: Argon2idParams;
70
+ /** Injecteerbare klok (deterministische TTL's in tests). Default `Date.now`. */
71
+ clock?: Clock;
72
+ /** Audit-log-sink (US-A0705). Default: een in-memory audit-log (query-baar in tests). */
73
+ auditLog?: AuditLog;
74
+ /** Extra event-sink naast de audit-log (host-hooks/analytics). */
75
+ events?: AuthEventSink;
76
+ }
77
+
78
+ const DEFAULTS = {
79
+ issuer: 'webapp-factory',
80
+ accessTtlSeconds: 15 * 60,
81
+ refreshTtlSeconds: 14 * 24 * 60 * 60,
82
+ password: { minLength: 8, maxLength: 200 },
83
+ purposes: { emailVerify: 'email-verify', passwordReset: 'password-reset' },
84
+ rateLimit: {
85
+ login: { capacity: 5, refillPerSecond: 0.2 },
86
+ forgot: { capacity: 5, refillPerSecond: 0.2 },
87
+ },
88
+ };
89
+
90
+ @Module({})
91
+ export class AuthModule {
92
+ static forRoot(options: AuthModuleOptions): DynamicModule {
93
+ requireOption('pool', options.pool);
94
+ requireOption('mailer', options.mailer, ['send']);
95
+ if (!options.signingSecret || typeof options.signingSecret !== 'string') {
96
+ throw new Error('AuthModule.forRoot: "signingSecret" ontbreekt of is ongeldig');
97
+ }
98
+
99
+ const clock: Clock = options.clock ?? Date.now;
100
+ const issuer = options.issuer ?? DEFAULTS.issuer;
101
+ const accessTtlSeconds = options.accessTtlSeconds ?? DEFAULTS.accessTtlSeconds;
102
+ const refreshTtlSeconds = options.refreshTtlSeconds ?? DEFAULTS.refreshTtlSeconds;
103
+ const password = options.password ?? DEFAULTS.password;
104
+ const purposes = options.purposes ?? DEFAULTS.purposes;
105
+ const cookie: CookieOptions = { ...DEFAULT_COOKIE_OPTIONS, ...options.cookie };
106
+
107
+ // --- pg-adapters over de kit-pool ---
108
+ const userStore = pgUserStore(options.pool);
109
+ const tokenStore = pgTokenStore(options.pool);
110
+ const singleUseStore = pgSingleUseStore(options.pool);
111
+
112
+ // --- kit-services die de mechanism-poorten (structureel) vervullen ---
113
+ const tokenService = new TokenService(
114
+ { secret: options.signingSecret, accessTtlSeconds, refreshTtlSeconds, issuer },
115
+ tokenStore,
116
+ clock,
117
+ );
118
+ const singleUseTokenService = new SingleUseTokenService(
119
+ { defaultTtlSeconds: 24 * 60 * 60, ttlByPurpose: { [purposes.passwordReset]: 60 * 60 } },
120
+ singleUseStore,
121
+ clock,
122
+ );
123
+ const hasher = argon2idHasher(options.argon2);
124
+
125
+ // --- audit-log (US-A0705): auth-events → onveranderlijk logboek ---
126
+ const auditLog =
127
+ options.auditLog ?? createAuditLog(inMemoryAuditStore(), { eventTypes: [...AUTH_EVENTS], clock });
128
+ const events = auditEventSink(auditLog, options.events);
129
+
130
+ const config: AuthModuleConfig = { purposes, password };
131
+ const deps: AuthModuleDeps = {
132
+ userStore,
133
+ mailer: options.mailer,
134
+ hasher,
135
+ tokenService,
136
+ singleUseTokenService,
137
+ events,
138
+ clock,
139
+ config,
140
+ };
141
+
142
+ // --- rate-limiters (US-A0701) op login + forgot ---
143
+ const store = inMemoryRateLimitStore();
144
+ const loginThreshold = options.rateLimit?.login ?? DEFAULTS.rateLimit.login;
145
+ const forgotThreshold = options.rateLimit?.forgot ?? DEFAULTS.rateLimit.forgot;
146
+ const loginLimiter: RateLimiter = tokenBucketLimiter(
147
+ { capacity: loginThreshold.capacity, refillPerSecond: loginThreshold.refillPerSecond },
148
+ store,
149
+ clock,
150
+ );
151
+ const forgotLimiter: RateLimiter = tokenBucketLimiter(
152
+ { capacity: forgotThreshold.capacity, refillPerSecond: forgotThreshold.refillPerSecond },
153
+ store,
154
+ clock,
155
+ );
156
+
157
+ const surfaceConfig: AuthSurfaceConfig = { accessTtlSeconds, refreshTtlSeconds };
158
+
159
+ const providers: Provider[] = [
160
+ { provide: AUTH_MODULE_DEPS, useValue: deps },
161
+ { provide: AUTH_LOGIN_LIMITER, useValue: loginLimiter },
162
+ { provide: AUTH_FORGOT_LIMITER, useValue: forgotLimiter },
163
+ { provide: AUTH_AUDIT_LOG, useValue: auditLog },
164
+ { provide: AUTH_COOKIE_OPTIONS, useValue: cookie },
165
+ { provide: AUTH_SURFACE_CONFIG, useValue: surfaceConfig },
166
+ ];
167
+
168
+ return {
169
+ module: AuthModule,
170
+ controllers: [AuthController],
171
+ providers,
172
+ exports: [AUTH_MODULE_DEPS, AUTH_AUDIT_LOG],
173
+ };
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Adapteert de {@link AuthEventSink} naar een audit-log-append (US-A0705). Emit is synchroon (`void`);
179
+ * de append draait fire-and-forget met een zwaluwstaart-catch zodat een logboek-hapering nooit de
180
+ * auth-flow breekt. Payloads bevatten alleen identificatie (nooit credentials/tokens).
181
+ */
182
+ function auditEventSink(audit: AuditLog, extra?: AuthEventSink): AuthEventSink {
183
+ return {
184
+ emit(event: AuthEvent): void {
185
+ extra?.emit(event);
186
+ void audit
187
+ .append({
188
+ actor: event.userId,
189
+ action: event.name,
190
+ target: event.userId,
191
+ meta: { occurredAt: event.occurredAt },
192
+ })
193
+ .catch(() => undefined);
194
+ },
195
+ };
196
+ }
197
+
198
+ function requireOption(name: string, value: unknown, methods: string[] = []): void {
199
+ if (value === null || value === undefined || typeof value !== 'object') {
200
+ throw new Error(`AuthModule.forRoot: "${name}" ontbreekt of is geen object`);
201
+ }
202
+ for (const method of methods) {
203
+ if (typeof (value as Record<string, unknown>)[method] !== 'function') {
204
+ throw new Error(`AuthModule.forRoot: "${name}" implementeert "${method}" niet`);
205
+ }
206
+ }
207
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * DI-tokens + kleine surface-config-typen voor de authentication-module. In een apart bestand zodat de
3
+ * controller en de module ze allebei kunnen importeren zonder circulaire import.
4
+ */
5
+ import type { InjectionToken } from '@nestjs/common';
6
+
7
+ export const AUTH_MODULE_DEPS: InjectionToken = Symbol.for('authentication.module-deps');
8
+ export const AUTH_LOGIN_LIMITER: InjectionToken = Symbol.for('authentication.login-limiter');
9
+ export const AUTH_FORGOT_LIMITER: InjectionToken = Symbol.for('authentication.forgot-limiter');
10
+ export const AUTH_AUDIT_LOG: InjectionToken = Symbol.for('authentication.audit-log');
11
+ export const AUTH_COOKIE_OPTIONS: InjectionToken = Symbol.for('authentication.cookie-options');
12
+ export const AUTH_SURFACE_CONFIG: InjectionToken = Symbol.for('authentication.surface-config');
13
+
14
+ /** Drempel voor een token-bucket-limiter (US-A0701). */
15
+ export interface RateLimitThreshold {
16
+ capacity: number;
17
+ refillPerSecond: number;
18
+ }
19
+
20
+ /** Runtime-config die de surface (controller) nodig heeft naast de mechanism-`AuthModuleConfig`. */
21
+ export interface AuthSurfaceConfig {
22
+ accessTtlSeconds: number;
23
+ refreshTtlSeconds: number;
24
+ }