@heyatom/bastion-client 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,9 +4,9 @@ Consumer SDK for [Bastion](https://github.com/heyatomdev/bastion), the HeyAtom i
4
4
  Replaces the `src/modules/bastion/` boilerplate every service used to copy by hand.
5
5
 
6
6
  - `@heyatom/bastion-client` — framework-free core: `JwksVerifier` (kid-indexed cache, unknown-kid refetch with cooldown, stale-on-error), `BastionHttp` (`POST /auth/client`, `POST /events`, any other route), `ServiceTokenProvider` (single-flight refresh ahead of `exp`), payload types.
7
- - `@heyatom/bastion-client` also carries Bastion's user-API contract: `BastionApiClient` (login, register, OAuth exchange, 2FA, single-flight refresh, logout, sessions, `/me`, profile, social accounts, email/password flows, export, delete), its types (`BastionMe`, `BastionTokenPair`, …) and `BastionErrorCode` (a stable code on every `BastionHttpError`).
7
+ - `@heyatom/bastion-client` also carries Bastion's user-API contract: `BastionApiClient` (login, register, OAuth exchange, 2FA, single-flight refresh, logout, sessions, `/me`, profile, social accounts, email/password flows, export, delete), its types (`BastionMe`, `BastionTokenPair`, …) and `BastionErrorCode` (a stable code on every `BastionHttpError`: credentials, app context, refresh token, OAuth code, age gate, OAuth flow, unavailable, upstream). `refreshMemoryMs` tunes how long a refresh answer is remembered per token (default 60 s).
8
8
  - `@heyatom/bastion-client/webhooks` — `verifyWebhookSignature` (`X-Bastion-Signature-V2` with timestamp + delivery id, legacy fallback, secret rotation on both sides), header constants, payload type.
9
- - `@heyatom/bastion-client/nest` — NestJS: `BastionModule`, `ServiceClientJwtGuard`, `BastionUserGuard`, `BastionAuditService`, `AuditInterceptor`, `@Public()`, `@RequireScope()`, `@Audit()`, `@CurrentClient()`, `@CurrentAdminUser()`.
9
+ - `@heyatom/bastion-client/nest` — NestJS: `BastionModule`, `ServiceClientJwtGuard`, `BastionUserGuard`, `BastionAuditService`, `AuditInterceptor` (attributes to `req.adminUser`, any `AuditActor` shape: `sub` plus optional `tenantId`/`tenantSlug`/`role`/`appSlug`), `@Public()`, `@RequireScope()`, `@Audit()`, `@CurrentClient()`, `@CurrentAdminUser()`.
10
10
 
11
11
  Ships CJS and ESM. `jose` v6 is ESM-only, so the CJS build relies on Node's `require(esm)` — Node ≥ 20.19 / 22.12. Peer deps: `jose` (always), `@nestjs/common`, `@nestjs/core`, `rxjs`, `reflect-metadata` (for `/nest`).
12
12
 
@@ -7,6 +7,14 @@ export interface BastionApiClientOptions {
7
7
  appSlug: string;
8
8
  /** Required by Bastion when the app is registered in more than one tenant, or is `isGlobal`. */
9
9
  tenantSlug?: string;
10
+ /**
11
+ * How long a refresh answer is remembered per refresh token, default 60 000 ms.
12
+ * Within the window a second `refresh` with the same (now revoked) token
13
+ * returns the remembered pair instead of tripping Bastion's reuse detection,
14
+ * and `logout` still finds the successor. A BFF whose pages fire several
15
+ * calls before the browser sees the new cookie may want a few minutes.
16
+ */
17
+ refreshMemoryMs?: number;
10
18
  logger?: BastionLogger;
11
19
  }
12
20
  /**
@@ -27,6 +35,7 @@ export declare class BastionApiClient {
27
35
  private readonly http;
28
36
  private readonly appSlug;
29
37
  private readonly tenantSlug?;
38
+ private readonly refreshMemoryMs;
30
39
  private readonly logger;
31
40
  private readonly inFlight;
32
41
  private readonly recent;
@@ -45,7 +54,7 @@ export declare class BastionApiClient {
45
54
  exchange(code: string, ctx?: BastionClientContext): Promise<BastionLoginResult>;
46
55
  completeTwoFactor(twoFactorToken: string, code: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
47
56
  refresh(refreshToken: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
48
- /** Revokes the token and any successor issued through `refresh` in the last minute. Never throws. */
57
+ /** Revokes the token and any successor issued through `refresh` within the memory window. Never throws. */
49
58
  logout(refreshToken: string | null | undefined): Promise<void>;
50
59
  listSessions(accessToken: string): Promise<BastionSession[]>;
51
60
  revokeSession(accessToken: string, sessionId: string): Promise<void>;
@@ -68,18 +77,18 @@ export declare class BastionApiClient {
68
77
  createSocialAccountLinkTicket(accessToken: string, provider: string): Promise<BastionLinkTicket>;
69
78
  unlinkSocialAccount(accessToken: string, provider: string): Promise<void>;
70
79
  updateSocialAccountSyncAvatar(accessToken: string, provider: string, syncAvatar: boolean): Promise<BastionSocialAccount>;
71
- resendVerification(email: string): Promise<void>;
72
- verifyEmail(token: string): Promise<void>;
73
- forgotPassword(email: string): Promise<void>;
74
- resetPassword(token: string, newPassword: string): Promise<void>;
80
+ resendVerification(email: string, ctx?: BastionClientContext): Promise<void>;
81
+ verifyEmail(token: string, ctx?: BastionClientContext): Promise<void>;
82
+ forgotPassword(email: string, ctx?: BastionClientContext): Promise<void>;
83
+ resetPassword(token: string, newPassword: string, ctx?: BastionClientContext): Promise<void>;
75
84
  changePassword(accessToken: string, currentPassword: string, newPassword: string): Promise<void>;
76
85
  requestEmailChange(accessToken: string, input: BastionEmailChangeRequest): Promise<{
77
86
  message: string;
78
87
  }>;
79
- confirmEmailChange(token: string): Promise<{
88
+ confirmEmailChange(token: string, ctx?: BastionClientContext): Promise<{
80
89
  message: string;
81
90
  }>;
82
- revokeEmailChange(token: string): Promise<{
91
+ revokeEmailChange(token: string, ctx?: BastionClientContext): Promise<{
83
92
  message: string;
84
93
  }>;
85
94
  /** The user's own audit trail (`GET /auth/me/events`), paginated. */
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BastionApiClient = void 0;
4
4
  const node_crypto_1 = require("node:crypto");
5
5
  const types_js_1 = require("./types.js");
6
- const REFRESH_CACHE_MS = 60_000;
6
+ const DEFAULT_REFRESH_MEMORY_MS = 60_000;
7
7
  const AUTH_METHODS_CACHE_MS = 5 * 60_000;
8
8
  const MAX_ROTATION_HOPS = 32;
9
9
  /**
@@ -24,6 +24,7 @@ class BastionApiClient {
24
24
  http;
25
25
  appSlug;
26
26
  tenantSlug;
27
+ refreshMemoryMs;
27
28
  logger;
28
29
  inFlight = new Map();
29
30
  recent = new Map();
@@ -33,6 +34,7 @@ class BastionApiClient {
33
34
  this.http = options.http;
34
35
  this.appSlug = options.appSlug;
35
36
  this.tenantSlug = options.tenantSlug || undefined;
37
+ this.refreshMemoryMs = options.refreshMemoryMs ?? DEFAULT_REFRESH_MEMORY_MS;
36
38
  this.logger = options.logger ?? types_js_1.silentLogger;
37
39
  }
38
40
  get appContext() {
@@ -86,7 +88,7 @@ class BastionApiClient {
86
88
  this.inFlight.set(key, pending);
87
89
  return pending;
88
90
  }
89
- /** Revokes the token and any successor issued through `refresh` in the last minute. Never throws. */
91
+ /** Revokes the token and any successor issued through `refresh` within the memory window. Never throws. */
90
92
  async logout(refreshToken) {
91
93
  if (!refreshToken)
92
94
  return;
@@ -161,17 +163,19 @@ class BastionApiClient {
161
163
  return this.call('PATCH', `/auth/me/social-accounts/${encodeURIComponent(provider)}`, { syncAvatar }, { token: accessToken });
162
164
  }
163
165
  // ── email & password ─────────────────────────────────────────────────────
164
- resendVerification(email) {
165
- return this.call('POST', '/auth/resend-verification', { email, ...this.appContext });
166
+ // Unauthenticated routes: Bastion rate-limits them by IP, so pass the
167
+ // browser's context or every user behind this backend shares one bucket.
168
+ resendVerification(email, ctx) {
169
+ return this.call('POST', '/auth/resend-verification', { email, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
166
170
  }
167
- verifyEmail(token) {
168
- return this.call('GET', `/auth/verify-email?token=${encodeURIComponent(token)}`);
171
+ verifyEmail(token, ctx) {
172
+ return this.call('GET', `/auth/verify-email?token=${encodeURIComponent(token)}`, undefined, { headers: BastionApiClient.clientHeaders(ctx) });
169
173
  }
170
- forgotPassword(email) {
171
- return this.call('POST', '/auth/forgot-password', { email, ...this.appContext });
174
+ forgotPassword(email, ctx) {
175
+ return this.call('POST', '/auth/forgot-password', { email, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
172
176
  }
173
- resetPassword(token, newPassword) {
174
- return this.call('POST', '/auth/reset-password', { token, newPassword });
177
+ resetPassword(token, newPassword, ctx) {
178
+ return this.call('POST', '/auth/reset-password', { token, newPassword }, { headers: BastionApiClient.clientHeaders(ctx) });
175
179
  }
176
180
  changePassword(accessToken, currentPassword, newPassword) {
177
181
  return this.call('PATCH', '/auth/me/password', { currentPassword, newPassword }, { token: accessToken });
@@ -179,11 +183,11 @@ class BastionApiClient {
179
183
  requestEmailChange(accessToken, input) {
180
184
  return this.call('PATCH', '/auth/me/email', input, { token: accessToken });
181
185
  }
182
- confirmEmailChange(token) {
183
- return this.call('POST', '/auth/me/confirm-email', { token });
186
+ confirmEmailChange(token, ctx) {
187
+ return this.call('POST', '/auth/me/confirm-email', { token }, { headers: BastionApiClient.clientHeaders(ctx) });
184
188
  }
185
- revokeEmailChange(token) {
186
- return this.call('POST', '/auth/me/revoke-email-change', { token });
189
+ revokeEmailChange(token, ctx) {
190
+ return this.call('POST', '/auth/me/revoke-email-change', { token }, { headers: BastionApiClient.clientHeaders(ctx) });
187
191
  }
188
192
  // ── GDPR ─────────────────────────────────────────────────────────────────
189
193
  /** The user's own audit trail (`GET /auth/me/events`), paginated. */
@@ -216,10 +220,10 @@ class BastionApiClient {
216
220
  }
217
221
  prune(now) {
218
222
  for (const [key, entry] of this.recent)
219
- if (now - entry.at >= REFRESH_CACHE_MS)
223
+ if (now - entry.at >= this.refreshMemoryMs)
220
224
  this.recent.delete(key);
221
225
  for (const [key, entry] of this.successors)
222
- if (now - entry.at >= REFRESH_CACHE_MS)
226
+ if (now - entry.at >= this.refreshMemoryMs)
223
227
  this.successors.delete(key);
224
228
  }
225
229
  }
@@ -15,10 +15,20 @@ export declare const BastionErrorCode: {
15
15
  readonly INVALID_CREDENTIALS: "INVALID_CREDENTIALS";
16
16
  readonly NO_APP_ACCESS: "NO_APP_ACCESS";
17
17
  readonly ACCOUNT_INACTIVE: "ACCOUNT_INACTIVE";
18
+ /** The app/tenant pair in the request does not resolve: unknown app or tenant, inactive tenant, missing or wrong `tenantSlug`. A configuration error, never the user's. */
19
+ readonly INVALID_APP_CONTEXT: "INVALID_APP_CONTEXT";
20
+ /** `POST /auth/refresh` refused the token: expired, unknown, or replayed (the whole family is revoked). Re-authenticate. */
21
+ readonly INVALID_REFRESH_TOKEN: "INVALID_REFRESH_TOKEN";
22
+ /** `POST /auth/exchange` refused the one-time OAuth code: expired, already used, or minted for another app. */
23
+ readonly INVALID_OAUTH_CODE: "INVALID_OAUTH_CODE";
18
24
  readonly CONFLICT: "CONFLICT";
19
25
  readonly BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED";
20
26
  readonly BIRTH_DATE_INVALID: "BIRTH_DATE_INVALID";
21
27
  readonly UNDER_MINIMUM_AGE: "UNDER_MINIMUM_AGE";
28
+ readonly OAUTH_FLOW_INVALID: "OAUTH_FLOW_INVALID";
29
+ readonly OAUTH_EMAIL_UNVERIFIED: "OAUTH_EMAIL_UNVERIFIED";
30
+ readonly OAUTH_EMAIL_REQUIRED: "OAUTH_EMAIL_REQUIRED";
31
+ readonly OAUTH_NO_ACCOUNT: "OAUTH_NO_ACCOUNT";
22
32
  readonly UNAVAILABLE: "UNAVAILABLE";
23
33
  readonly UPSTREAM: "UPSTREAM";
24
34
  };
@@ -23,28 +23,55 @@ exports.BastionErrorCode = {
23
23
  INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
24
24
  NO_APP_ACCESS: 'NO_APP_ACCESS',
25
25
  ACCOUNT_INACTIVE: 'ACCOUNT_INACTIVE',
26
+ /** The app/tenant pair in the request does not resolve: unknown app or tenant, inactive tenant, missing or wrong `tenantSlug`. A configuration error, never the user's. */
27
+ INVALID_APP_CONTEXT: 'INVALID_APP_CONTEXT',
28
+ /** `POST /auth/refresh` refused the token: expired, unknown, or replayed (the whole family is revoked). Re-authenticate. */
29
+ INVALID_REFRESH_TOKEN: 'INVALID_REFRESH_TOKEN',
30
+ /** `POST /auth/exchange` refused the one-time OAuth code: expired, already used, or minted for another app. */
31
+ INVALID_OAUTH_CODE: 'INVALID_OAUTH_CODE',
26
32
  CONFLICT: 'CONFLICT',
27
33
  BIRTH_DATE_REQUIRED: 'BIRTH_DATE_REQUIRED',
28
34
  BIRTH_DATE_INVALID: 'BIRTH_DATE_INVALID',
29
35
  UNDER_MINIMUM_AGE: 'UNDER_MINIMUM_AGE',
36
+ OAUTH_FLOW_INVALID: 'OAUTH_FLOW_INVALID',
37
+ OAUTH_EMAIL_UNVERIFIED: 'OAUTH_EMAIL_UNVERIFIED',
38
+ OAUTH_EMAIL_REQUIRED: 'OAUTH_EMAIL_REQUIRED',
39
+ OAUTH_NO_ACCOUNT: 'OAUTH_NO_ACCOUNT',
30
40
  UNAVAILABLE: 'UNAVAILABLE',
31
41
  UPSTREAM: 'UPSTREAM',
32
42
  };
43
+ /** Messages Bastion already emits as a bare code: passed through unchanged. */
44
+ const CODE_MESSAGES = new Set([
45
+ exports.BastionErrorCode.BIRTH_DATE_REQUIRED,
46
+ exports.BastionErrorCode.BIRTH_DATE_INVALID,
47
+ exports.BastionErrorCode.UNDER_MINIMUM_AGE,
48
+ exports.BastionErrorCode.OAUTH_FLOW_INVALID,
49
+ exports.BastionErrorCode.OAUTH_EMAIL_UNVERIFIED,
50
+ exports.BastionErrorCode.OAUTH_EMAIL_REQUIRED,
51
+ exports.BastionErrorCode.OAUTH_NO_ACCOUNT,
52
+ ]);
53
+ const APP_CONTEXT_RE = /app not found|tenant not found|tenant is inactive|no tenants configured|tenantslug is required/i;
54
+ const OAUTH_CODE_RE = /already used code|code expired|code\/app mismatch/i;
33
55
  function classifyBastionError(status, message) {
34
- if (message === exports.BastionErrorCode.BIRTH_DATE_REQUIRED ||
35
- message === exports.BastionErrorCode.BIRTH_DATE_INVALID ||
36
- message === exports.BastionErrorCode.UNDER_MINIMUM_AGE) {
56
+ if (CODE_MESSAGES.has(message))
37
57
  return message;
38
- }
58
+ if (APP_CONTEXT_RE.test(message) && (status === 401 || status === 400))
59
+ return exports.BastionErrorCode.INVALID_APP_CONTEXT;
39
60
  if (status === 403 && /email not verified/i.test(message))
40
61
  return exports.BastionErrorCode.EMAIL_NOT_VERIFIED;
41
62
  if (status === 401) {
63
+ if (/refresh token/i.test(message))
64
+ return exports.BastionErrorCode.INVALID_REFRESH_TOKEN;
65
+ if (OAUTH_CODE_RE.test(message))
66
+ return exports.BastionErrorCode.INVALID_OAUTH_CODE;
42
67
  if (/no access to this app/i.test(message))
43
68
  return exports.BastionErrorCode.NO_APP_ACCESS;
44
69
  if (/inactive|locked/i.test(message))
45
70
  return exports.BastionErrorCode.ACCOUNT_INACTIVE;
46
71
  return exports.BastionErrorCode.INVALID_CREDENTIALS;
47
72
  }
73
+ if (status === 403 && /no access to this app/i.test(message))
74
+ return exports.BastionErrorCode.NO_APP_ACCESS;
48
75
  if (status === 409)
49
76
  return exports.BastionErrorCode.CONFLICT;
50
77
  if (status === 503)
@@ -1,7 +1,17 @@
1
1
  import { OnModuleInit } from '@nestjs/common';
2
- import type { UserJwtPayload } from '../core/types.js';
3
2
  import { BastionService } from './bastion.service.js';
4
3
  import { type BastionModuleOptions } from './options.js';
4
+ /**
5
+ * The fields `writeAsAdmin` reads from an actor. A `UserJwtPayload` fits, and
6
+ * so does a consumer's own admin payload that carries extra fields.
7
+ */
8
+ export interface AuditActor {
9
+ sub: string;
10
+ tenantId?: string;
11
+ tenantSlug?: string;
12
+ role?: string;
13
+ appSlug?: string;
14
+ }
5
15
  /**
6
16
  * Fire-and-forget audit writes to `POST /events` under this service's own
7
17
  * machine token. Never throws: a failed write is logged, the request that
@@ -12,6 +22,7 @@ export declare class BastionAuditService implements OnModuleInit {
12
22
  private readonly logger;
13
23
  private readonly tokens;
14
24
  private tenantMismatchWarned;
25
+ private noApiKeyWarned;
15
26
  constructor(bastion: BastionService, options: BastionModuleOptions);
16
27
  onModuleInit(): Promise<void>;
17
28
  /** Tenant of this service's machine token, once known. */
@@ -25,5 +36,5 @@ export declare class BastionAuditService implements OnModuleInit {
25
36
  * belongs to the same tenant as this service's token — Bastion rejects a
26
37
  * foreign userId silently — otherwise the actor goes into `metadata`.
27
38
  */
28
- writeAsAdmin(event: string, actor: UserJwtPayload | undefined, metadata?: Record<string, unknown>): Promise<void>;
39
+ writeAsAdmin(event: string, actor: AuditActor | undefined, metadata?: Record<string, unknown>): Promise<void>;
29
40
  }
@@ -29,6 +29,7 @@ let BastionAuditService = BastionAuditService_1 = class BastionAuditService {
29
29
  logger = new common_1.Logger(BastionAuditService_1.name);
30
30
  tokens;
31
31
  tenantMismatchWarned = false;
32
+ noApiKeyWarned = false;
32
33
  constructor(bastion, options) {
33
34
  this.bastion = bastion;
34
35
  this.tokens = options.apiKey
@@ -50,7 +51,12 @@ let BastionAuditService = BastionAuditService_1 = class BastionAuditService {
50
51
  }
51
52
  async write(event, opts = {}) {
52
53
  if (!this.tokens) {
53
- this.logger.warn(`audit write skipped, no apiKey configured event=${event}`);
54
+ // Once, not per event: a consumer that verifies tokens but never writes
55
+ // audit (no apiKey on purpose) would otherwise log every skipped write.
56
+ if (!this.noApiKeyWarned) {
57
+ this.noApiKeyWarned = true;
58
+ this.logger.warn(`audit writes skipped, no apiKey configured (first event=${event})`);
59
+ }
54
60
  return;
55
61
  }
56
62
  let token;
@@ -5,7 +5,7 @@ export { BastionJwksService } from './bastion-jwks.service.js';
5
5
  export { BastionService } from './bastion.service.js';
6
6
  export { BastionApiClientProvider } from './bastion-api-client.provider.js';
7
7
  export { BastionHttpException, NestBastionHttp } from './bastion-http-exception.js';
8
- export { BastionAuditService } from './bastion-audit.service.js';
8
+ export { BastionAuditService, type AuditActor } from './bastion-audit.service.js';
9
9
  export { ServiceClientJwtGuard } from './guards/service-client-jwt.guard.js';
10
10
  export { BastionUserGuard } from './guards/bastion-user.guard.js';
11
11
  export { AuditInterceptor } from './interceptors/audit.interceptor.js';
@@ -7,6 +7,14 @@ export interface BastionApiClientOptions {
7
7
  appSlug: string;
8
8
  /** Required by Bastion when the app is registered in more than one tenant, or is `isGlobal`. */
9
9
  tenantSlug?: string;
10
+ /**
11
+ * How long a refresh answer is remembered per refresh token, default 60 000 ms.
12
+ * Within the window a second `refresh` with the same (now revoked) token
13
+ * returns the remembered pair instead of tripping Bastion's reuse detection,
14
+ * and `logout` still finds the successor. A BFF whose pages fire several
15
+ * calls before the browser sees the new cookie may want a few minutes.
16
+ */
17
+ refreshMemoryMs?: number;
10
18
  logger?: BastionLogger;
11
19
  }
12
20
  /**
@@ -27,6 +35,7 @@ export declare class BastionApiClient {
27
35
  private readonly http;
28
36
  private readonly appSlug;
29
37
  private readonly tenantSlug?;
38
+ private readonly refreshMemoryMs;
30
39
  private readonly logger;
31
40
  private readonly inFlight;
32
41
  private readonly recent;
@@ -45,7 +54,7 @@ export declare class BastionApiClient {
45
54
  exchange(code: string, ctx?: BastionClientContext): Promise<BastionLoginResult>;
46
55
  completeTwoFactor(twoFactorToken: string, code: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
47
56
  refresh(refreshToken: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
48
- /** Revokes the token and any successor issued through `refresh` in the last minute. Never throws. */
57
+ /** Revokes the token and any successor issued through `refresh` within the memory window. Never throws. */
49
58
  logout(refreshToken: string | null | undefined): Promise<void>;
50
59
  listSessions(accessToken: string): Promise<BastionSession[]>;
51
60
  revokeSession(accessToken: string, sessionId: string): Promise<void>;
@@ -68,18 +77,18 @@ export declare class BastionApiClient {
68
77
  createSocialAccountLinkTicket(accessToken: string, provider: string): Promise<BastionLinkTicket>;
69
78
  unlinkSocialAccount(accessToken: string, provider: string): Promise<void>;
70
79
  updateSocialAccountSyncAvatar(accessToken: string, provider: string, syncAvatar: boolean): Promise<BastionSocialAccount>;
71
- resendVerification(email: string): Promise<void>;
72
- verifyEmail(token: string): Promise<void>;
73
- forgotPassword(email: string): Promise<void>;
74
- resetPassword(token: string, newPassword: string): Promise<void>;
80
+ resendVerification(email: string, ctx?: BastionClientContext): Promise<void>;
81
+ verifyEmail(token: string, ctx?: BastionClientContext): Promise<void>;
82
+ forgotPassword(email: string, ctx?: BastionClientContext): Promise<void>;
83
+ resetPassword(token: string, newPassword: string, ctx?: BastionClientContext): Promise<void>;
75
84
  changePassword(accessToken: string, currentPassword: string, newPassword: string): Promise<void>;
76
85
  requestEmailChange(accessToken: string, input: BastionEmailChangeRequest): Promise<{
77
86
  message: string;
78
87
  }>;
79
- confirmEmailChange(token: string): Promise<{
88
+ confirmEmailChange(token: string, ctx?: BastionClientContext): Promise<{
80
89
  message: string;
81
90
  }>;
82
- revokeEmailChange(token: string): Promise<{
91
+ revokeEmailChange(token: string, ctx?: BastionClientContext): Promise<{
83
92
  message: string;
84
93
  }>;
85
94
  /** The user's own audit trail (`GET /auth/me/events`), paginated. */
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { silentLogger } from './types.js';
3
- const REFRESH_CACHE_MS = 60_000;
3
+ const DEFAULT_REFRESH_MEMORY_MS = 60_000;
4
4
  const AUTH_METHODS_CACHE_MS = 5 * 60_000;
5
5
  const MAX_ROTATION_HOPS = 32;
6
6
  /**
@@ -21,6 +21,7 @@ export class BastionApiClient {
21
21
  http;
22
22
  appSlug;
23
23
  tenantSlug;
24
+ refreshMemoryMs;
24
25
  logger;
25
26
  inFlight = new Map();
26
27
  recent = new Map();
@@ -30,6 +31,7 @@ export class BastionApiClient {
30
31
  this.http = options.http;
31
32
  this.appSlug = options.appSlug;
32
33
  this.tenantSlug = options.tenantSlug || undefined;
34
+ this.refreshMemoryMs = options.refreshMemoryMs ?? DEFAULT_REFRESH_MEMORY_MS;
33
35
  this.logger = options.logger ?? silentLogger;
34
36
  }
35
37
  get appContext() {
@@ -83,7 +85,7 @@ export class BastionApiClient {
83
85
  this.inFlight.set(key, pending);
84
86
  return pending;
85
87
  }
86
- /** Revokes the token and any successor issued through `refresh` in the last minute. Never throws. */
88
+ /** Revokes the token and any successor issued through `refresh` within the memory window. Never throws. */
87
89
  async logout(refreshToken) {
88
90
  if (!refreshToken)
89
91
  return;
@@ -158,17 +160,19 @@ export class BastionApiClient {
158
160
  return this.call('PATCH', `/auth/me/social-accounts/${encodeURIComponent(provider)}`, { syncAvatar }, { token: accessToken });
159
161
  }
160
162
  // ── email & password ─────────────────────────────────────────────────────
161
- resendVerification(email) {
162
- return this.call('POST', '/auth/resend-verification', { email, ...this.appContext });
163
+ // Unauthenticated routes: Bastion rate-limits them by IP, so pass the
164
+ // browser's context or every user behind this backend shares one bucket.
165
+ resendVerification(email, ctx) {
166
+ return this.call('POST', '/auth/resend-verification', { email, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
163
167
  }
164
- verifyEmail(token) {
165
- return this.call('GET', `/auth/verify-email?token=${encodeURIComponent(token)}`);
168
+ verifyEmail(token, ctx) {
169
+ return this.call('GET', `/auth/verify-email?token=${encodeURIComponent(token)}`, undefined, { headers: BastionApiClient.clientHeaders(ctx) });
166
170
  }
167
- forgotPassword(email) {
168
- return this.call('POST', '/auth/forgot-password', { email, ...this.appContext });
171
+ forgotPassword(email, ctx) {
172
+ return this.call('POST', '/auth/forgot-password', { email, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
169
173
  }
170
- resetPassword(token, newPassword) {
171
- return this.call('POST', '/auth/reset-password', { token, newPassword });
174
+ resetPassword(token, newPassword, ctx) {
175
+ return this.call('POST', '/auth/reset-password', { token, newPassword }, { headers: BastionApiClient.clientHeaders(ctx) });
172
176
  }
173
177
  changePassword(accessToken, currentPassword, newPassword) {
174
178
  return this.call('PATCH', '/auth/me/password', { currentPassword, newPassword }, { token: accessToken });
@@ -176,11 +180,11 @@ export class BastionApiClient {
176
180
  requestEmailChange(accessToken, input) {
177
181
  return this.call('PATCH', '/auth/me/email', input, { token: accessToken });
178
182
  }
179
- confirmEmailChange(token) {
180
- return this.call('POST', '/auth/me/confirm-email', { token });
183
+ confirmEmailChange(token, ctx) {
184
+ return this.call('POST', '/auth/me/confirm-email', { token }, { headers: BastionApiClient.clientHeaders(ctx) });
181
185
  }
182
- revokeEmailChange(token) {
183
- return this.call('POST', '/auth/me/revoke-email-change', { token });
186
+ revokeEmailChange(token, ctx) {
187
+ return this.call('POST', '/auth/me/revoke-email-change', { token }, { headers: BastionApiClient.clientHeaders(ctx) });
184
188
  }
185
189
  // ── GDPR ─────────────────────────────────────────────────────────────────
186
190
  /** The user's own audit trail (`GET /auth/me/events`), paginated. */
@@ -213,10 +217,10 @@ export class BastionApiClient {
213
217
  }
214
218
  prune(now) {
215
219
  for (const [key, entry] of this.recent)
216
- if (now - entry.at >= REFRESH_CACHE_MS)
220
+ if (now - entry.at >= this.refreshMemoryMs)
217
221
  this.recent.delete(key);
218
222
  for (const [key, entry] of this.successors)
219
- if (now - entry.at >= REFRESH_CACHE_MS)
223
+ if (now - entry.at >= this.refreshMemoryMs)
220
224
  this.successors.delete(key);
221
225
  }
222
226
  }
@@ -15,10 +15,20 @@ export declare const BastionErrorCode: {
15
15
  readonly INVALID_CREDENTIALS: "INVALID_CREDENTIALS";
16
16
  readonly NO_APP_ACCESS: "NO_APP_ACCESS";
17
17
  readonly ACCOUNT_INACTIVE: "ACCOUNT_INACTIVE";
18
+ /** The app/tenant pair in the request does not resolve: unknown app or tenant, inactive tenant, missing or wrong `tenantSlug`. A configuration error, never the user's. */
19
+ readonly INVALID_APP_CONTEXT: "INVALID_APP_CONTEXT";
20
+ /** `POST /auth/refresh` refused the token: expired, unknown, or replayed (the whole family is revoked). Re-authenticate. */
21
+ readonly INVALID_REFRESH_TOKEN: "INVALID_REFRESH_TOKEN";
22
+ /** `POST /auth/exchange` refused the one-time OAuth code: expired, already used, or minted for another app. */
23
+ readonly INVALID_OAUTH_CODE: "INVALID_OAUTH_CODE";
18
24
  readonly CONFLICT: "CONFLICT";
19
25
  readonly BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED";
20
26
  readonly BIRTH_DATE_INVALID: "BIRTH_DATE_INVALID";
21
27
  readonly UNDER_MINIMUM_AGE: "UNDER_MINIMUM_AGE";
28
+ readonly OAUTH_FLOW_INVALID: "OAUTH_FLOW_INVALID";
29
+ readonly OAUTH_EMAIL_UNVERIFIED: "OAUTH_EMAIL_UNVERIFIED";
30
+ readonly OAUTH_EMAIL_REQUIRED: "OAUTH_EMAIL_REQUIRED";
31
+ readonly OAUTH_NO_ACCOUNT: "OAUTH_NO_ACCOUNT";
22
32
  readonly UNAVAILABLE: "UNAVAILABLE";
23
33
  readonly UPSTREAM: "UPSTREAM";
24
34
  };
@@ -18,28 +18,55 @@ export const BastionErrorCode = {
18
18
  INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
19
19
  NO_APP_ACCESS: 'NO_APP_ACCESS',
20
20
  ACCOUNT_INACTIVE: 'ACCOUNT_INACTIVE',
21
+ /** The app/tenant pair in the request does not resolve: unknown app or tenant, inactive tenant, missing or wrong `tenantSlug`. A configuration error, never the user's. */
22
+ INVALID_APP_CONTEXT: 'INVALID_APP_CONTEXT',
23
+ /** `POST /auth/refresh` refused the token: expired, unknown, or replayed (the whole family is revoked). Re-authenticate. */
24
+ INVALID_REFRESH_TOKEN: 'INVALID_REFRESH_TOKEN',
25
+ /** `POST /auth/exchange` refused the one-time OAuth code: expired, already used, or minted for another app. */
26
+ INVALID_OAUTH_CODE: 'INVALID_OAUTH_CODE',
21
27
  CONFLICT: 'CONFLICT',
22
28
  BIRTH_DATE_REQUIRED: 'BIRTH_DATE_REQUIRED',
23
29
  BIRTH_DATE_INVALID: 'BIRTH_DATE_INVALID',
24
30
  UNDER_MINIMUM_AGE: 'UNDER_MINIMUM_AGE',
31
+ OAUTH_FLOW_INVALID: 'OAUTH_FLOW_INVALID',
32
+ OAUTH_EMAIL_UNVERIFIED: 'OAUTH_EMAIL_UNVERIFIED',
33
+ OAUTH_EMAIL_REQUIRED: 'OAUTH_EMAIL_REQUIRED',
34
+ OAUTH_NO_ACCOUNT: 'OAUTH_NO_ACCOUNT',
25
35
  UNAVAILABLE: 'UNAVAILABLE',
26
36
  UPSTREAM: 'UPSTREAM',
27
37
  };
38
+ /** Messages Bastion already emits as a bare code: passed through unchanged. */
39
+ const CODE_MESSAGES = new Set([
40
+ BastionErrorCode.BIRTH_DATE_REQUIRED,
41
+ BastionErrorCode.BIRTH_DATE_INVALID,
42
+ BastionErrorCode.UNDER_MINIMUM_AGE,
43
+ BastionErrorCode.OAUTH_FLOW_INVALID,
44
+ BastionErrorCode.OAUTH_EMAIL_UNVERIFIED,
45
+ BastionErrorCode.OAUTH_EMAIL_REQUIRED,
46
+ BastionErrorCode.OAUTH_NO_ACCOUNT,
47
+ ]);
48
+ const APP_CONTEXT_RE = /app not found|tenant not found|tenant is inactive|no tenants configured|tenantslug is required/i;
49
+ const OAUTH_CODE_RE = /already used code|code expired|code\/app mismatch/i;
28
50
  export function classifyBastionError(status, message) {
29
- if (message === BastionErrorCode.BIRTH_DATE_REQUIRED ||
30
- message === BastionErrorCode.BIRTH_DATE_INVALID ||
31
- message === BastionErrorCode.UNDER_MINIMUM_AGE) {
51
+ if (CODE_MESSAGES.has(message))
32
52
  return message;
33
- }
53
+ if (APP_CONTEXT_RE.test(message) && (status === 401 || status === 400))
54
+ return BastionErrorCode.INVALID_APP_CONTEXT;
34
55
  if (status === 403 && /email not verified/i.test(message))
35
56
  return BastionErrorCode.EMAIL_NOT_VERIFIED;
36
57
  if (status === 401) {
58
+ if (/refresh token/i.test(message))
59
+ return BastionErrorCode.INVALID_REFRESH_TOKEN;
60
+ if (OAUTH_CODE_RE.test(message))
61
+ return BastionErrorCode.INVALID_OAUTH_CODE;
37
62
  if (/no access to this app/i.test(message))
38
63
  return BastionErrorCode.NO_APP_ACCESS;
39
64
  if (/inactive|locked/i.test(message))
40
65
  return BastionErrorCode.ACCOUNT_INACTIVE;
41
66
  return BastionErrorCode.INVALID_CREDENTIALS;
42
67
  }
68
+ if (status === 403 && /no access to this app/i.test(message))
69
+ return BastionErrorCode.NO_APP_ACCESS;
43
70
  if (status === 409)
44
71
  return BastionErrorCode.CONFLICT;
45
72
  if (status === 503)
@@ -1,7 +1,17 @@
1
1
  import { OnModuleInit } from '@nestjs/common';
2
- import type { UserJwtPayload } from '../core/types.js';
3
2
  import { BastionService } from './bastion.service.js';
4
3
  import { type BastionModuleOptions } from './options.js';
4
+ /**
5
+ * The fields `writeAsAdmin` reads from an actor. A `UserJwtPayload` fits, and
6
+ * so does a consumer's own admin payload that carries extra fields.
7
+ */
8
+ export interface AuditActor {
9
+ sub: string;
10
+ tenantId?: string;
11
+ tenantSlug?: string;
12
+ role?: string;
13
+ appSlug?: string;
14
+ }
5
15
  /**
6
16
  * Fire-and-forget audit writes to `POST /events` under this service's own
7
17
  * machine token. Never throws: a failed write is logged, the request that
@@ -12,6 +22,7 @@ export declare class BastionAuditService implements OnModuleInit {
12
22
  private readonly logger;
13
23
  private readonly tokens;
14
24
  private tenantMismatchWarned;
25
+ private noApiKeyWarned;
15
26
  constructor(bastion: BastionService, options: BastionModuleOptions);
16
27
  onModuleInit(): Promise<void>;
17
28
  /** Tenant of this service's machine token, once known. */
@@ -25,5 +36,5 @@ export declare class BastionAuditService implements OnModuleInit {
25
36
  * belongs to the same tenant as this service's token — Bastion rejects a
26
37
  * foreign userId silently — otherwise the actor goes into `metadata`.
27
38
  */
28
- writeAsAdmin(event: string, actor: UserJwtPayload | undefined, metadata?: Record<string, unknown>): Promise<void>;
39
+ writeAsAdmin(event: string, actor: AuditActor | undefined, metadata?: Record<string, unknown>): Promise<void>;
29
40
  }
@@ -26,6 +26,7 @@ let BastionAuditService = BastionAuditService_1 = class BastionAuditService {
26
26
  logger = new Logger(BastionAuditService_1.name);
27
27
  tokens;
28
28
  tenantMismatchWarned = false;
29
+ noApiKeyWarned = false;
29
30
  constructor(bastion, options) {
30
31
  this.bastion = bastion;
31
32
  this.tokens = options.apiKey
@@ -47,7 +48,12 @@ let BastionAuditService = BastionAuditService_1 = class BastionAuditService {
47
48
  }
48
49
  async write(event, opts = {}) {
49
50
  if (!this.tokens) {
50
- this.logger.warn(`audit write skipped, no apiKey configured event=${event}`);
51
+ // Once, not per event: a consumer that verifies tokens but never writes
52
+ // audit (no apiKey on purpose) would otherwise log every skipped write.
53
+ if (!this.noApiKeyWarned) {
54
+ this.noApiKeyWarned = true;
55
+ this.logger.warn(`audit writes skipped, no apiKey configured (first event=${event})`);
56
+ }
51
57
  return;
52
58
  }
53
59
  let token;
@@ -5,7 +5,7 @@ export { BastionJwksService } from './bastion-jwks.service.js';
5
5
  export { BastionService } from './bastion.service.js';
6
6
  export { BastionApiClientProvider } from './bastion-api-client.provider.js';
7
7
  export { BastionHttpException, NestBastionHttp } from './bastion-http-exception.js';
8
- export { BastionAuditService } from './bastion-audit.service.js';
8
+ export { BastionAuditService, type AuditActor } from './bastion-audit.service.js';
9
9
  export { ServiceClientJwtGuard } from './guards/service-client-jwt.guard.js';
10
10
  export { BastionUserGuard } from './guards/bastion-user.guard.js';
11
11
  export { AuditInterceptor } from './interceptors/audit.interceptor.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyatom/bastion-client",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Consumer SDK for the Bastion identity provider: JWKS verification, service-client auth, audit writes, NestJS guards",
5
5
  "license": "MIT",
6
6
  "author": "Andrea Tombolato <hey@heyatom.dev> (https://heyatom.dev)",