@heyatom/bastion-client 0.5.0 → 0.7.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
@@ -5,7 +5,7 @@ Replaces the `src/modules/bastion/` boilerplate every service used to copy by ha
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
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
- - `@heyatom/bastion-client/webhooks` — `verifyWebhookSignature` (`X-Bastion-Signature-V2` with timestamp + delivery id, legacy fallback, secret rotation on both sides), header constants, payload type.
8
+ - `@heyatom/bastion-client/webhooks` — `verifyWebhookSignature` (`X-Bastion-Signature-V2` with timestamp + delivery id, legacy fallback only with `allowLegacySignature: true` (off by default since 0.7.0), secret rotation on both sides), header constants, payload type.
9
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`).
@@ -81,7 +81,11 @@ export declare class BastionApiClient {
81
81
  * links only if its `sub` is the user the ticket was issued to (403
82
82
  * `OAUTH_LINK_USER_MISMATCH` otherwise; the code is spent either way).
83
83
  */
84
- completeSocialAccountLink(accessToken: string, code: string): Promise<BastionLinkCompleted>;
84
+ /** Step-up: `currentPassword` when the account has one, `twoFactorCode` when 2FA is on. */
85
+ completeSocialAccountLink(accessToken: string, code: string, stepUp?: {
86
+ currentPassword?: string;
87
+ twoFactorCode?: string;
88
+ }): Promise<BastionLinkCompleted>;
85
89
  unlinkSocialAccount(accessToken: string, provider: string): Promise<void>;
86
90
  updateSocialAccountSyncAvatar(accessToken: string, provider: string, syncAvatar: boolean): Promise<BastionSocialAccount>;
87
91
  resendVerification(email: string, ctx?: BastionClientContext): Promise<void>;
@@ -162,8 +162,9 @@ class BastionApiClient {
162
162
  * links only if its `sub` is the user the ticket was issued to (403
163
163
  * `OAUTH_LINK_USER_MISMATCH` otherwise; the code is spent either way).
164
164
  */
165
- completeSocialAccountLink(accessToken, code) {
166
- return this.call('POST', '/auth/me/social-accounts/link/complete', { code }, { token: accessToken });
165
+ /** Step-up: `currentPassword` when the account has one, `twoFactorCode` when 2FA is on. */
166
+ completeSocialAccountLink(accessToken, code, stepUp = {}) {
167
+ return this.call('POST', '/auth/me/social-accounts/link/complete', { code, ...stepUp }, { token: accessToken });
167
168
  }
168
169
  unlinkSocialAccount(accessToken, provider) {
169
170
  return this.call('DELETE', `/auth/me/social-accounts/${encodeURIComponent(provider)}`, undefined, { token: accessToken });
@@ -96,6 +96,8 @@ export interface BastionClientContext {
96
96
  }
97
97
  export interface BastionSocialAccount {
98
98
  provider: string;
99
+ /** The provider's own id for the account (e.g. the Twitch user id), as verified by the OAuth flow. */
100
+ providerId: string;
99
101
  username: string | null;
100
102
  email: string | null;
101
103
  avatar: string | null;
@@ -25,6 +25,13 @@ export interface VerifyWebhookOptions {
25
25
  headers: Record<string, string | string[] | undefined>;
26
26
  /** Your webhook secret, plus the previous one during a rotation. */
27
27
  secrets: string | string[];
28
+ /**
29
+ * Accept the legacy body-only `X-Bastion-Signature` when v2 is absent.
30
+ * Default `false`: legacy carries no timestamp or delivery id, so accepting
31
+ * it lets anyone holding an old delivery replay it by stripping the v2
32
+ * header. Opt in only while migrating a consumer that cannot see v2 yet.
33
+ */
34
+ allowLegacySignature?: boolean;
28
35
  /** Max age of `t` on a v2 signature, default 300 s. */
29
36
  toleranceSeconds?: number;
30
37
  /** For tests. */
@@ -46,9 +53,9 @@ export type VerifyWebhookResult = {
46
53
  * Verifies a Bastion delivery. Prefers `X-Bastion-Signature-V2`
47
54
  * (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
48
55
  * which binds the timestamp and the delivery id so a stale or replayed
49
- * delivery is rejected without trusting any unsigned header; falls back to
50
- * the legacy `X-Bastion-Signature: sha256=<hex>` over the body alone only when
51
- * v2 is absent. Any of the caller's `secrets` matching any `v1=` accepts:
56
+ * delivery is rejected without trusting any unsigned header. The legacy
57
+ * `X-Bastion-Signature: sha256=<hex>` over the body alone is accepted only
58
+ * when v2 is absent **and** `allowLegacySignature` is set. Any of the caller's `secrets` matching any `v1=` accepts:
52
59
  * that is how a secret rotation stays zero-downtime on both sides.
53
60
  */
54
61
  export declare function verifyWebhookSignature(options: VerifyWebhookOptions): VerifyWebhookResult;
@@ -27,9 +27,9 @@ function macEquals(hexA, hexB) {
27
27
  * Verifies a Bastion delivery. Prefers `X-Bastion-Signature-V2`
28
28
  * (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
29
29
  * which binds the timestamp and the delivery id so a stale or replayed
30
- * delivery is rejected without trusting any unsigned header; falls back to
31
- * the legacy `X-Bastion-Signature: sha256=<hex>` over the body alone only when
32
- * v2 is absent. Any of the caller's `secrets` matching any `v1=` accepts:
30
+ * delivery is rejected without trusting any unsigned header. The legacy
31
+ * `X-Bastion-Signature: sha256=<hex>` over the body alone is accepted only
32
+ * when v2 is absent **and** `allowLegacySignature` is set. Any of the caller's `secrets` matching any `v1=` accepts:
33
33
  * that is how a secret rotation stays zero-downtime on both sides.
34
34
  */
35
35
  function verifyWebhookSignature(options) {
@@ -57,6 +57,8 @@ function verifyWebhookSignature(options) {
57
57
  }
58
58
  return { ok: false, reason: 'mismatch' };
59
59
  }
60
+ if (!options.allowLegacySignature)
61
+ return { ok: false, reason: 'missing' };
60
62
  const legacy = header(options.headers, exports.BASTION_SIGNATURE_HEADER);
61
63
  if (!legacy)
62
64
  return { ok: false, reason: 'missing' };
@@ -81,7 +81,11 @@ export declare class BastionApiClient {
81
81
  * links only if its `sub` is the user the ticket was issued to (403
82
82
  * `OAUTH_LINK_USER_MISMATCH` otherwise; the code is spent either way).
83
83
  */
84
- completeSocialAccountLink(accessToken: string, code: string): Promise<BastionLinkCompleted>;
84
+ /** Step-up: `currentPassword` when the account has one, `twoFactorCode` when 2FA is on. */
85
+ completeSocialAccountLink(accessToken: string, code: string, stepUp?: {
86
+ currentPassword?: string;
87
+ twoFactorCode?: string;
88
+ }): Promise<BastionLinkCompleted>;
85
89
  unlinkSocialAccount(accessToken: string, provider: string): Promise<void>;
86
90
  updateSocialAccountSyncAvatar(accessToken: string, provider: string, syncAvatar: boolean): Promise<BastionSocialAccount>;
87
91
  resendVerification(email: string, ctx?: BastionClientContext): Promise<void>;
@@ -159,8 +159,9 @@ export class BastionApiClient {
159
159
  * links only if its `sub` is the user the ticket was issued to (403
160
160
  * `OAUTH_LINK_USER_MISMATCH` otherwise; the code is spent either way).
161
161
  */
162
- completeSocialAccountLink(accessToken, code) {
163
- return this.call('POST', '/auth/me/social-accounts/link/complete', { code }, { token: accessToken });
162
+ /** Step-up: `currentPassword` when the account has one, `twoFactorCode` when 2FA is on. */
163
+ completeSocialAccountLink(accessToken, code, stepUp = {}) {
164
+ return this.call('POST', '/auth/me/social-accounts/link/complete', { code, ...stepUp }, { token: accessToken });
164
165
  }
165
166
  unlinkSocialAccount(accessToken, provider) {
166
167
  return this.call('DELETE', `/auth/me/social-accounts/${encodeURIComponent(provider)}`, undefined, { token: accessToken });
@@ -96,6 +96,8 @@ export interface BastionClientContext {
96
96
  }
97
97
  export interface BastionSocialAccount {
98
98
  provider: string;
99
+ /** The provider's own id for the account (e.g. the Twitch user id), as verified by the OAuth flow. */
100
+ providerId: string;
99
101
  username: string | null;
100
102
  email: string | null;
101
103
  avatar: string | null;
@@ -25,6 +25,13 @@ export interface VerifyWebhookOptions {
25
25
  headers: Record<string, string | string[] | undefined>;
26
26
  /** Your webhook secret, plus the previous one during a rotation. */
27
27
  secrets: string | string[];
28
+ /**
29
+ * Accept the legacy body-only `X-Bastion-Signature` when v2 is absent.
30
+ * Default `false`: legacy carries no timestamp or delivery id, so accepting
31
+ * it lets anyone holding an old delivery replay it by stripping the v2
32
+ * header. Opt in only while migrating a consumer that cannot see v2 yet.
33
+ */
34
+ allowLegacySignature?: boolean;
28
35
  /** Max age of `t` on a v2 signature, default 300 s. */
29
36
  toleranceSeconds?: number;
30
37
  /** For tests. */
@@ -46,9 +53,9 @@ export type VerifyWebhookResult = {
46
53
  * Verifies a Bastion delivery. Prefers `X-Bastion-Signature-V2`
47
54
  * (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
48
55
  * which binds the timestamp and the delivery id so a stale or replayed
49
- * delivery is rejected without trusting any unsigned header; falls back to
50
- * the legacy `X-Bastion-Signature: sha256=<hex>` over the body alone only when
51
- * v2 is absent. Any of the caller's `secrets` matching any `v1=` accepts:
56
+ * delivery is rejected without trusting any unsigned header. The legacy
57
+ * `X-Bastion-Signature: sha256=<hex>` over the body alone is accepted only
58
+ * when v2 is absent **and** `allowLegacySignature` is set. Any of the caller's `secrets` matching any `v1=` accepts:
52
59
  * that is how a secret rotation stays zero-downtime on both sides.
53
60
  */
54
61
  export declare function verifyWebhookSignature(options: VerifyWebhookOptions): VerifyWebhookResult;
@@ -23,9 +23,9 @@ function macEquals(hexA, hexB) {
23
23
  * Verifies a Bastion delivery. Prefers `X-Bastion-Signature-V2`
24
24
  * (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
25
25
  * which binds the timestamp and the delivery id so a stale or replayed
26
- * delivery is rejected without trusting any unsigned header; falls back to
27
- * the legacy `X-Bastion-Signature: sha256=<hex>` over the body alone only when
28
- * v2 is absent. Any of the caller's `secrets` matching any `v1=` accepts:
26
+ * delivery is rejected without trusting any unsigned header. The legacy
27
+ * `X-Bastion-Signature: sha256=<hex>` over the body alone is accepted only
28
+ * when v2 is absent **and** `allowLegacySignature` is set. Any of the caller's `secrets` matching any `v1=` accepts:
29
29
  * that is how a secret rotation stays zero-downtime on both sides.
30
30
  */
31
31
  export function verifyWebhookSignature(options) {
@@ -53,6 +53,8 @@ export function verifyWebhookSignature(options) {
53
53
  }
54
54
  return { ok: false, reason: 'mismatch' };
55
55
  }
56
+ if (!options.allowLegacySignature)
57
+ return { ok: false, reason: 'missing' };
56
58
  const legacy = header(options.headers, BASTION_SIGNATURE_HEADER);
57
59
  if (!legacy)
58
60
  return { ok: false, reason: 'missing' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyatom/bastion-client",
3
- "version": "0.5.0",
3
+ "version": "0.7.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)",