@heyatom/bastion-client 0.6.0 → 0.7.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.
- package/README.md +1 -1
- package/dist/cjs/core/api-client.d.ts +12 -3
- package/dist/cjs/core/api-client.js +11 -5
- package/dist/cjs/webhooks/index.d.ts +10 -3
- package/dist/cjs/webhooks/index.js +5 -3
- package/dist/esm/core/api-client.d.ts +12 -3
- package/dist/esm/core/api-client.js +11 -5
- package/dist/esm/webhooks/index.d.ts +10 -3
- package/dist/esm/webhooks/index.js +5 -3
- package/package.json +1 -1
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`).
|
|
@@ -50,8 +50,13 @@ export declare class BastionApiClient {
|
|
|
50
50
|
register(input: BastionRegisterInput, ctx?: BastionClientContext): Promise<{
|
|
51
51
|
message: string;
|
|
52
52
|
}>;
|
|
53
|
-
/**
|
|
54
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Trades the one-time OAuth code for tokens. `state` is the value the app put
|
|
55
|
+
* on the initiate URL, read back from its own cookie/session (never from the
|
|
56
|
+
* callback query): Bastion refuses a code minted under a different one, which
|
|
57
|
+
* is what stops an attacker's code from being redeemed in a victim's browser.
|
|
58
|
+
*/
|
|
59
|
+
exchange(code: string, ctx?: BastionClientContext, state?: string): Promise<BastionLoginResult>;
|
|
55
60
|
completeTwoFactor(twoFactorToken: string, code: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
|
|
56
61
|
refresh(refreshToken: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
|
|
57
62
|
/** Revokes the token and any successor issued through `refresh` within the memory window. Never throws. */
|
|
@@ -81,7 +86,11 @@ export declare class BastionApiClient {
|
|
|
81
86
|
* links only if its `sub` is the user the ticket was issued to (403
|
|
82
87
|
* `OAUTH_LINK_USER_MISMATCH` otherwise; the code is spent either way).
|
|
83
88
|
*/
|
|
84
|
-
|
|
89
|
+
/** Step-up: `currentPassword` when the account has one, `twoFactorCode` when 2FA is on. */
|
|
90
|
+
completeSocialAccountLink(accessToken: string, code: string, stepUp?: {
|
|
91
|
+
currentPassword?: string;
|
|
92
|
+
twoFactorCode?: string;
|
|
93
|
+
}): Promise<BastionLinkCompleted>;
|
|
85
94
|
unlinkSocialAccount(accessToken: string, provider: string): Promise<void>;
|
|
86
95
|
updateSocialAccountSyncAvatar(accessToken: string, provider: string, syncAvatar: boolean): Promise<BastionSocialAccount>;
|
|
87
96
|
resendVerification(email: string, ctx?: BastionClientContext): Promise<void>;
|
|
@@ -61,9 +61,14 @@ class BastionApiClient {
|
|
|
61
61
|
register(input, ctx) {
|
|
62
62
|
return this.call('POST', '/auth/register', { ...input, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
63
63
|
}
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Trades the one-time OAuth code for tokens. `state` is the value the app put
|
|
66
|
+
* on the initiate URL, read back from its own cookie/session (never from the
|
|
67
|
+
* callback query): Bastion refuses a code minted under a different one, which
|
|
68
|
+
* is what stops an attacker's code from being redeemed in a victim's browser.
|
|
69
|
+
*/
|
|
70
|
+
exchange(code, ctx, state) {
|
|
71
|
+
return this.call('POST', '/auth/exchange', { code, appSlug: this.appSlug, ...(state !== undefined ? { state } : {}) }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
67
72
|
}
|
|
68
73
|
completeTwoFactor(twoFactorToken, code, ctx) {
|
|
69
74
|
return this.call('POST', '/auth/2fa/authenticate', { twoFactorToken, code }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
@@ -162,8 +167,9 @@ class BastionApiClient {
|
|
|
162
167
|
* links only if its `sub` is the user the ticket was issued to (403
|
|
163
168
|
* `OAUTH_LINK_USER_MISMATCH` otherwise; the code is spent either way).
|
|
164
169
|
*/
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
/** Step-up: `currentPassword` when the account has one, `twoFactorCode` when 2FA is on. */
|
|
171
|
+
completeSocialAccountLink(accessToken, code, stepUp = {}) {
|
|
172
|
+
return this.call('POST', '/auth/me/social-accounts/link/complete', { code, ...stepUp }, { token: accessToken });
|
|
167
173
|
}
|
|
168
174
|
unlinkSocialAccount(accessToken, provider) {
|
|
169
175
|
return this.call('DELETE', `/auth/me/social-accounts/${encodeURIComponent(provider)}`, undefined, { token: accessToken });
|
|
@@ -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
|
|
50
|
-
*
|
|
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
|
|
31
|
-
*
|
|
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' };
|
|
@@ -50,8 +50,13 @@ export declare class BastionApiClient {
|
|
|
50
50
|
register(input: BastionRegisterInput, ctx?: BastionClientContext): Promise<{
|
|
51
51
|
message: string;
|
|
52
52
|
}>;
|
|
53
|
-
/**
|
|
54
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Trades the one-time OAuth code for tokens. `state` is the value the app put
|
|
55
|
+
* on the initiate URL, read back from its own cookie/session (never from the
|
|
56
|
+
* callback query): Bastion refuses a code minted under a different one, which
|
|
57
|
+
* is what stops an attacker's code from being redeemed in a victim's browser.
|
|
58
|
+
*/
|
|
59
|
+
exchange(code: string, ctx?: BastionClientContext, state?: string): Promise<BastionLoginResult>;
|
|
55
60
|
completeTwoFactor(twoFactorToken: string, code: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
|
|
56
61
|
refresh(refreshToken: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
|
|
57
62
|
/** Revokes the token and any successor issued through `refresh` within the memory window. Never throws. */
|
|
@@ -81,7 +86,11 @@ export declare class BastionApiClient {
|
|
|
81
86
|
* links only if its `sub` is the user the ticket was issued to (403
|
|
82
87
|
* `OAUTH_LINK_USER_MISMATCH` otherwise; the code is spent either way).
|
|
83
88
|
*/
|
|
84
|
-
|
|
89
|
+
/** Step-up: `currentPassword` when the account has one, `twoFactorCode` when 2FA is on. */
|
|
90
|
+
completeSocialAccountLink(accessToken: string, code: string, stepUp?: {
|
|
91
|
+
currentPassword?: string;
|
|
92
|
+
twoFactorCode?: string;
|
|
93
|
+
}): Promise<BastionLinkCompleted>;
|
|
85
94
|
unlinkSocialAccount(accessToken: string, provider: string): Promise<void>;
|
|
86
95
|
updateSocialAccountSyncAvatar(accessToken: string, provider: string, syncAvatar: boolean): Promise<BastionSocialAccount>;
|
|
87
96
|
resendVerification(email: string, ctx?: BastionClientContext): Promise<void>;
|
|
@@ -58,9 +58,14 @@ export class BastionApiClient {
|
|
|
58
58
|
register(input, ctx) {
|
|
59
59
|
return this.call('POST', '/auth/register', { ...input, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
60
60
|
}
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Trades the one-time OAuth code for tokens. `state` is the value the app put
|
|
63
|
+
* on the initiate URL, read back from its own cookie/session (never from the
|
|
64
|
+
* callback query): Bastion refuses a code minted under a different one, which
|
|
65
|
+
* is what stops an attacker's code from being redeemed in a victim's browser.
|
|
66
|
+
*/
|
|
67
|
+
exchange(code, ctx, state) {
|
|
68
|
+
return this.call('POST', '/auth/exchange', { code, appSlug: this.appSlug, ...(state !== undefined ? { state } : {}) }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
64
69
|
}
|
|
65
70
|
completeTwoFactor(twoFactorToken, code, ctx) {
|
|
66
71
|
return this.call('POST', '/auth/2fa/authenticate', { twoFactorToken, code }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
@@ -159,8 +164,9 @@ export class BastionApiClient {
|
|
|
159
164
|
* links only if its `sub` is the user the ticket was issued to (403
|
|
160
165
|
* `OAUTH_LINK_USER_MISMATCH` otherwise; the code is spent either way).
|
|
161
166
|
*/
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
/** Step-up: `currentPassword` when the account has one, `twoFactorCode` when 2FA is on. */
|
|
168
|
+
completeSocialAccountLink(accessToken, code, stepUp = {}) {
|
|
169
|
+
return this.call('POST', '/auth/me/social-accounts/link/complete', { code, ...stepUp }, { token: accessToken });
|
|
164
170
|
}
|
|
165
171
|
unlinkSocialAccount(accessToken, provider) {
|
|
166
172
|
return this.call('DELETE', `/auth/me/social-accounts/${encodeURIComponent(provider)}`, undefined, { token: accessToken });
|
|
@@ -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
|
|
50
|
-
*
|
|
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
|
|
27
|
-
*
|
|
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.
|
|
3
|
+
"version": "0.7.1",
|
|
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)",
|