@dudousxd/adonis-authkit-server 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 (174) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/build/assets/grafana/authkit-dashboard.json +118 -0
  4. package/build/commands/commands.json +30 -0
  5. package/build/commands/configure.d.ts +2 -0
  6. package/build/commands/configure.js +42 -0
  7. package/build/commands/eject.d.ts +11 -0
  8. package/build/commands/eject.js +96 -0
  9. package/build/commands/main.d.ts +12 -0
  10. package/build/commands/main.js +38 -0
  11. package/build/commands/ui_preset.d.ts +4 -0
  12. package/build/commands/ui_preset.js +32 -0
  13. package/build/database/migrations/make_authkit_oidc_table.d.ts +6 -0
  14. package/build/database/migrations/make_authkit_oidc_table.js +19 -0
  15. package/build/host/views/account/login.edge +29 -0
  16. package/build/host/views/account/mfa.edge +151 -0
  17. package/build/host/views/account/tokens.edge +70 -0
  18. package/build/host/views/admin/audit.edge +72 -0
  19. package/build/host/views/admin/clients.edge +51 -0
  20. package/build/host/views/admin/dashboard.edge +58 -0
  21. package/build/host/views/admin/users.edge +76 -0
  22. package/build/host/views/consent.edge +19 -0
  23. package/build/host/views/forgot.edge +30 -0
  24. package/build/host/views/login.edge +91 -0
  25. package/build/host/views/mfa-challenge.edge +88 -0
  26. package/build/host/views/reset.edge +29 -0
  27. package/build/host/views/signup.edge +44 -0
  28. package/build/host/views/verify-email.edge +16 -0
  29. package/build/index.d.ts +42 -0
  30. package/build/index.js +28 -0
  31. package/build/providers/authkit_server_provider.d.ts +19 -0
  32. package/build/providers/authkit_server_provider.js +81 -0
  33. package/build/src/accounts/account_store.d.ts +136 -0
  34. package/build/src/accounts/account_store.js +1 -0
  35. package/build/src/accounts/lucid_account_store.d.ts +75 -0
  36. package/build/src/accounts/lucid_account_store.js +396 -0
  37. package/build/src/adapters/adapter_contract.d.ts +18 -0
  38. package/build/src/adapters/adapter_contract.js +1 -0
  39. package/build/src/adapters/database_adapter.d.ts +15 -0
  40. package/build/src/adapters/database_adapter.js +63 -0
  41. package/build/src/adapters/factory.d.ts +30 -0
  42. package/build/src/adapters/factory.js +43 -0
  43. package/build/src/adapters/redis_adapter.d.ts +16 -0
  44. package/build/src/adapters/redis_adapter.js +95 -0
  45. package/build/src/audit/audit_sink.d.ts +54 -0
  46. package/build/src/audit/audit_sink.js +1 -0
  47. package/build/src/audit/lucid_audit_sink.d.ts +10 -0
  48. package/build/src/audit/lucid_audit_sink.js +60 -0
  49. package/build/src/controllers/oidc_callback_controller.d.ts +22 -0
  50. package/build/src/controllers/oidc_callback_controller.js +33 -0
  51. package/build/src/define_config.d.ts +261 -0
  52. package/build/src/define_config.js +115 -0
  53. package/build/src/host/account_lockout.d.ts +86 -0
  54. package/build/src/host/account_lockout.js +185 -0
  55. package/build/src/host/augmentations.d.ts +1 -0
  56. package/build/src/host/augmentations.js +1 -0
  57. package/build/src/host/branding.d.ts +17 -0
  58. package/build/src/host/branding.js +8 -0
  59. package/build/src/host/controllers/account_mfa_controller.d.ts +30 -0
  60. package/build/src/host/controllers/account_mfa_controller.js +157 -0
  61. package/build/src/host/controllers/account_session_controller.d.ts +7 -0
  62. package/build/src/host/controllers/account_session_controller.js +50 -0
  63. package/build/src/host/controllers/account_tokens_controller.d.ts +7 -0
  64. package/build/src/host/controllers/account_tokens_controller.js +55 -0
  65. package/build/src/host/controllers/admin/admin_audit_controller.d.ts +10 -0
  66. package/build/src/host/controllers/admin/admin_audit_controller.js +56 -0
  67. package/build/src/host/controllers/admin/admin_clients_controller.d.ts +10 -0
  68. package/build/src/host/controllers/admin/admin_clients_controller.js +24 -0
  69. package/build/src/host/controllers/admin/admin_dashboard_controller.d.ts +10 -0
  70. package/build/src/host/controllers/admin/admin_dashboard_controller.js +27 -0
  71. package/build/src/host/controllers/admin/admin_users_controller.d.ts +11 -0
  72. package/build/src/host/controllers/admin/admin_users_controller.js +53 -0
  73. package/build/src/host/controllers/interaction_controller.d.ts +44 -0
  74. package/build/src/host/controllers/interaction_controller.js +304 -0
  75. package/build/src/host/controllers/pat_introspection_controller.d.ts +22 -0
  76. package/build/src/host/controllers/pat_introspection_controller.js +46 -0
  77. package/build/src/host/controllers/registration_controller.d.ts +18 -0
  78. package/build/src/host/controllers/registration_controller.js +169 -0
  79. package/build/src/host/controllers/social_controller.d.ts +8 -0
  80. package/build/src/host/controllers/social_controller.js +82 -0
  81. package/build/src/host/default_mailer.d.ts +39 -0
  82. package/build/src/host/default_mailer.js +141 -0
  83. package/build/src/host/email_templates.d.ts +35 -0
  84. package/build/src/host/email_templates.js +66 -0
  85. package/build/src/host/i18n.d.ts +178 -0
  86. package/build/src/host/i18n.js +208 -0
  87. package/build/src/host/middleware/account_auth.d.ts +7 -0
  88. package/build/src/host/middleware/account_auth.js +11 -0
  89. package/build/src/host/rate_limit.d.ts +32 -0
  90. package/build/src/host/rate_limit.js +87 -0
  91. package/build/src/host/register_auth_host.d.ts +41 -0
  92. package/build/src/host/register_auth_host.js +133 -0
  93. package/build/src/host/renderers/edge_renderer.d.ts +3 -0
  94. package/build/src/host/renderers/edge_renderer.js +29 -0
  95. package/build/src/host/renderers/inertia_renderer.d.ts +5 -0
  96. package/build/src/host/renderers/inertia_renderer.js +26 -0
  97. package/build/src/host/validators.d.ts +39 -0
  98. package/build/src/host/validators.js +13 -0
  99. package/build/src/keys/jwks_manager.d.ts +6 -0
  100. package/build/src/keys/jwks_manager.js +11 -0
  101. package/build/src/mixins/with_audit_log.d.ts +19 -0
  102. package/build/src/mixins/with_audit_log.js +41 -0
  103. package/build/src/mixins/with_auth_user.d.ts +18 -0
  104. package/build/src/mixins/with_auth_user.js +39 -0
  105. package/build/src/mixins/with_credentials.d.ts +20 -0
  106. package/build/src/mixins/with_credentials.js +29 -0
  107. package/build/src/mixins/with_mfa.d.ts +31 -0
  108. package/build/src/mixins/with_mfa.js +39 -0
  109. package/build/src/mixins/with_personal_access_token.d.ts +19 -0
  110. package/build/src/mixins/with_personal_access_token.js +44 -0
  111. package/build/src/mixins/with_provider_identity.d.ts +20 -0
  112. package/build/src/mixins/with_provider_identity.js +32 -0
  113. package/build/src/mixins/with_webauthn_credential.d.ts +37 -0
  114. package/build/src/mixins/with_webauthn_credential.js +49 -0
  115. package/build/src/observability/metrics_controller.d.ts +5 -0
  116. package/build/src/observability/metrics_controller.js +24 -0
  117. package/build/src/observability/metrics_service.d.ts +2 -0
  118. package/build/src/observability/metrics_service.js +7 -0
  119. package/build/src/observability/otel_recorder.d.ts +10 -0
  120. package/build/src/observability/otel_recorder.js +59 -0
  121. package/build/src/observability/wire_provider_events.d.ts +12 -0
  122. package/build/src/observability/wire_provider_events.js +19 -0
  123. package/build/src/pat/lucid_pat_store.d.ts +6 -0
  124. package/build/src/pat/lucid_pat_store.js +62 -0
  125. package/build/src/pat/pat_store.d.ts +31 -0
  126. package/build/src/pat/pat_store.js +1 -0
  127. package/build/src/pat/pat_tokens.d.ts +4 -0
  128. package/build/src/pat/pat_tokens.js +9 -0
  129. package/build/src/provider/build_provider.d.ts +8 -0
  130. package/build/src/provider/build_provider.js +101 -0
  131. package/build/src/provider/interaction_actions.d.ts +21 -0
  132. package/build/src/provider/interaction_actions.js +32 -0
  133. package/build/src/provider/oidc_service.d.ts +17 -0
  134. package/build/src/provider/oidc_service.js +84 -0
  135. package/build/src/provider/token_exchange.d.ts +15 -0
  136. package/build/src/provider/token_exchange.js +72 -0
  137. package/build/src/register_routes.d.ts +16 -0
  138. package/build/src/register_routes.js +21 -0
  139. package/build/stubs/config/authkit.stub +29 -0
  140. package/build/stubs/main.d.ts +1 -0
  141. package/build/stubs/main.js +2 -0
  142. package/build/stubs/models/auth_user.stub +13 -0
  143. package/build/stubs/ui/edge/views/consent.edge +13 -0
  144. package/build/stubs/ui/edge/views/login.edge +19 -0
  145. package/build/stubs/ui/react/components/auth_shell.tsx +67 -0
  146. package/build/stubs/ui/react/pages/account/login.tsx +56 -0
  147. package/build/stubs/ui/react/pages/account/mfa.tsx +132 -0
  148. package/build/stubs/ui/react/pages/account/tokens.tsx +88 -0
  149. package/build/stubs/ui/react/pages/consent.tsx +39 -0
  150. package/build/stubs/ui/react/pages/forgot.tsx +44 -0
  151. package/build/stubs/ui/react/pages/login.tsx +171 -0
  152. package/build/stubs/ui/react/pages/mfa-challenge.tsx +72 -0
  153. package/build/stubs/ui/react/pages/reset.tsx +58 -0
  154. package/build/stubs/ui/react/pages/signup.tsx +78 -0
  155. package/build/stubs/ui/react/pages/verify-email.tsx +24 -0
  156. package/build/types.d.ts +7 -0
  157. package/build/types.js +1 -0
  158. package/package.json +108 -0
  159. package/stubs/config/authkit.stub +29 -0
  160. package/stubs/main.ts +2 -0
  161. package/stubs/models/auth_user.stub +13 -0
  162. package/stubs/ui/edge/views/consent.edge +13 -0
  163. package/stubs/ui/edge/views/login.edge +19 -0
  164. package/stubs/ui/react/components/auth_shell.tsx +67 -0
  165. package/stubs/ui/react/pages/account/login.tsx +56 -0
  166. package/stubs/ui/react/pages/account/mfa.tsx +132 -0
  167. package/stubs/ui/react/pages/account/tokens.tsx +88 -0
  168. package/stubs/ui/react/pages/consent.tsx +39 -0
  169. package/stubs/ui/react/pages/forgot.tsx +44 -0
  170. package/stubs/ui/react/pages/login.tsx +171 -0
  171. package/stubs/ui/react/pages/mfa-challenge.tsx +72 -0
  172. package/stubs/ui/react/pages/reset.tsx +58 -0
  173. package/stubs/ui/react/pages/signup.tsx +78 -0
  174. package/stubs/ui/react/pages/verify-email.tsx +24 -0
@@ -0,0 +1,396 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { DateTime } from 'luxon';
3
+ import { authenticator } from 'otplib';
4
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
5
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
6
+ /** Recovery code legível: 10 chars hex em duas metades (ex.: a1b2c-3d4e5). */
7
+ function generateRecoveryCode() {
8
+ const raw = randomBytes(5).toString('hex');
9
+ return `${raw.slice(0, 5)}-${raw.slice(5, 10)}`;
10
+ }
11
+ /** Comparação de hashes hex resistente a timing. */
12
+ function hashesEqual(a, b) {
13
+ const ba = Buffer.from(a);
14
+ const bb = Buffer.from(b);
15
+ if (ba.length !== bb.length)
16
+ return false;
17
+ return timingSafeEqual(ba, bb);
18
+ }
19
+ /**
20
+ * Implementação default do {@link AccountStore} sobre um model Lucid composto
21
+ * de `withAuthUser()` + `withCredentials()` (+ opcionalmente `withMfa()`). O
22
+ * model carrega `connection`/`table` (app-específico) e, por convenção, uma
23
+ * coluna `fullName` (mapeada de `name`).
24
+ */
25
+ export function lucidAccountStore(Model, options = {}) {
26
+ const mfaIssuer = options.mfaIssuer ?? 'AuthKit';
27
+ const recoveryCodeCount = options.recoveryCodeCount ?? 8;
28
+ const encrypter = options.encrypter;
29
+ const ProviderIdentityModel = options.providerIdentityModel;
30
+ const WebauthnCredentialModel = options.webauthnCredentialModel;
31
+ // RP do WebAuthn: usado nas cerimônias. Default do rpName cai no mfaIssuer.
32
+ const webauthn = options.webauthn ?? {
33
+ rpName: mfaIssuer,
34
+ rpId: 'localhost',
35
+ origin: 'http://localhost',
36
+ };
37
+ // Cerimônias WebAuthn: reais por default, injetáveis para testes.
38
+ const ceremonies = {
39
+ generateRegistrationOptions,
40
+ verifyRegistrationResponse,
41
+ generateAuthenticationOptions,
42
+ verifyAuthenticationResponse,
43
+ ...options.webauthnCeremonies,
44
+ };
45
+ // Garante que o store foi configurado com o model das identidades antes de
46
+ // tocar nos métodos de account linking (contrato: lança com mensagem clara).
47
+ const requireProviderIdentityModel = () => {
48
+ if (!ProviderIdentityModel) {
49
+ throw new Error('lucidAccountStore: account linking por provider identity requer a opção ' +
50
+ '`providerIdentityModel` (um model Lucid composto de `withProviderIdentity()`).');
51
+ }
52
+ return ProviderIdentityModel;
53
+ };
54
+ const requireWebauthnModel = () => {
55
+ if (!WebauthnCredentialModel) {
56
+ throw new Error('lucidAccountStore: passkeys (WebAuthn) requerem a opção ' +
57
+ '`webauthnCredentialModel` (um model Lucid composto de `withWebauthnCredential()`).');
58
+ }
59
+ return WebauthnCredentialModel;
60
+ };
61
+ // Encripta o segredo antes de persistir (no-op sem encrypter).
62
+ const sealSecret = (secret) => (encrypter ? encrypter.encrypt(secret) : secret);
63
+ // Decripta o segredo armazenado; retorna null em falha/adulteração (no-op sem encrypter).
64
+ const openSecret = (stored) => {
65
+ if (!stored)
66
+ return null;
67
+ if (!encrypter)
68
+ return stored;
69
+ return encrypter.decrypt(stored);
70
+ };
71
+ const toAccount = (row) => ({
72
+ id: row.id,
73
+ email: row.email,
74
+ globalRoles: row.globalRoles ?? [],
75
+ name: row.fullName ?? undefined,
76
+ });
77
+ return {
78
+ async findById(id) {
79
+ const row = await Model.find(id);
80
+ return row ? toAccount(row) : null;
81
+ },
82
+ async findByEmail(email) {
83
+ const row = await Model.query().where('email', email).first();
84
+ return row ? toAccount(row) : null;
85
+ },
86
+ async verifyCredentials(email, password) {
87
+ const row = await Model.query().where('email', email).first();
88
+ if (!row || !(await row.verifyPassword(password)))
89
+ return null;
90
+ return toAccount(row);
91
+ },
92
+ async create(input) {
93
+ const row = await Model.create({
94
+ email: input.email,
95
+ password: input.password,
96
+ fullName: input.fullName ?? null,
97
+ globalRoles: input.globalRoles ?? [],
98
+ emailVerifiedAt: input.emailVerified ? DateTime.now() : null,
99
+ });
100
+ return toAccount(row);
101
+ },
102
+ async findByProviderIdentity(provider, providerUserId) {
103
+ const Identity = requireProviderIdentityModel();
104
+ const identity = await Identity.query()
105
+ .where('provider', provider)
106
+ .where('providerUserId', providerUserId)
107
+ .first();
108
+ if (!identity)
109
+ return null;
110
+ const row = await Model.find(identity.accountId);
111
+ return row ? toAccount(row) : null;
112
+ },
113
+ async linkProviderIdentity(data) {
114
+ const Identity = requireProviderIdentityModel();
115
+ // Upsert idempotente na chave única (provider, providerUserId): atualiza
116
+ // account/email se já existir, cria caso contrário.
117
+ const existing = await Identity.query()
118
+ .where('provider', data.provider)
119
+ .where('providerUserId', data.providerUserId)
120
+ .first();
121
+ if (existing) {
122
+ existing.accountId = data.accountId;
123
+ if (data.email !== undefined)
124
+ existing.email = data.email;
125
+ await existing.save();
126
+ return;
127
+ }
128
+ await Identity.create({
129
+ provider: data.provider,
130
+ providerUserId: data.providerUserId,
131
+ accountId: data.accountId,
132
+ email: data.email ?? null,
133
+ });
134
+ },
135
+ async issuePasswordResetToken(email) {
136
+ const row = await Model.query().where('email', email).first();
137
+ if (!row)
138
+ return null;
139
+ const token = randomBytes(32).toString('hex');
140
+ row.passwordResetToken = token;
141
+ row.passwordResetExpiresAt = DateTime.now().plus({ hours: 1 });
142
+ await row.save();
143
+ return { token, account: toAccount(row) };
144
+ },
145
+ async consumePasswordResetToken(token, newPassword) {
146
+ const row = await Model.query().where('passwordResetToken', token).first();
147
+ if (!row)
148
+ return false;
149
+ if (!row.passwordResetExpiresAt || row.passwordResetExpiresAt < DateTime.now())
150
+ return false;
151
+ row.password = newPassword;
152
+ row.passwordResetToken = null;
153
+ row.passwordResetExpiresAt = null;
154
+ await row.save();
155
+ return true;
156
+ },
157
+ async issueEmailVerificationToken(email) {
158
+ const row = await Model.query().where('email', email).first();
159
+ if (!row)
160
+ return null;
161
+ const token = randomBytes(32).toString('hex');
162
+ row.emailVerificationToken = token;
163
+ await row.save();
164
+ return { token, account: toAccount(row) };
165
+ },
166
+ async consumeEmailVerificationToken(token) {
167
+ if (!token)
168
+ return false;
169
+ const row = await Model.query().where('emailVerificationToken', token).first();
170
+ if (!row)
171
+ return false;
172
+ row.emailVerifiedAt = DateTime.now();
173
+ row.emailVerificationToken = null;
174
+ await row.save();
175
+ return true;
176
+ },
177
+ // ----- Administração (console admin) -----
178
+ async listAccounts(params) {
179
+ const page = Math.max(1, params.page ?? 1);
180
+ const limit = Math.max(1, params.limit ?? 20);
181
+ const search = params.search?.trim();
182
+ const base = () => {
183
+ const q = Model.query();
184
+ // Filtro por e-mail (substring, case-insensitive). `whereILike` cai no LIKE
185
+ // no sqlite (case-insensitive por default p/ ASCII), e em ILIKE no Postgres.
186
+ if (search)
187
+ q.whereILike('email', `%${search}%`);
188
+ return q;
189
+ };
190
+ const countResult = await base().count('* as total');
191
+ // O shape do count varia por dialeto; lê de $extras.total (Lucid).
192
+ const total = Number(countResult[0]?.$extras?.total ?? 0);
193
+ const rows = await base()
194
+ .orderBy('email', 'asc')
195
+ .offset((page - 1) * limit)
196
+ .limit(limit);
197
+ return { data: rows.map(toAccount), total };
198
+ },
199
+ async setGlobalRoles(accountId, roles) {
200
+ const row = await Model.find(accountId);
201
+ if (!row)
202
+ return;
203
+ // A coluna `globalRoles` é serializada como JSON pelo mixin withAuthUser.
204
+ row.globalRoles = roles;
205
+ await row.save();
206
+ },
207
+ // ----- MFA / TOTP -----
208
+ async getMfaState(accountId) {
209
+ const row = await Model.find(accountId);
210
+ return { enabled: !!row?.mfaEnabledAt };
211
+ },
212
+ async startTotpEnrollment(accountId) {
213
+ const row = await Model.find(accountId);
214
+ if (!row)
215
+ return null;
216
+ const secret = authenticator.generateSecret();
217
+ // Segredo PENDENTE: armazenado (encriptado em repouso) mas mfaEnabledAt continua null.
218
+ row.totpSecret = sealSecret(secret);
219
+ row.mfaEnabledAt = null;
220
+ row.recoveryCodes = null;
221
+ await row.save();
222
+ const otpauthUri = authenticator.keyuri(row.email, mfaIssuer, secret);
223
+ return { secret, otpauthUri };
224
+ },
225
+ async confirmTotpEnrollment(accountId, code) {
226
+ const row = await Model.find(accountId);
227
+ if (!row || !row.totpSecret)
228
+ return { ok: false };
229
+ const secret = openSecret(row.totpSecret);
230
+ if (!secret)
231
+ return { ok: false };
232
+ // Só confirma a partir de um segredo pendente (não re-confirma um já ativo).
233
+ const valid = authenticator.verify({ token: String(code ?? ''), secret });
234
+ if (!valid)
235
+ return { ok: false };
236
+ const codes = Array.from({ length: recoveryCodeCount }, () => generateRecoveryCode());
237
+ row.mfaEnabledAt = DateTime.now();
238
+ row.recoveryCodes = codes.map(sha256);
239
+ await row.save();
240
+ return { ok: true, recoveryCodes: codes };
241
+ },
242
+ async verifyTotp(accountId, code) {
243
+ const row = await Model.find(accountId);
244
+ if (!row || !row.mfaEnabledAt || !row.totpSecret)
245
+ return false;
246
+ const secret = openSecret(row.totpSecret);
247
+ if (!secret)
248
+ return false;
249
+ return authenticator.verify({ token: String(code ?? ''), secret });
250
+ },
251
+ async consumeRecoveryCode(accountId, code) {
252
+ const row = await Model.find(accountId);
253
+ if (!row || !row.mfaEnabledAt || !Array.isArray(row.recoveryCodes))
254
+ return false;
255
+ const target = sha256(String(code ?? '').trim());
256
+ const remaining = row.recoveryCodes.filter((h) => !hashesEqual(h, target));
257
+ if (remaining.length === row.recoveryCodes.length)
258
+ return false; // nada casou
259
+ row.recoveryCodes = remaining;
260
+ await row.save();
261
+ return true;
262
+ },
263
+ async disableMfa(accountId) {
264
+ const row = await Model.find(accountId);
265
+ if (!row)
266
+ return;
267
+ row.totpSecret = null;
268
+ row.mfaEnabledAt = null;
269
+ row.recoveryCodes = null;
270
+ await row.save();
271
+ },
272
+ // ----- MFA / WebAuthn (passkeys) -----
273
+ async generatePasskeyRegistrationOptions(accountId) {
274
+ const Credential = requireWebauthnModel();
275
+ const row = await Model.find(accountId);
276
+ if (!row)
277
+ return null;
278
+ const existing = await Credential.query().where('accountId', accountId);
279
+ const options = await ceremonies.generateRegistrationOptions({
280
+ rpName: webauthn.rpName,
281
+ rpID: webauthn.rpId,
282
+ userName: row.email,
283
+ userDisplayName: row.fullName ?? row.email,
284
+ // Não pede attestation (privacidade); confia na verificação local.
285
+ attestationType: 'none',
286
+ // Evita registrar a mesma credencial duas vezes.
287
+ excludeCredentials: existing.map((c) => ({
288
+ id: c.id,
289
+ transports: (c.transports ?? undefined),
290
+ })),
291
+ authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
292
+ });
293
+ return { options: options, challenge: options.challenge };
294
+ },
295
+ async verifyPasskeyRegistration(accountId, response, expectedChallenge) {
296
+ const Credential = requireWebauthnModel();
297
+ const row = await Model.find(accountId);
298
+ if (!row)
299
+ return false;
300
+ let verification;
301
+ try {
302
+ verification = await ceremonies.verifyRegistrationResponse({
303
+ response: response,
304
+ expectedChallenge,
305
+ expectedOrigin: webauthn.origin,
306
+ expectedRPID: webauthn.rpId,
307
+ });
308
+ }
309
+ catch {
310
+ return false;
311
+ }
312
+ if (!verification.verified || !verification.registrationInfo)
313
+ return false;
314
+ const { credential } = verification.registrationInfo;
315
+ // publicKey vem como Uint8Array → armazenamos como base64url (texto).
316
+ const publicKey = Buffer.from(credential.publicKey).toString('base64url');
317
+ await Credential.create({
318
+ id: credential.id,
319
+ accountId,
320
+ publicKey,
321
+ counter: credential.counter,
322
+ transports: credential.transports ?? null,
323
+ label: null,
324
+ });
325
+ // Registrar uma passkey também habilita o MFA (2º fator presente).
326
+ if (!row.mfaEnabledAt) {
327
+ row.mfaEnabledAt = DateTime.now();
328
+ await row.save();
329
+ }
330
+ return true;
331
+ },
332
+ async generatePasskeyAuthenticationOptions(accountId) {
333
+ const Credential = requireWebauthnModel();
334
+ const creds = await Credential.query().where('accountId', accountId);
335
+ if (creds.length === 0)
336
+ return null;
337
+ const options = await ceremonies.generateAuthenticationOptions({
338
+ rpID: webauthn.rpId,
339
+ allowCredentials: creds.map((c) => ({
340
+ id: c.id,
341
+ transports: (c.transports ?? undefined),
342
+ })),
343
+ userVerification: 'preferred',
344
+ });
345
+ return { options: options, challenge: options.challenge };
346
+ },
347
+ async verifyPasskeyAuthentication(accountId, response, expectedChallenge) {
348
+ const Credential = requireWebauthnModel();
349
+ const resp = response;
350
+ // O credential id vem na resposta (base64url) → acha a credencial da conta.
351
+ const cred = await Credential.query()
352
+ .where('accountId', accountId)
353
+ .where('id', resp?.id ?? '')
354
+ .first();
355
+ if (!cred)
356
+ return false;
357
+ let verification;
358
+ try {
359
+ verification = await ceremonies.verifyAuthenticationResponse({
360
+ response: resp,
361
+ expectedChallenge,
362
+ expectedOrigin: webauthn.origin,
363
+ expectedRPID: webauthn.rpId,
364
+ credential: {
365
+ id: cred.id,
366
+ publicKey: new Uint8Array(Buffer.from(cred.publicKey, 'base64url')),
367
+ counter: cred.counter,
368
+ transports: (cred.transports ?? undefined),
369
+ },
370
+ });
371
+ }
372
+ catch {
373
+ return false;
374
+ }
375
+ if (!verification.verified)
376
+ return false;
377
+ // Atualiza o signature counter (anti-replay).
378
+ cred.counter = verification.authenticationInfo.newCounter;
379
+ await cred.save();
380
+ return true;
381
+ },
382
+ async listPasskeys(accountId) {
383
+ const Credential = requireWebauthnModel();
384
+ const creds = await Credential.query().where('accountId', accountId).orderBy('createdAt', 'asc');
385
+ return creds.map((c) => ({
386
+ id: c.id,
387
+ label: c.label ?? undefined,
388
+ createdAt: c.createdAt?.toISO?.() ?? String(c.createdAt ?? ''),
389
+ }));
390
+ },
391
+ async removePasskey(accountId, credentialId) {
392
+ const Credential = requireWebauthnModel();
393
+ await Credential.query().where('accountId', accountId).where('id', credentialId).delete();
394
+ },
395
+ };
396
+ }
@@ -0,0 +1,18 @@
1
+ /** Forma mínima do payload persistido pelo oidc-provider. */
2
+ export interface OidcPayload {
3
+ [key: string]: unknown;
4
+ grantId?: string;
5
+ userCode?: string;
6
+ uid?: string;
7
+ consumed?: unknown;
8
+ }
9
+ /** Contrato que o oidc-provider espera de um adapter (um por model). */
10
+ export interface OidcAdapter {
11
+ upsert(id: string, payload: OidcPayload, expiresIn: number): Promise<void>;
12
+ find(id: string): Promise<OidcPayload | undefined>;
13
+ findByUserCode(userCode: string): Promise<OidcPayload | undefined>;
14
+ findByUid(uid: string): Promise<OidcPayload | undefined>;
15
+ consume(id: string): Promise<void>;
16
+ destroy(id: string): Promise<void>;
17
+ revokeByGrantId(grantId: string): Promise<void>;
18
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { Database } from '@adonisjs/lucid/database';
2
+ import type { OidcAdapter, OidcPayload } from './adapter_contract.js';
3
+ export declare class DatabaseAdapter implements OidcAdapter {
4
+ #private;
5
+ private name;
6
+ private db;
7
+ constructor(name: string, db: Database);
8
+ upsert(id: string, payload: OidcPayload, expiresIn: number): Promise<void>;
9
+ find(id: string): Promise<OidcPayload | undefined>;
10
+ findByUid(uid: string): Promise<OidcPayload | undefined>;
11
+ findByUserCode(userCode: string): Promise<OidcPayload | undefined>;
12
+ consume(id: string): Promise<void>;
13
+ destroy(id: string): Promise<void>;
14
+ revokeByGrantId(grantId: string): Promise<void>;
15
+ }
@@ -0,0 +1,63 @@
1
+ const TABLE = 'authkit_oidc_payloads';
2
+ export class DatabaseAdapter {
3
+ name;
4
+ db;
5
+ constructor(name, db) {
6
+ this.name = name;
7
+ this.db = db;
8
+ }
9
+ #query() {
10
+ return this.db.query().from(TABLE).where('model_name', this.name);
11
+ }
12
+ async upsert(id, payload, expiresIn) {
13
+ // Armazenamos como ISO string para que o parse de expiração seja determinístico
14
+ // independente do backend (SQLite/Postgres) e do formato nativo de timestamp.
15
+ const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000).toISOString() : null;
16
+ const row = {
17
+ id,
18
+ model_name: this.name,
19
+ payload: JSON.stringify(payload),
20
+ grant_id: payload.grantId ?? null,
21
+ user_code: payload.userCode ?? null,
22
+ uid: payload.uid ?? null,
23
+ expires_at: expiresAt,
24
+ };
25
+ const existing = await this.#query().where('id', id).first();
26
+ if (existing) {
27
+ await this.#query().where('id', id).update(row);
28
+ }
29
+ else {
30
+ await this.db.table(TABLE).insert(row);
31
+ }
32
+ }
33
+ async #parse(record) {
34
+ if (!record)
35
+ return undefined;
36
+ if (record.expires_at && new Date(record.expires_at).getTime() <= Date.now()) {
37
+ return undefined;
38
+ }
39
+ return JSON.parse(record.payload);
40
+ }
41
+ async find(id) {
42
+ return this.#parse(await this.#query().where('id', id).first());
43
+ }
44
+ async findByUid(uid) {
45
+ return this.#parse(await this.#query().where('uid', uid).first());
46
+ }
47
+ async findByUserCode(userCode) {
48
+ return this.#parse(await this.#query().where('user_code', userCode).first());
49
+ }
50
+ async consume(id) {
51
+ const found = await this.find(id);
52
+ if (!found)
53
+ return;
54
+ found.consumed = Math.floor(Date.now() / 1000);
55
+ await this.#query().where('id', id).update({ payload: JSON.stringify(found) });
56
+ }
57
+ async destroy(id) {
58
+ await this.#query().where('id', id).delete();
59
+ }
60
+ async revokeByGrantId(grantId) {
61
+ await this.db.query().from(TABLE).where('grant_id', grantId).delete();
62
+ }
63
+ }
@@ -0,0 +1,30 @@
1
+ import type { ApplicationService } from '@adonisjs/core/types';
2
+ import type { OidcAdapter } from './adapter_contract.js';
3
+ export type OidcAdapterClass = new (name: string) => OidcAdapter;
4
+ export interface AdapterFactory {
5
+ resolver(app: ApplicationService): Promise<OidcAdapterClass>;
6
+ }
7
+ export interface RedisAdapterConfig {
8
+ /** nome da conexão do @adonisjs/redis */
9
+ connection: string;
10
+ prefix?: string;
11
+ }
12
+ export interface DatabaseAdapterConfig {
13
+ /** nome da conexão Lucid (default: a primária) */
14
+ connection?: string;
15
+ }
16
+ export declare const adapters: {
17
+ /**
18
+ * Factory para o adapter Redis. O consumidor precisa ter o @adonisjs/redis
19
+ * configurado, pois o resolver resolve o `RedisManager` pelo token `'redis'`
20
+ * do container e obtém a conexão nomeada via `connection(name)`.
21
+ */
22
+ redis(config: RedisAdapterConfig): AdapterFactory;
23
+ /**
24
+ * Factory para o adapter de banco (Lucid). Resolve o `Database` manager pelo
25
+ * token `'lucid.db'`. O `DatabaseAdapter` consome o manager diretamente
26
+ * (`db.query()`/`db.table()`); quando uma conexão específica é solicitada,
27
+ * usamos `db.connection(name)` para obter o cliente daquela conexão.
28
+ */
29
+ database(config?: DatabaseAdapterConfig): AdapterFactory;
30
+ };
@@ -0,0 +1,43 @@
1
+ import { RedisAdapter } from './redis_adapter.js';
2
+ import { DatabaseAdapter } from './database_adapter.js';
3
+ export const adapters = {
4
+ /**
5
+ * Factory para o adapter Redis. O consumidor precisa ter o @adonisjs/redis
6
+ * configurado, pois o resolver resolve o `RedisManager` pelo token `'redis'`
7
+ * do container e obtém a conexão nomeada via `connection(name)`.
8
+ */
9
+ redis(config) {
10
+ return {
11
+ async resolver(app) {
12
+ const redisManager = await app.container.make('redis');
13
+ const client = redisManager.connection(config.connection);
14
+ const prefix = config.prefix ?? 'authkit';
15
+ return class extends RedisAdapter {
16
+ constructor(name) {
17
+ super(name, client, prefix);
18
+ }
19
+ };
20
+ },
21
+ };
22
+ },
23
+ /**
24
+ * Factory para o adapter de banco (Lucid). Resolve o `Database` manager pelo
25
+ * token `'lucid.db'`. O `DatabaseAdapter` consome o manager diretamente
26
+ * (`db.query()`/`db.table()`); quando uma conexão específica é solicitada,
27
+ * usamos `db.connection(name)` para obter o cliente daquela conexão.
28
+ */
29
+ database(config = {}) {
30
+ return {
31
+ async resolver(app) {
32
+ const db = await app.container.make('lucid.db');
33
+ const connection = config.connection;
34
+ const conn = connection ? db.connection(connection) : db;
35
+ return class extends DatabaseAdapter {
36
+ constructor(name) {
37
+ super(name, conn);
38
+ }
39
+ };
40
+ },
41
+ };
42
+ },
43
+ };
@@ -0,0 +1,16 @@
1
+ import type { Redis } from 'ioredis';
2
+ import type { OidcAdapter, OidcPayload } from './adapter_contract.js';
3
+ export declare class RedisAdapter implements OidcAdapter {
4
+ #private;
5
+ private name;
6
+ private redis;
7
+ private prefix;
8
+ constructor(name: string, redis: Redis, prefix: string);
9
+ upsert(id: string, payload: OidcPayload, expiresIn: number): Promise<void>;
10
+ find(id: string): Promise<OidcPayload | undefined>;
11
+ findByUid(uid: string): Promise<OidcPayload | undefined>;
12
+ findByUserCode(userCode: string): Promise<OidcPayload | undefined>;
13
+ consume(id: string): Promise<void>;
14
+ destroy(id: string): Promise<void>;
15
+ revokeByGrantId(grantId: string): Promise<void>;
16
+ }
@@ -0,0 +1,95 @@
1
+ const grantable = new Set([
2
+ 'AccessToken',
3
+ 'AuthorizationCode',
4
+ 'RefreshToken',
5
+ 'DeviceCode',
6
+ 'BackchannelAuthenticationRequest',
7
+ ]);
8
+ export class RedisAdapter {
9
+ name;
10
+ redis;
11
+ prefix;
12
+ constructor(name, redis, prefix) {
13
+ this.name = name;
14
+ this.redis = redis;
15
+ this.prefix = prefix;
16
+ }
17
+ #key(id) {
18
+ return `${this.prefix}:${this.name}:${id}`;
19
+ }
20
+ #grantKey(grantId) {
21
+ return `${this.prefix}:grant:${grantId}`;
22
+ }
23
+ #userCodeKey(userCode) {
24
+ return `${this.prefix}:userCode:${userCode}`;
25
+ }
26
+ #uidKey(uid) {
27
+ return `${this.prefix}:uid:${uid}`;
28
+ }
29
+ async upsert(id, payload, expiresIn) {
30
+ const key = this.#key(id);
31
+ const multi = this.redis.multi();
32
+ multi.set(key, JSON.stringify(payload));
33
+ if (grantable.has(this.name) && payload.grantId) {
34
+ const gk = this.#grantKey(payload.grantId);
35
+ multi.rpush(gk, key);
36
+ if (expiresIn)
37
+ multi.expire(gk, expiresIn);
38
+ }
39
+ if (payload.userCode) {
40
+ const uck = this.#userCodeKey(payload.userCode);
41
+ multi.set(uck, id);
42
+ if (expiresIn)
43
+ multi.expire(uck, expiresIn);
44
+ }
45
+ if (this.name === 'Session' && payload.uid) {
46
+ const uk = this.#uidKey(payload.uid);
47
+ multi.set(uk, id);
48
+ if (expiresIn)
49
+ multi.expire(uk, expiresIn);
50
+ }
51
+ if (expiresIn)
52
+ multi.expire(key, expiresIn);
53
+ await multi.exec();
54
+ }
55
+ async find(id) {
56
+ const data = await this.redis.get(this.#key(id));
57
+ if (!data)
58
+ return undefined;
59
+ return JSON.parse(data);
60
+ }
61
+ async findByUid(uid) {
62
+ const id = await this.redis.get(this.#uidKey(uid));
63
+ if (!id)
64
+ return undefined;
65
+ return this.find(id);
66
+ }
67
+ async findByUserCode(userCode) {
68
+ const id = await this.redis.get(this.#userCodeKey(userCode));
69
+ if (!id)
70
+ return undefined;
71
+ return this.find(id);
72
+ }
73
+ async consume(id) {
74
+ const found = await this.find(id);
75
+ if (!found)
76
+ return;
77
+ found.consumed = Math.floor(Date.now() / 1000);
78
+ const ttl = await this.redis.ttl(this.#key(id));
79
+ if (ttl > 0)
80
+ await this.redis.set(this.#key(id), JSON.stringify(found), 'EX', ttl);
81
+ else
82
+ await this.redis.set(this.#key(id), JSON.stringify(found));
83
+ }
84
+ async destroy(id) {
85
+ await this.redis.del(this.#key(id));
86
+ }
87
+ async revokeByGrantId(grantId) {
88
+ const gk = this.#grantKey(grantId);
89
+ const keys = await this.redis.lrange(gk, 0, -1);
90
+ const multi = this.redis.multi();
91
+ keys.forEach((k) => multi.del(k));
92
+ multi.del(gk);
93
+ await multi.exec();
94
+ }
95
+ }