@adonis-agora/authkit-server 0.81.3 → 0.82.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
10
  <link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
11
- <script type="module" crossorigin src="/__AUTHKIT_BASE__/assets/index-CIv26Mfg.js"></script>
11
+ <script type="module" crossorigin src="/__AUTHKIT_BASE__/assets/index-C1eSfG8B.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/__AUTHKIT_BASE__/assets/index-DTSmD4RU.css">
13
13
  </head>
14
14
  <body>
@@ -1,5 +1,7 @@
1
1
  import { DateTime } from 'luxon';
2
2
  import { generatePatToken, hashPatToken } from './pat_tokens.js';
3
+ /** Resolução de `lastUsedAt`: um uso dentro desta janela não regrava a linha. */
4
+ const LAST_USED_RESOLUTION_MS = 60_000;
3
5
  /**
4
6
  * Implementação default do {@link PatStore} sobre um model Lucid composto de
5
7
  * `withPersonalAccessToken()`. A coluna DB `user_id` é mapeada de `accountId`.
@@ -43,10 +45,14 @@ export function lucidPatStore(Model) {
43
45
  const row = await Model.query().where('token_hash', hash).first();
44
46
  if (!row)
45
47
  return null;
46
- if (row.expiresAt && row.expiresAt < DateTime.now())
48
+ const now = DateTime.now();
49
+ if (row.expiresAt && row.expiresAt < now)
47
50
  return null;
48
- row.lastUsedAt = DateTime.now();
49
- await row.save();
51
+ if (!row.lastUsedAt ||
52
+ now.toMillis() - row.lastUsedAt.toMillis() >= LAST_USED_RESOLUTION_MS) {
53
+ row.lastUsedAt = now;
54
+ await row.save();
55
+ }
50
56
  return {
51
57
  accountId: row.userId,
52
58
  scopes: row.scopes ?? [],
@@ -5,6 +5,7 @@ export interface PatRecord {
5
5
  scopes: string[];
6
6
  audience: string | null;
7
7
  createdAt: string;
8
+ /** Last successful lookup, at a one-minute resolution (`lucidPatStore` writes it at most once a minute). */
8
9
  lastUsedAt: string | null;
9
10
  expiresAt: string | null;
10
11
  }
@@ -38,6 +38,8 @@ export interface EnsureSchemaReport {
38
38
  created: string[];
39
39
  /** Colunas adicionadas em tabelas já existentes: tabela → colunas. */
40
40
  altered: Record<string, string[]>;
41
+ /** Índices criados em tabelas já existentes: tabela → nomes. Ausente quando nenhum. */
42
+ indexed?: Record<string, string[]>;
41
43
  /**
42
44
  * Desfecho da coluna `login_methods`. `ensured: false` significa que a tabela
43
45
  * da conta não existe no banco, então NADA foi feito — e é o caso que
@@ -53,5 +55,8 @@ export interface EnsureSchemaReport {
53
55
  * Garante que as tabelas do authkit existem e têm todas as colunas que esta
54
56
  * versão da lib espera. Aditivo e idempotente — seguro de rodar em todo boot
55
57
  * e dentro de migrations.
58
+ *
59
+ * `db` é o manager (`Database`, no boot) ou o client da própria migration
60
+ * (`this.db`, um `QueryClient`/`TransactionClient`, usado como está).
56
61
  */
57
62
  export declare function ensureAuthkitSchema(db: any, options?: EnsureSchemaOptions): Promise<EnsureSchemaReport>;
@@ -23,6 +23,11 @@
23
23
  * }
24
24
  * ```
25
25
  */
26
+ /** Busca por conta (`account_id`) e limpeza dos expirados (`expires_at`). */
27
+ const OIDC_PAYLOAD_INDEXES = [
28
+ { name: 'authkit_oidc_payloads_account_idx', columns: ['model_name', 'account_id'] },
29
+ { name: 'authkit_oidc_payloads_expires_idx', columns: ['expires_at'] },
30
+ ];
26
31
  const TABLES = [
27
32
  {
28
33
  name: 'authkit_oidc_payloads',
@@ -35,6 +40,9 @@ const TABLES = [
35
40
  t.string('uid', 255).nullable().index();
36
41
  t.timestamp('expires_at', { useTz: true }).nullable();
37
42
  t.primary(['model_name', 'id']);
43
+ t.string('account_id', 255).nullable();
44
+ for (const index of OIDC_PAYLOAD_INDEXES)
45
+ t.index(index.columns, index.name);
38
46
  },
39
47
  columns: {
40
48
  id: (t) => t.string('id', 255),
@@ -44,7 +52,9 @@ const TABLES = [
44
52
  user_code: (t) => t.string('user_code', 255).nullable().index(),
45
53
  uid: (t) => t.string('uid', 255).nullable().index(),
46
54
  expires_at: (t) => t.timestamp('expires_at', { useTz: true }).nullable(),
55
+ account_id: (t) => t.string('account_id', 255).nullable(),
47
56
  },
57
+ indexes: OIDC_PAYLOAD_INDEXES,
48
58
  },
49
59
  {
50
60
  name: 'auth_settings',
@@ -448,12 +458,56 @@ const TABLES = [
448
458
  },
449
459
  },
450
460
  ];
461
+ /** Linhas de um `rawQuery`, no formato de cada driver (pg, mysql2, sqlite). */
462
+ function catalogRows(result) {
463
+ if (Array.isArray(result))
464
+ return Array.isArray(result[0]) ? result[0] : result;
465
+ return result?.rows ?? [];
466
+ }
467
+ async function catalogHas(conn, sql, bindings) {
468
+ return catalogRows(await conn.rawQuery(sql, bindings)).length > 0;
469
+ }
470
+ const SQLITE_DIALECTS = new Set(['sqlite3', 'better-sqlite3', 'libsql']);
471
+ /**
472
+ * Nome de tabela (`tabela` ou `schema.tabela`) como o `to_regclass` espera, com cada
473
+ * parte entre aspas — o mesmo identificador que o knex gera.
474
+ */
475
+ function pgRelation(table) {
476
+ return table
477
+ .split('.')
478
+ .map((part) => `"${part.replace(/"/g, '""')}"`)
479
+ .join('.');
480
+ }
481
+ /** `[schema, tabela]` para o MySQL; sem schema explícito vale o `database()` corrente. */
482
+ function mysqlRelation(table) {
483
+ const dot = table.indexOf('.');
484
+ return dot === -1 ? [null, table] : [table.slice(0, dot), table.slice(dot + 1)];
485
+ }
451
486
  /**
452
- * Probe searchPath-aware: `schema.hasTable` no Postgres ignora o
453
- * search_path, então testamos com um SELECT de verdade (mesma técnica do
454
- * RuntimeSettings).
487
+ * Existência de tabela, coluna e índice pelo CATÁLOGO do dialeto, nunca por um
488
+ * SELECT na própria tabela: dentro de uma transação do Postgres (uma migration do
489
+ * host), um SELECT numa tabela ausente aborta a transação inteira. No Postgres a
490
+ * resolução é pelo search_path (`to_regclass`), como faz uma query de verdade.
491
+ * Dialeto sem consulta conhecida cai no SELECT de prova.
455
492
  */
456
493
  async function tableExists(conn, table) {
494
+ const dialect = conn.dialect?.name;
495
+ if (dialect === 'postgres' || dialect === 'redshift') {
496
+ const found = catalogRows(await conn.rawQuery('select to_regclass(?) as oid', [pgRelation(table)]));
497
+ return found[0]?.oid != null;
498
+ }
499
+ if (dialect === 'mysql') {
500
+ const [schema, name] = mysqlRelation(table);
501
+ return catalogHas(conn, 'select 1 from information_schema.tables where table_schema = coalesce(?, database()) and table_name = ? limit 1', [schema, name]);
502
+ }
503
+ if (dialect && SQLITE_DIALECTS.has(dialect)) {
504
+ return catalogHas(conn, "select 1 from sqlite_master where type = 'table' and name = ?", [
505
+ table,
506
+ ]);
507
+ }
508
+ if (dialect === 'mssql') {
509
+ return catalogHas(conn, "select 1 where object_id(?, 'U') is not null", [table]);
510
+ }
457
511
  try {
458
512
  await conn.from(table).limit(1);
459
513
  return true;
@@ -463,6 +517,20 @@ async function tableExists(conn, table) {
463
517
  }
464
518
  }
465
519
  async function columnExists(conn, table, column) {
520
+ const dialect = conn.dialect?.name;
521
+ if (dialect === 'postgres' || dialect === 'redshift') {
522
+ return catalogHas(conn, 'select 1 from pg_attribute where attrelid = to_regclass(?) and attname = ? and attnum > 0 and not attisdropped', [pgRelation(table), column]);
523
+ }
524
+ if (dialect === 'mysql') {
525
+ const [schema, name] = mysqlRelation(table);
526
+ return catalogHas(conn, 'select 1 from information_schema.columns where table_schema = coalesce(?, database()) and table_name = ? and column_name = ? limit 1', [schema, name, column]);
527
+ }
528
+ if (dialect && SQLITE_DIALECTS.has(dialect)) {
529
+ return catalogHas(conn, 'select 1 from pragma_table_info(?) where name = ?', [table, column]);
530
+ }
531
+ if (dialect === 'mssql') {
532
+ return catalogHas(conn, 'select 1 where col_length(?, ?) is not null', [table, column]);
533
+ }
466
534
  try {
467
535
  await conn.from(table).select(column).limit(1);
468
536
  return true;
@@ -471,13 +539,62 @@ async function columnExists(conn, table, column) {
471
539
  return false;
472
540
  }
473
541
  }
542
+ /** O índice existe? Por nome, no catálogo do dialeto. `null` = dialeto sem consulta conhecida. */
543
+ async function indexExists(conn, table, name) {
544
+ const dialect = conn.dialect?.name;
545
+ if (dialect === 'postgres' || dialect === 'redshift') {
546
+ const found = catalogRows(await conn.rawQuery('select to_regclass(?) as oid', [name]));
547
+ return found[0]?.oid != null;
548
+ }
549
+ if (dialect === 'mysql') {
550
+ const [schema, relation] = mysqlRelation(table);
551
+ return catalogHas(conn, 'select 1 from information_schema.statistics where table_schema = coalesce(?, database()) and table_name = ? and index_name = ? limit 1', [schema, relation, name]);
552
+ }
553
+ if (dialect && SQLITE_DIALECTS.has(dialect)) {
554
+ return catalogHas(conn, "select 1 from sqlite_master where type = 'index' and name = ?", [
555
+ name,
556
+ ]);
557
+ }
558
+ if (dialect === 'mssql') {
559
+ return catalogHas(conn, 'select 1 from sys.indexes where name = ? and object_id = object_id(?)', [name, table]);
560
+ }
561
+ return null;
562
+ }
563
+ /**
564
+ * Cria o índice quando falta. Idempotente: um índice já existente (inclusive
565
+ * criado por outra instância no mesmo boot) não é erro.
566
+ */
567
+ async function ensureIndex(conn, table, index) {
568
+ if ((await indexExists(conn, table, index.name)) === true)
569
+ return false;
570
+ try {
571
+ await conn.schema.alterTable(table, (t) => t.index(index.columns, index.name));
572
+ return true;
573
+ }
574
+ catch (error) {
575
+ const exists = await indexExists(conn, table, index.name).catch(() => null);
576
+ if (exists === true)
577
+ return false;
578
+ if (exists === null && /already exists|duplicate/i.test(String(error?.message))) {
579
+ return false;
580
+ }
581
+ throw error;
582
+ }
583
+ }
474
584
  /**
475
585
  * Garante que as tabelas do authkit existem e têm todas as colunas que esta
476
586
  * versão da lib espera. Aditivo e idempotente — seguro de rodar em todo boot
477
587
  * e dentro de migrations.
588
+ *
589
+ * `db` é o manager (`Database`, no boot) ou o client da própria migration
590
+ * (`this.db`, um `QueryClient`/`TransactionClient`, usado como está).
478
591
  */
479
592
  export async function ensureAuthkitSchema(db, options = {}) {
480
- const conn = options.connection ? db.connection(options.connection) : db.connection();
593
+ const conn = typeof db.connection !== 'function'
594
+ ? db
595
+ : options.connection
596
+ ? db.connection(options.connection)
597
+ : db.connection();
481
598
  const report = {
482
599
  created: [],
483
600
  altered: {},
@@ -507,6 +624,15 @@ export async function ensureAuthkitSchema(db, options = {}) {
507
624
  report.altered[def.name] = [];
508
625
  report.altered[def.name].push(column);
509
626
  }
627
+ for (const index of def.indexes ?? []) {
628
+ if (!(await ensureIndex(conn, def.name, index)))
629
+ continue;
630
+ if (!report.indexed)
631
+ report.indexed = {};
632
+ if (!report.indexed[def.name])
633
+ report.indexed[def.name] = [];
634
+ report.indexed[def.name].push(index.name);
635
+ }
510
636
  }
511
637
  // Coluna `login_methods` na tabela da CONTA — a tabela é HOST-owned (não está
512
638
  // em TABLES porque o nome/shape são decisão do app), mas a LIB consome a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.81.3",
3
+ "version": "0.82.1",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",
@@ -105,7 +105,7 @@
105
105
  }
106
106
  },
107
107
  "dependencies": {
108
- "@adonis-agora/authkit-core": "0.10.0",
108
+ "@adonis-agora/authkit-core": "0.10.1",
109
109
  "@simplewebauthn/browser": "14.0.0",
110
110
  "@simplewebauthn/server": "14.0.3",
111
111
  "jose": "6.2.13",
@@ -116,30 +116,30 @@
116
116
  "qrcode": "1.5.4"
117
117
  },
118
118
  "devDependencies": {
119
- "@adonis-agora/authkit-react": "0.25.0",
120
- "@adonis-agora/durable": "0.45.0",
121
- "@adonis-agora/telescope": "0.22.0",
119
+ "@adonis-agora/authkit-react": "0.25.1",
120
+ "@adonis-agora/durable": "0.53.0",
121
+ "@adonis-agora/telescope": "0.23.0",
122
122
  "@adonisjs/ally": "6.3.2",
123
123
  "@adonisjs/auth": "10.1.0",
124
124
  "@adonisjs/core": "7.6.0",
125
125
  "@adonisjs/drive": "4.0.0",
126
126
  "@adonisjs/lucid": "22.4.2",
127
127
  "@adonisjs/redis": "11.0.0",
128
- "@adonisjs/session": "8.1.0",
128
+ "@adonisjs/session": "8.3.0",
129
129
  "@adonisjs/shield": "9.0.0",
130
130
  "@adonisjs/tsconfig": "2.0.0",
131
131
  "@japa/assert": "4.2.0",
132
- "@japa/runner": "5.3.0",
133
- "@poppinss/ts-exec": "1.4.4",
132
+ "@japa/runner": "5.4.0",
133
+ "@poppinss/ts-exec": "1.5.1",
134
134
  "@tailwindcss/cli": "4.3.3",
135
- "@tanstack/intent": "0.4.0",
136
- "@tanstack/react-query": "5.102.8",
135
+ "@tanstack/intent": "0.5.6",
136
+ "@tanstack/react-query": "5.104.5",
137
137
  "@types/better-sqlite3": "9.6.0",
138
138
  "@types/ioredis-mock": "8.2.8",
139
139
  "@types/koa": "3.0.3",
140
140
  "@types/koa-mount": "4.0.5",
141
141
  "@types/luxon": "3.7.6",
142
- "@types/node": "26.5.1",
142
+ "@types/node": "26.6.5",
143
143
  "@types/qrcode": "1.5.6",
144
144
  "@types/react": "19.3.0",
145
145
  "@types/react-dom": "19.3.0",
@@ -151,15 +151,18 @@
151
151
  "ioredis": "6.0.0",
152
152
  "ioredis-mock": "8.13.1",
153
153
  "luxon": "3.7.2",
154
+ "mysql2": "3.24.6",
154
155
  "nuqs": "2.10.2",
156
+ "pg": "8.23.1",
155
157
  "react": "19.3.1",
156
158
  "react-dom": "19.3.1",
157
159
  "react-error-boundary": "6.1.6",
158
160
  "recharts": "3.10.1",
159
161
  "reflect-metadata": "0.2.2",
160
162
  "tempura": "0.4.1",
163
+ "testcontainers": "12.2.1",
161
164
  "typescript": "7.0.2",
162
- "vite": "8.3.4"
165
+ "vite": "8.3.5"
163
166
  },
164
167
  "scripts": {
165
168
  "build": "node scripts/build_host_css.mjs && node scripts/build_webauthn.mjs && node scripts/build_ui.mjs && node -e \"const fs=require('node:fs');for(const d of ['build/stubs','build/host/views'])fs.rmSync(d,{recursive:true,force:true})\" && tsc && node -e \"require('node:fs').cpSync('src/host/assets','build/src/host/assets',{recursive:true})\" && node -e \"require('node:fs').cpSync('stubs','build/stubs',{recursive:true,filter:(s)=>!s.endsWith('.ts')})\" && node -e \"const fs=require('node:fs');if(fs.existsSync('assets'))fs.cpSync('assets','build/assets',{recursive:true})\" && node -e \"require('node:fs').cpSync('src/host/views','build/host/views',{recursive:true})\" && node -e \"const fs=require('node:fs');fs.mkdirSync('build/host/ui',{recursive:true});fs.readdirSync('src/host/ui').filter(f=>f.endsWith('.html')).forEach(f=>fs.copyFileSync('src/host/ui/'+f,'build/host/ui/'+f))\" && node -e \"require('node:fs').copyFileSync('commands/commands.json','build/commands/commands.json')\" && node -e \"const fs=require('node:fs');fs.mkdirSync('build/src/password',{recursive:true});fs.copyFileSync('src/password/common_passwords.txt','build/src/password/common_passwords.txt')\"",
@@ -83,8 +83,12 @@ export default authServerConfig
83
83
 
84
84
  `adapter` is where oidc-provider persists grants/tokens/codes: `adapters.redis({
85
85
  connection })` requires `@adonisjs/redis` configured; `adapters.database({
86
- connection? })` uses Lucid and requires running the `authkit_oidc_payloads`
87
- migration. `accountStore` is the identity contract — `lucidAccountStore(AuthUser)`
86
+ connection? })` uses Lucid and stores artifacts in `authkit_oidc_payloads`, which
87
+ `ensureAuthkitSchema` creates on boot (with `schema: { autoManage: false }`, call it
88
+ from a migration). The table carries an indexed `account_id` column so per-account
89
+ session lookups and revocation stay in SQL; rows with a NULL `account_id` are still
90
+ found and get classified on read, so no backfill is needed. Works on Postgres,
91
+ MySQL and SQLite; expired rows are pruned every 10 minutes per connection. `accountStore` is the identity contract — `lucidAccountStore(AuthUser)`
88
92
  derives `findAccount`/`verifyCredentials` from your user model.
89
93
 
90
94
  Source: `stubs/config/authkit.stub`, `src/define_config.ts` (`AuthServerConfigInput`),