@adonis-agora/authkit-server 0.82.0 → 0.82.2

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.
@@ -1,4 +1,3 @@
1
- import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
2
1
  import { PasswordManager } from '../password/password_manager.js';
3
2
  import { buildCore } from './lucid_store/core.js';
4
3
  import { buildLoginMethods, supportsLoginMethodsColumn } from './lucid_store/login_methods.js';
@@ -7,7 +6,7 @@ import { defaultOrganizationModels } from './lucid_store/organization_models.js'
7
6
  import { buildOrganizations } from './lucid_store/organizations.js';
8
7
  import { buildPasswordExpiration, buildPasswordHistory } from './lucid_store/password_hygiene.js';
9
8
  import { buildProviderIdentity } from './lucid_store/provider_identity.js';
10
- import { hasTable, } from './lucid_store/shared.js';
9
+ import { defaultWebauthnCeremonies, hasTable, } from './lucid_store/shared.js';
11
10
  import { buildDeletion, buildEmailVerificationStatus, buildProfile, buildStatus, hasColumn, } from './lucid_store/status_profile.js';
12
11
  import { buildWebauthn } from './lucid_store/webauthn.js';
13
12
  export function resolveEmailTokens(input) {
@@ -147,10 +146,7 @@ export function lucidAccountStore(Model, options = {}) {
147
146
  };
148
147
  // Cerimônias WebAuthn: reais por default, injetáveis para testes.
149
148
  const ceremonies = {
150
- generateRegistrationOptions,
151
- verifyRegistrationResponse,
152
- generateAuthenticationOptions,
153
- verifyAuthenticationResponse,
149
+ ...defaultWebauthnCeremonies,
154
150
  ...options.webauthnCeremonies,
155
151
  };
156
152
  const passwords = new PasswordManager(options.password, {
@@ -79,9 +79,17 @@ export function buildOrganizations(ctx) {
79
79
  },
80
80
  async listOrgsForAccount(accountId) {
81
81
  const memberships = await MemberModel.query().where('account_id', accountId);
82
+ if (memberships.length === 0)
83
+ return [];
84
+ // One query for every org (by primary key, like `find`), instead of one per
85
+ // membership. The result keeps the memberships' order.
86
+ const ids = [...new Set(memberships.map((m) => m.organizationId))];
87
+ const orgs = new Map();
88
+ for (const org of await OrgModel.findMany(ids))
89
+ orgs.set(String(org.id), org);
82
90
  const result = [];
83
91
  for (const m of memberships) {
84
- const org = await OrgModel.find(m.organizationId);
92
+ const org = orgs.get(String(m.organizationId));
85
93
  if (org)
86
94
  result.push({ ...toOrgSummary(org), role: m.role });
87
95
  }
@@ -1,4 +1,4 @@
1
- import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';
1
+ import type { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';
2
2
  import type { AuditSink } from '../../audit/audit_sink.js';
3
3
  import type { PasswordManager } from '../../password/password_manager.js';
4
4
  import type { AuthAccount } from '../account_store.js';
@@ -23,6 +23,12 @@ export interface WebauthnCeremonies {
23
23
  generateAuthenticationOptions: typeof generateAuthenticationOptions;
24
24
  verifyAuthenticationResponse: typeof verifyAuthenticationResponse;
25
25
  }
26
+ /**
27
+ * As cerimônias reais (default de {@link WebauthnCeremonies}). Todas já são
28
+ * assíncronas no `@simplewebauthn/server`; cada uma só espera o import lazy antes
29
+ * de delegar.
30
+ */
31
+ export declare const defaultWebauthnCeremonies: WebauthnCeremonies;
26
32
  /** RP (Relying Party) do WebAuthn usado nas cerimônias. */
27
33
  export interface ResolvedRp {
28
34
  rpName: string;
@@ -1,4 +1,28 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ /**
3
+ * `@simplewebauthn/server` carregado na primeira cerimônia, não no boot: a maioria
4
+ * dos processos (workers, ace, apps sem passkeys) nunca faz uma. Um import por
5
+ * processo; uma falha não fica em cache (a próxima cerimônia tenta de novo).
6
+ */
7
+ let simpleWebauthn;
8
+ function loadSimpleWebauthn() {
9
+ simpleWebauthn ??= import('@simplewebauthn/server').catch((error) => {
10
+ simpleWebauthn = undefined;
11
+ throw error;
12
+ });
13
+ return simpleWebauthn;
14
+ }
15
+ /**
16
+ * As cerimônias reais (default de {@link WebauthnCeremonies}). Todas já são
17
+ * assíncronas no `@simplewebauthn/server`; cada uma só espera o import lazy antes
18
+ * de delegar.
19
+ */
20
+ export const defaultWebauthnCeremonies = {
21
+ generateRegistrationOptions: async (options) => (await loadSimpleWebauthn()).generateRegistrationOptions(options),
22
+ verifyRegistrationResponse: async (options) => (await loadSimpleWebauthn()).verifyRegistrationResponse(options),
23
+ generateAuthenticationOptions: async (options) => (await loadSimpleWebauthn()).generateAuthenticationOptions(options),
24
+ verifyAuthenticationResponse: async (options) => (await loadSimpleWebauthn()).verifyAuthenticationResponse(options),
25
+ };
2
26
  export const sha256 = (value) => createHash('sha256').update(value).digest('hex');
3
27
  /** Recovery code legível: 10 chars hex em duas metades (ex.: a1b2c-3d4e5). */
4
28
  export function generateRecoveryCode() {
@@ -119,7 +119,8 @@ export class DatabaseAdapter {
119
119
  const accountId = payload.accountId;
120
120
  row.account_id = typeof accountId === 'string' && accountId ? accountId : null;
121
121
  }
122
- const existing = await this.#query().where('id', id).first();
122
+ // Only whether the row exists matters: don't read the (possibly large) payload back.
123
+ const existing = await this.#query().where('id', id).select('id').first();
123
124
  if (existing) {
124
125
  await this.#query().where('id', id).update(row);
125
126
  }
@@ -101,15 +101,23 @@ export class RedisAdapter {
101
101
  return this.find(id);
102
102
  }
103
103
  async consume(id) {
104
- const found = await this.find(id);
105
- if (!found)
104
+ const key = this.#key(id);
105
+ // GET + TTL in one round trip (a pipeline, read-only: nothing changes before the SET).
106
+ const results = await this.redis.pipeline().get(key).ttl(key).exec();
107
+ const [getError, data] = results?.[0] ?? [null, null];
108
+ if (getError)
109
+ throw getError;
110
+ if (!data)
106
111
  return;
112
+ const [ttlError, ttl] = results?.[1] ?? [null, -1];
113
+ if (ttlError)
114
+ throw ttlError;
115
+ const found = JSON.parse(data);
107
116
  found.consumed = Math.floor(Date.now() / 1000);
108
- const ttl = await this.redis.ttl(this.#key(id));
109
117
  if (ttl > 0)
110
- await this.redis.set(this.#key(id), JSON.stringify(found), 'EX', ttl);
118
+ await this.redis.set(key, JSON.stringify(found), 'EX', ttl);
111
119
  else
112
- await this.redis.set(this.#key(id), JSON.stringify(found));
120
+ await this.redis.set(key, JSON.stringify(found));
113
121
  }
114
122
  async destroy(id) {
115
123
  await this.redis.del(this.#key(id));
@@ -1,6 +1,25 @@
1
1
  import { ACCOUNT_IDP_SESSION_KEY, readIdpSession } from './idp_session_bridge.js';
2
2
  import { impersonationState } from './impersonation_session.js';
3
3
  import { assertAccountEnabled } from './login_attempt.js';
4
+ /**
5
+ * How long (seconds) a renewal stays good enough: a linked RP whose IdP session was
6
+ * renewed less than this ago, with the current TTL, is not renewed again. Without
7
+ * it every authenticated request wrote the IdP session row and re-sent its cookie.
8
+ */
9
+ const RENEW_RESOLUTION_SEC = 60;
10
+ /**
11
+ * The linked RP's IdP session already expires between `ttl - RENEW_RESOLUTION_SEC`
12
+ * and `ttl` from now, i.e. it was renewed with this TTL moments ago. Renewing again
13
+ * would only move the expiry by those few seconds. A session that expires LATER
14
+ * than `now + ttl` (the TTL was lowered) is still renewed, which shortens it.
15
+ * Restoration (no local login yet) always renews.
16
+ */
17
+ function renewedRecently(idp, ttl, current, linkedUid) {
18
+ if (!current || linkedUid !== idp.uid || typeof idp.exp !== 'number')
19
+ return false;
20
+ const remaining = idp.exp - Date.now() / 1000;
21
+ return remaining <= ttl && remaining > ttl - RENEW_RESOLUTION_SEC;
22
+ }
4
23
  /** Same-host RP restoration: the signed IdP cookie is the persistent credential. */
5
24
  export async function ensureRpSession(ctx, sessionKey) {
6
25
  const service = await ctx.containerResolver.make('authkit.server');
@@ -39,7 +58,7 @@ export async function ensureRpSession(ctx, sessionKey) {
39
58
  }
40
59
  const adapter = service.provider.Session.adapter;
41
60
  const ttl = service.sessionTtlHolder.rememberSec;
42
- if (adapter.renewSession) {
61
+ if (adapter.renewSession && !renewedRecently(idp, ttl, current, linkedUid)) {
43
62
  if (!(await adapter.renewSession(idp.id, ttl))) {
44
63
  if (linkedUid)
45
64
  ctx.session.clear();