@heyatom/bastion-client 0.8.0 → 1.0.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/CHANGELOG.md +37 -0
- package/README.md +28 -5
- package/dist/cjs/core/api-client.js +8 -2
- package/dist/cjs/core/api-types.d.ts +6 -2
- package/dist/cjs/core/bastion-http.d.ts +10 -3
- package/dist/cjs/core/bastion-http.js +22 -5
- package/dist/cjs/core/errors.d.ts +19 -3
- package/dist/cjs/core/errors.js +28 -3
- package/dist/cjs/webhooks/index.d.ts +17 -3
- package/dist/cjs/webhooks/index.js +48 -11
- package/dist/esm/core/api-client.js +8 -2
- package/dist/esm/core/api-types.d.ts +6 -2
- package/dist/esm/core/bastion-http.d.ts +10 -3
- package/dist/esm/core/bastion-http.js +21 -5
- package/dist/esm/core/errors.d.ts +19 -3
- package/dist/esm/core/errors.js +28 -3
- package/dist/esm/webhooks/index.d.ts +17 -3
- package/dist/esm/webhooks/index.js +47 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
Reads both the current Bastion answers and the `{ data }` envelope Bastion 2.0 will send, so you can upgrade before Bastion does.
|
|
6
|
+
|
|
7
|
+
### Breaking
|
|
8
|
+
|
|
9
|
+
- `BastionHttp.call` (and so every `BastionApiClient`, `BastionService.call`, `ServiceTokenProvider` and `BastionHttpException` call) unwraps a success body whose only key is `data` and returns `data`. A page (`{ data, meta }`), an array, any other object and an empty body are returned unchanged. Three Bastion routes answer `{ data }` alone today (`GET /auth/me/tenants`, `GET /admin/apps/:slug/permissions`, `PUT /admin/apps/:slug/roles/:roleId/permissions`): through `call` they now give you the inner array. Read them as `Array.isArray(res) ? res : res.data`, which also works with any later Bastion.
|
|
10
|
+
- `listMyEvents` returns `{ data, meta: { total, page, limit, totalPages } }` (new type `BastionPageMeta`). An older Bastion's `{ data, total, page, limit }` is converted to this shape.
|
|
11
|
+
- `err.code` is the domain code for `PROVIDER_TAKEN`, `ALREADY_LINKED`, `OAUTH_EMAIL_TAKEN`, `AGE_ALREADY_VERIFIED` (previously `CONFLICT`), `AGE_GATE_NOT_CONFIGURED`, `CURRENT_PASSWORD_REQUIRED`, `TWO_FACTOR_CODE_REQUIRED`, `OAUTH_USER_INACTIVE`, `OAUTH_UNDER_MINIMUM_AGE` (previously `UPSTREAM`). `BastionHttpException` forwards that code to your client instead of `CONFLICT` / `BAD_REQUEST` / `FORBIDDEN`. When Bastion sends these codes in `code`, `upstreamCode` is no longer set for them.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- The error codes above in `BastionErrorCode`.
|
|
16
|
+
- Bastion's `code` is used whenever it is a `BastionErrorCode` and `message` is not itself one, so the code survives a reworded `message`. Message-based classification stays as the fallback for a Bastion that sends no domain `code`.
|
|
17
|
+
|
|
18
|
+
## 0.9.0
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `verifyWebhookSignature` verifies [Standard Webhooks](https://www.standardwebhooks.com/) headers (`webhook-id`, `webhook-timestamp`, `webhook-signature`: space-separated `v1,<base64 HMAC-SHA256("<id>.<timestamp>.<body>")>`, secret used as UTF-8). When any of them is present they are the ones checked: same tolerance, any secret against any `v1` entry, `deliveryId` = `webhook-id`, result `version: 'standard'`.
|
|
23
|
+
- Header name constants `WEBHOOK_ID_HEADER`, `WEBHOOK_TIMESTAMP_HEADER`, `WEBHOOK_SIGNATURE_HEADER`.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Without Standard Webhooks headers, `X-Bastion-Signature-V2` is verified exactly as before, so receivers keep working against an older Bastion. Bastion sends both for one release and will then drop V2. `allowLegacySignature` is unchanged.
|
|
28
|
+
- Whitespace-only entries in `secrets` (e.g. `' '` from an unset env var) are ignored instead of keying the HMAC; if none is left the result is `{ ok: false, reason: 'missing' }`. Other secrets are still used byte-exact (not trimmed).
|
|
29
|
+
|
|
30
|
+
### Breaking (types)
|
|
31
|
+
|
|
32
|
+
- `VerifyWebhookResult` gains the `version: 'standard'` variant: an exhaustive `switch` on `version` needs the new case.
|
|
33
|
+
|
|
34
|
+
## 0.8.1
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- Error code `STEP_UP_FAILED`: the 401 Bastion answers when the current password or 2FA code of a step-up protected route is wrong (or the step-up counter is locked). Tells a wrong factor apart from a dead session, also a 401 (`INVALID_CREDENTIALS`). Needs a Bastion that sends `code: 'STEP_UP_FAILED'`; an older one still classifies as `INVALID_CREDENTIALS`.
|
|
39
|
+
|
|
3
40
|
## 0.8.0
|
|
4
41
|
|
|
5
42
|
### Breaking
|
package/README.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/heyatomdev/bastion-client/main/docs/brand/banner.png" width="500" alt="HeyAtom @heyatom/bastion-client" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/@heyatom/bastion-client"><img src="https://img.shields.io/npm/v/@heyatom/bastion-client" alt="npm version" /></a>
|
|
7
|
+
<a href="https://github.com/heyatomdev/bastion-client/actions/workflows/test.yml"><img src="https://github.com/heyatomdev/bastion-client/actions/workflows/test.yml/badge.svg?branch=main" alt="Test" /></a>
|
|
8
|
+
<img src="https://img.shields.io/node/v/@heyatom/bastion-client" alt="Node version" />
|
|
9
|
+
<img src="https://img.shields.io/npm/l/@heyatom/bastion-client" alt="License" />
|
|
10
|
+
</p>
|
|
11
|
+
|
|
1
12
|
# @heyatom/bastion-client
|
|
2
13
|
|
|
3
14
|
Client SDK for [Bastion](https://github.com/heyatomdev/bastion), the HeyAtom identity provider.
|
|
@@ -136,6 +147,11 @@ export class AuthController {
|
|
|
136
147
|
from one browser must collapse into one call. Use one client instance per process.
|
|
137
148
|
- `logout` revokes the latest token in the rotation chain and never throws.
|
|
138
149
|
|
|
150
|
+
## Responses
|
|
151
|
+
|
|
152
|
+
Calls return Bastion's body. A body whose only key is `data` is unwrapped, so the same code reads
|
|
153
|
+
Bastion with and without the `{ data }` envelope; pages keep their `{ data, meta }` shape.
|
|
154
|
+
|
|
139
155
|
## Errors
|
|
140
156
|
|
|
141
157
|
Every failed call throws `BastionHttpError` (core) or `BastionHttpException` (Nest) with a stable
|
|
@@ -143,9 +159,12 @@ Every failed call throws `BastionHttpError` (core) or `BastionHttpException` (Ne
|
|
|
143
159
|
|
|
144
160
|
`INVALID_CREDENTIALS`, `EMAIL_NOT_VERIFIED`, `NO_APP_ACCESS`, `ACCOUNT_INACTIVE`,
|
|
145
161
|
`INVALID_APP_CONTEXT` (your configuration, not the user), `INVALID_REFRESH_TOKEN`,
|
|
146
|
-
`INVALID_OAUTH_CODE`, `VALIDATION_FAILED`, `
|
|
147
|
-
|
|
148
|
-
|
|
162
|
+
`INVALID_OAUTH_CODE`, `VALIDATION_FAILED`, `STEP_UP_FAILED` (wrong current password or 2FA
|
|
163
|
+
code on a step-up route: the session is fine), `CURRENT_PASSWORD_REQUIRED` and
|
|
164
|
+
`TWO_FACTOR_CODE_REQUIRED` (a step-up field is missing: ask for it), `CONFLICT`,
|
|
165
|
+
`PROVIDER_TAKEN`, `ALREADY_LINKED`, `RATE_LIMITED`, the age-gate codes (`BIRTH_DATE_REQUIRED`,
|
|
166
|
+
`BIRTH_DATE_INVALID`, `UNDER_MINIMUM_AGE`, `AGE_ALREADY_VERIFIED`, `AGE_GATE_NOT_CONFIGURED`),
|
|
167
|
+
the OAuth codes (`OAUTH_*`), `UNAVAILABLE` (Bastion unreachable or timed out) and `UPSTREAM`.
|
|
149
168
|
|
|
150
169
|
- A 400 carries the field errors in `errors: string[]`. Show them: `message` is only
|
|
151
170
|
"Validation failed".
|
|
@@ -206,8 +225,12 @@ if (!result.ok) throw new UnauthorizedException(result.reason);
|
|
|
206
225
|
// result.deliveryId: store it and ignore repeats
|
|
207
226
|
```
|
|
208
227
|
|
|
209
|
-
Checks
|
|
210
|
-
`
|
|
228
|
+
Checks [Standard Webhooks](https://www.standardwebhooks.com/) headers (`webhook-id`,
|
|
229
|
+
`webhook-timestamp`, `webhook-signature`) when present: HMAC-SHA256 over id + timestamp + body,
|
|
230
|
+
5 min tolerance, secret used as a UTF-8 string, `result.deliveryId` = `webhook-id`. Without them
|
|
231
|
+
it falls back to `X-Bastion-Signature-V2` (same guarantees), so it works against an older Bastion
|
|
232
|
+
too; Bastion will stop sending V2 in a later release. `result.version` says which one matched.
|
|
233
|
+
Leave `allowLegacySignature` off.
|
|
211
234
|
|
|
212
235
|
## Changelog
|
|
213
236
|
|
|
@@ -225,14 +225,20 @@ class BastionApiClient {
|
|
|
225
225
|
}
|
|
226
226
|
// ── GDPR ─────────────────────────────────────────────────────────────────
|
|
227
227
|
/** The user's own audit trail (`GET /auth/me/events`), paginated. */
|
|
228
|
-
listMyEvents(accessToken, page = {}) {
|
|
228
|
+
async listMyEvents(accessToken, page = {}) {
|
|
229
229
|
const params = new URLSearchParams();
|
|
230
230
|
if (page.page !== undefined)
|
|
231
231
|
params.set('page', String(page.page));
|
|
232
232
|
if (page.limit !== undefined)
|
|
233
233
|
params.set('limit', String(page.limit));
|
|
234
234
|
const qs = params.toString();
|
|
235
|
-
|
|
235
|
+
const res = await this.call('GET', `/auth/me/events${qs ? `?${qs}` : ''}`, undefined, { token: accessToken });
|
|
236
|
+
// No body (a proxy in between): nothing to normalise, same as every other method.
|
|
237
|
+
if (!res || typeof res !== 'object' || 'meta' in res)
|
|
238
|
+
return res;
|
|
239
|
+
// Bastion before 2.0: the page fields sit next to `data`.
|
|
240
|
+
const { data, total, page: current, limit } = res;
|
|
241
|
+
return { data, meta: { total, page: current, limit, totalPages: limit > 0 ? Math.ceil(total / limit) : 0 } };
|
|
236
242
|
}
|
|
237
243
|
exportData(accessToken) {
|
|
238
244
|
return this.call('GET', '/auth/me/export', undefined, { token: accessToken });
|
|
@@ -136,9 +136,13 @@ export interface BastionUserAuditEvent {
|
|
|
136
136
|
metadata: unknown;
|
|
137
137
|
createdAt: string;
|
|
138
138
|
}
|
|
139
|
-
export interface
|
|
140
|
-
data: BastionUserAuditEvent[];
|
|
139
|
+
export interface BastionPageMeta {
|
|
141
140
|
total: number;
|
|
142
141
|
page: number;
|
|
143
142
|
limit: number;
|
|
143
|
+
totalPages: number;
|
|
144
|
+
}
|
|
145
|
+
export interface BastionUserAuditEventsPage {
|
|
146
|
+
data: BastionUserAuditEvent[];
|
|
147
|
+
meta: BastionPageMeta;
|
|
144
148
|
}
|
|
@@ -11,6 +11,12 @@ export interface BastionHttpOptions {
|
|
|
11
11
|
*/
|
|
12
12
|
requestId?: () => string | undefined;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Bastion answers `{ data }` (or `{ data, meta }` for a page) from 2.0; earlier builds answer the
|
|
16
|
+
* bare body. An object whose only key is `data` gives back `data`; a page, an array, a bare body
|
|
17
|
+
* or no body passes through unchanged, so every caller reads the same shape from both.
|
|
18
|
+
*/
|
|
19
|
+
export declare function unwrapData(body: unknown): unknown;
|
|
14
20
|
export interface BastionRequestInit {
|
|
15
21
|
token?: string;
|
|
16
22
|
headers?: Record<string, string>;
|
|
@@ -24,9 +30,10 @@ export interface ClientAuthInput {
|
|
|
24
30
|
/**
|
|
25
31
|
* Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
|
|
26
32
|
* `JwksVerifier`. `call` is public so a service can reach any route with the
|
|
27
|
-
* same error handling
|
|
28
|
-
* status and a stable `code`;
|
|
29
|
-
* `UNAVAILABLE`. Pass the browser's
|
|
33
|
+
* same error handling and the same success shape (`unwrapData`): a non-2xx
|
|
34
|
+
* answer becomes `BastionHttpError` with the status and a stable `code`;
|
|
35
|
+
* Bastion unreachable or timed out is a 503 `UNAVAILABLE`. Pass the browser's
|
|
36
|
+
* IP as `X-Real-IP` on user-facing calls.
|
|
30
37
|
*/
|
|
31
38
|
export declare class BastionHttp {
|
|
32
39
|
private readonly baseUrl;
|
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.BastionHttp = void 0;
|
|
4
|
+
exports.unwrapData = unwrapData;
|
|
4
5
|
const errors_js_1 = require("./errors.js");
|
|
5
6
|
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
7
|
+
/**
|
|
8
|
+
* Bastion answers `{ data }` (or `{ data, meta }` for a page) from 2.0; earlier builds answer the
|
|
9
|
+
* bare body. An object whose only key is `data` gives back `data`; a page, an array, a bare body
|
|
10
|
+
* or no body passes through unchanged, so every caller reads the same shape from both.
|
|
11
|
+
*/
|
|
12
|
+
function unwrapData(body) {
|
|
13
|
+
if (body && typeof body === 'object' && !Array.isArray(body)) {
|
|
14
|
+
const keys = Object.keys(body);
|
|
15
|
+
if (keys.length === 1 && keys[0] === 'data')
|
|
16
|
+
return body.data;
|
|
17
|
+
}
|
|
18
|
+
return body;
|
|
19
|
+
}
|
|
6
20
|
/**
|
|
7
21
|
* Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
|
|
8
22
|
* `JwksVerifier`. `call` is public so a service can reach any route with the
|
|
9
|
-
* same error handling
|
|
10
|
-
* status and a stable `code`;
|
|
11
|
-
* `UNAVAILABLE`. Pass the browser's
|
|
23
|
+
* same error handling and the same success shape (`unwrapData`): a non-2xx
|
|
24
|
+
* answer becomes `BastionHttpError` with the status and a stable `code`;
|
|
25
|
+
* Bastion unreachable or timed out is a 503 `UNAVAILABLE`. Pass the browser's
|
|
26
|
+
* IP as `X-Real-IP` on user-facing calls.
|
|
12
27
|
*/
|
|
13
28
|
class BastionHttp {
|
|
14
29
|
baseUrl;
|
|
@@ -57,7 +72,9 @@ class BastionHttp {
|
|
|
57
72
|
: typeof parsed.message === 'string'
|
|
58
73
|
? parsed.message
|
|
59
74
|
: `Bastion error ${res.status}`;
|
|
60
|
-
// A code
|
|
75
|
+
// A code in `message` wins: an older Bastion sends it under a generic `code` that is also one
|
|
76
|
+
// of ours (ALREADY_LINKED under `code: CONFLICT`); classify reads it. Otherwise Bastion's
|
|
77
|
+
// `code` wins when it is one of ours, so a cleaned-up `message` still carries its code.
|
|
61
78
|
const code = (0, errors_js_1.isBastionErrorCode)(message)
|
|
62
79
|
? undefined
|
|
63
80
|
: (0, errors_js_1.isBastionErrorCode)(parsed.code)
|
|
@@ -70,7 +87,7 @@ class BastionHttp {
|
|
|
70
87
|
}
|
|
71
88
|
if (res.status === 204)
|
|
72
89
|
return undefined;
|
|
73
|
-
return (await res.json().catch(() => undefined));
|
|
90
|
+
return unwrapData(await res.json().catch(() => undefined));
|
|
74
91
|
}
|
|
75
92
|
clientAuth(input) {
|
|
76
93
|
return this.call('POST', '/auth/client', input);
|
|
@@ -6,9 +6,9 @@ export declare class BastionTokenError extends Error {
|
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
8
|
* Stable classification of a Bastion error answer, so an app can branch on a
|
|
9
|
-
* code instead of on Bastion's message text.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* code instead of on Bastion's message text. Bastion's own `code` is used when
|
|
10
|
+
* it is one of these; for an older Bastion that sends none, the code is derived
|
|
11
|
+
* from status + message (`classifyBastionError`).
|
|
12
12
|
*/
|
|
13
13
|
export declare const BastionErrorCode: {
|
|
14
14
|
readonly EMAIL_NOT_VERIFIED: "EMAIL_NOT_VERIFIED";
|
|
@@ -23,6 +23,8 @@ export declare const BastionErrorCode: {
|
|
|
23
23
|
readonly INVALID_OAUTH_CODE: "INVALID_OAUTH_CODE";
|
|
24
24
|
/** A 400 carrying field errors (`BastionHttpError.errors`): Bastion's `ValidationPipe`. */
|
|
25
25
|
readonly VALIDATION_FAILED: "VALIDATION_FAILED";
|
|
26
|
+
/** A 401 from a step-up protected route (password change, email change, 2FA setup/confirm/disable, account delete): the current password or 2FA code is wrong, or the step-up counter is locked. The session is fine — do not log the user out. Bastion sends it as `code`; an older Bastion sends a plain 401, classified `INVALID_CREDENTIALS`. */
|
|
27
|
+
readonly STEP_UP_FAILED: "STEP_UP_FAILED";
|
|
26
28
|
readonly CONFLICT: "CONFLICT";
|
|
27
29
|
readonly RATE_LIMITED: "RATE_LIMITED";
|
|
28
30
|
readonly BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED";
|
|
@@ -34,6 +36,20 @@ export declare const BastionErrorCode: {
|
|
|
34
36
|
readonly OAUTH_NO_ACCOUNT: "OAUTH_NO_ACCOUNT";
|
|
35
37
|
/** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
|
|
36
38
|
readonly OAUTH_LINK_USER_MISMATCH: "OAUTH_LINK_USER_MISMATCH";
|
|
39
|
+
/** OAuth signup with an email that a verified account of the tenant already owns. */
|
|
40
|
+
readonly OAUTH_EMAIL_TAKEN: "OAUTH_EMAIL_TAKEN";
|
|
41
|
+
readonly OAUTH_USER_INACTIVE: "OAUTH_USER_INACTIVE";
|
|
42
|
+
readonly OAUTH_UNDER_MINIMUM_AGE: "OAUTH_UNDER_MINIMUM_AGE";
|
|
43
|
+
/** Linking a provider account that belongs to another user. */
|
|
44
|
+
readonly PROVIDER_TAKEN: "PROVIDER_TAKEN";
|
|
45
|
+
/** The user already has an account of this provider linked. */
|
|
46
|
+
readonly ALREADY_LINKED: "ALREADY_LINKED";
|
|
47
|
+
readonly AGE_ALREADY_VERIFIED: "AGE_ALREADY_VERIFIED";
|
|
48
|
+
readonly AGE_GATE_NOT_CONFIGURED: "AGE_GATE_NOT_CONFIGURED";
|
|
49
|
+
/** A step-up route was called without `currentPassword`. Ask for it; the session is fine. */
|
|
50
|
+
readonly CURRENT_PASSWORD_REQUIRED: "CURRENT_PASSWORD_REQUIRED";
|
|
51
|
+
/** A step-up route was called without `twoFactorCode`. Ask for it; the session is fine. */
|
|
52
|
+
readonly TWO_FACTOR_CODE_REQUIRED: "TWO_FACTOR_CODE_REQUIRED";
|
|
37
53
|
readonly UNAVAILABLE: "UNAVAILABLE";
|
|
38
54
|
readonly UPSTREAM: "UPSTREAM";
|
|
39
55
|
};
|
package/dist/cjs/core/errors.js
CHANGED
|
@@ -15,9 +15,9 @@ class BastionTokenError extends Error {
|
|
|
15
15
|
exports.BastionTokenError = BastionTokenError;
|
|
16
16
|
/**
|
|
17
17
|
* Stable classification of a Bastion error answer, so an app can branch on a
|
|
18
|
-
* code instead of on Bastion's message text.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* code instead of on Bastion's message text. Bastion's own `code` is used when
|
|
19
|
+
* it is one of these; for an older Bastion that sends none, the code is derived
|
|
20
|
+
* from status + message (`classifyBastionError`).
|
|
21
21
|
*/
|
|
22
22
|
exports.BastionErrorCode = {
|
|
23
23
|
EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED',
|
|
@@ -32,6 +32,8 @@ exports.BastionErrorCode = {
|
|
|
32
32
|
INVALID_OAUTH_CODE: 'INVALID_OAUTH_CODE',
|
|
33
33
|
/** A 400 carrying field errors (`BastionHttpError.errors`): Bastion's `ValidationPipe`. */
|
|
34
34
|
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
|
35
|
+
/** A 401 from a step-up protected route (password change, email change, 2FA setup/confirm/disable, account delete): the current password or 2FA code is wrong, or the step-up counter is locked. The session is fine — do not log the user out. Bastion sends it as `code`; an older Bastion sends a plain 401, classified `INVALID_CREDENTIALS`. */
|
|
36
|
+
STEP_UP_FAILED: 'STEP_UP_FAILED',
|
|
35
37
|
CONFLICT: 'CONFLICT',
|
|
36
38
|
RATE_LIMITED: 'RATE_LIMITED',
|
|
37
39
|
BIRTH_DATE_REQUIRED: 'BIRTH_DATE_REQUIRED',
|
|
@@ -43,6 +45,20 @@ exports.BastionErrorCode = {
|
|
|
43
45
|
OAUTH_NO_ACCOUNT: 'OAUTH_NO_ACCOUNT',
|
|
44
46
|
/** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
|
|
45
47
|
OAUTH_LINK_USER_MISMATCH: 'OAUTH_LINK_USER_MISMATCH',
|
|
48
|
+
/** OAuth signup with an email that a verified account of the tenant already owns. */
|
|
49
|
+
OAUTH_EMAIL_TAKEN: 'OAUTH_EMAIL_TAKEN',
|
|
50
|
+
OAUTH_USER_INACTIVE: 'OAUTH_USER_INACTIVE',
|
|
51
|
+
OAUTH_UNDER_MINIMUM_AGE: 'OAUTH_UNDER_MINIMUM_AGE',
|
|
52
|
+
/** Linking a provider account that belongs to another user. */
|
|
53
|
+
PROVIDER_TAKEN: 'PROVIDER_TAKEN',
|
|
54
|
+
/** The user already has an account of this provider linked. */
|
|
55
|
+
ALREADY_LINKED: 'ALREADY_LINKED',
|
|
56
|
+
AGE_ALREADY_VERIFIED: 'AGE_ALREADY_VERIFIED',
|
|
57
|
+
AGE_GATE_NOT_CONFIGURED: 'AGE_GATE_NOT_CONFIGURED',
|
|
58
|
+
/** A step-up route was called without `currentPassword`. Ask for it; the session is fine. */
|
|
59
|
+
CURRENT_PASSWORD_REQUIRED: 'CURRENT_PASSWORD_REQUIRED',
|
|
60
|
+
/** A step-up route was called without `twoFactorCode`. Ask for it; the session is fine. */
|
|
61
|
+
TWO_FACTOR_CODE_REQUIRED: 'TWO_FACTOR_CODE_REQUIRED',
|
|
46
62
|
UNAVAILABLE: 'UNAVAILABLE',
|
|
47
63
|
UPSTREAM: 'UPSTREAM',
|
|
48
64
|
};
|
|
@@ -56,6 +72,15 @@ const CODE_MESSAGES = new Set([
|
|
|
56
72
|
exports.BastionErrorCode.OAUTH_EMAIL_REQUIRED,
|
|
57
73
|
exports.BastionErrorCode.OAUTH_NO_ACCOUNT,
|
|
58
74
|
exports.BastionErrorCode.OAUTH_LINK_USER_MISMATCH,
|
|
75
|
+
exports.BastionErrorCode.OAUTH_EMAIL_TAKEN,
|
|
76
|
+
exports.BastionErrorCode.OAUTH_USER_INACTIVE,
|
|
77
|
+
exports.BastionErrorCode.OAUTH_UNDER_MINIMUM_AGE,
|
|
78
|
+
exports.BastionErrorCode.PROVIDER_TAKEN,
|
|
79
|
+
exports.BastionErrorCode.ALREADY_LINKED,
|
|
80
|
+
exports.BastionErrorCode.AGE_ALREADY_VERIFIED,
|
|
81
|
+
exports.BastionErrorCode.AGE_GATE_NOT_CONFIGURED,
|
|
82
|
+
exports.BastionErrorCode.CURRENT_PASSWORD_REQUIRED,
|
|
83
|
+
exports.BastionErrorCode.TWO_FACTOR_CODE_REQUIRED,
|
|
59
84
|
]);
|
|
60
85
|
const APP_CONTEXT_RE = /app not found|tenant not found|tenant is inactive|no tenants configured|tenantslug is required/i;
|
|
61
86
|
const OAUTH_CODE_RE = /already used code|code expired|code\/app mismatch/i;
|
|
@@ -3,6 +3,10 @@ export declare const BASTION_SIGNATURE_HEADER = "x-bastion-signature";
|
|
|
3
3
|
export declare const BASTION_SIGNATURE_V2_HEADER = "x-bastion-signature-v2";
|
|
4
4
|
export declare const BASTION_EVENT_HEADER = "x-bastion-event";
|
|
5
5
|
export declare const BASTION_DELIVERY_ID_HEADER = "x-bastion-delivery-id";
|
|
6
|
+
/** Standard Webhooks (standardwebhooks.com) headers, sent alongside v2 during the migration. */
|
|
7
|
+
export declare const WEBHOOK_ID_HEADER = "webhook-id";
|
|
8
|
+
export declare const WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
|
|
9
|
+
export declare const WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
|
|
6
10
|
/** Sent to every app with a webhook URL, whether or not the user has a role in it. */
|
|
7
11
|
export declare const BASTION_LIFECYCLE_EVENTS: readonly ["user.deleted", "user.expired_unverified", "user.deactivated"];
|
|
8
12
|
export interface BastionWebhookPayload {
|
|
@@ -33,12 +37,17 @@ export interface VerifyWebhookOptions {
|
|
|
33
37
|
* left to migrate: leave it off.
|
|
34
38
|
*/
|
|
35
39
|
allowLegacySignature?: boolean;
|
|
36
|
-
/** Max
|
|
40
|
+
/** Max distance of the signed timestamp from now (Standard Webhooks and v2), default 300 s. */
|
|
37
41
|
toleranceSeconds?: number;
|
|
38
42
|
/** For tests. */
|
|
39
43
|
now?: () => number;
|
|
40
44
|
}
|
|
41
45
|
export type VerifyWebhookResult = {
|
|
46
|
+
ok: true;
|
|
47
|
+
version: 'standard';
|
|
48
|
+
timestamp: number;
|
|
49
|
+
deliveryId: string;
|
|
50
|
+
} | {
|
|
42
51
|
ok: true;
|
|
43
52
|
version: 'v2';
|
|
44
53
|
timestamp: number;
|
|
@@ -51,12 +60,17 @@ export type VerifyWebhookResult = {
|
|
|
51
60
|
reason: 'missing' | 'malformed' | 'stale' | 'mismatch';
|
|
52
61
|
};
|
|
53
62
|
/**
|
|
54
|
-
* Verifies a Bastion delivery. Prefers `
|
|
63
|
+
* Verifies a Bastion delivery. Prefers Standard Webhooks when any of `webhook-id`,
|
|
64
|
+
* `webhook-timestamp`, `webhook-signature` is present: `webhook-signature` is a
|
|
65
|
+
* space-separated list of `v1,<base64 HMAC-SHA256(secret, "<id>.<timestamp>.<body>")>`,
|
|
66
|
+
* keyed with the secret as UTF-8 (not a `whsec_` base64 key); `deliveryId` is `webhook-id`.
|
|
67
|
+
* Otherwise falls back to `X-Bastion-Signature-V2`
|
|
55
68
|
* (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
|
|
56
69
|
* which binds the timestamp and the delivery id so a stale or replayed
|
|
57
70
|
* delivery is rejected without trusting any unsigned header. The legacy
|
|
58
71
|
* `X-Bastion-Signature: sha256=<hex>` over the body alone is accepted only
|
|
59
|
-
* when
|
|
72
|
+
* when both of the above are absent **and** `allowLegacySignature` is set. Any of the caller's
|
|
73
|
+
* `secrets` matching any `v1` entry accepts:
|
|
60
74
|
* that is how a secret rotation stays zero-downtime on both sides.
|
|
61
75
|
*/
|
|
62
76
|
export declare function verifyWebhookSignature(options: VerifyWebhookOptions): VerifyWebhookResult;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.BASTION_LIFECYCLE_EVENTS = exports.BASTION_DELIVERY_ID_HEADER = exports.BASTION_EVENT_HEADER = exports.BASTION_SIGNATURE_V2_HEADER = exports.BASTION_SIGNATURE_HEADER = void 0;
|
|
3
|
+
exports.BASTION_LIFECYCLE_EVENTS = exports.WEBHOOK_SIGNATURE_HEADER = exports.WEBHOOK_TIMESTAMP_HEADER = exports.WEBHOOK_ID_HEADER = exports.BASTION_DELIVERY_ID_HEADER = exports.BASTION_EVENT_HEADER = exports.BASTION_SIGNATURE_V2_HEADER = exports.BASTION_SIGNATURE_HEADER = void 0;
|
|
4
4
|
exports.verifyWebhookSignature = verifyWebhookSignature;
|
|
5
5
|
const node_crypto_1 = require("node:crypto");
|
|
6
6
|
/** Headers Bastion sends with every delivery. Lower-case, as Node exposes them. */
|
|
@@ -8,14 +8,18 @@ exports.BASTION_SIGNATURE_HEADER = 'x-bastion-signature';
|
|
|
8
8
|
exports.BASTION_SIGNATURE_V2_HEADER = 'x-bastion-signature-v2';
|
|
9
9
|
exports.BASTION_EVENT_HEADER = 'x-bastion-event';
|
|
10
10
|
exports.BASTION_DELIVERY_ID_HEADER = 'x-bastion-delivery-id';
|
|
11
|
+
/** Standard Webhooks (standardwebhooks.com) headers, sent alongside v2 during the migration. */
|
|
12
|
+
exports.WEBHOOK_ID_HEADER = 'webhook-id';
|
|
13
|
+
exports.WEBHOOK_TIMESTAMP_HEADER = 'webhook-timestamp';
|
|
14
|
+
exports.WEBHOOK_SIGNATURE_HEADER = 'webhook-signature';
|
|
11
15
|
/** Sent to every app with a webhook URL, whether or not the user has a role in it. */
|
|
12
16
|
exports.BASTION_LIFECYCLE_EVENTS = ['user.deleted', 'user.expired_unverified', 'user.deactivated'];
|
|
13
17
|
function header(headers, name) {
|
|
14
|
-
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
let v = headers[name];
|
|
19
|
+
if (v === undefined) {
|
|
20
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === name);
|
|
21
|
+
v = key === undefined ? undefined : headers[key];
|
|
22
|
+
}
|
|
19
23
|
return Array.isArray(v) ? v[0] : v;
|
|
20
24
|
}
|
|
21
25
|
function macEquals(hexA, hexB) {
|
|
@@ -23,20 +27,54 @@ function macEquals(hexA, hexB) {
|
|
|
23
27
|
const b = Buffer.from(hexB, 'hex');
|
|
24
28
|
return a.length === b.length && a.length > 0 && (0, node_crypto_1.timingSafeEqual)(a, b);
|
|
25
29
|
}
|
|
30
|
+
function isFresh(timestamp, options) {
|
|
31
|
+
const now = Math.floor((options.now ?? Date.now)() / 1000);
|
|
32
|
+
return Math.abs(now - timestamp) <= (options.toleranceSeconds ?? 300);
|
|
33
|
+
}
|
|
26
34
|
/**
|
|
27
|
-
* Verifies a Bastion delivery. Prefers `
|
|
35
|
+
* Verifies a Bastion delivery. Prefers Standard Webhooks when any of `webhook-id`,
|
|
36
|
+
* `webhook-timestamp`, `webhook-signature` is present: `webhook-signature` is a
|
|
37
|
+
* space-separated list of `v1,<base64 HMAC-SHA256(secret, "<id>.<timestamp>.<body>")>`,
|
|
38
|
+
* keyed with the secret as UTF-8 (not a `whsec_` base64 key); `deliveryId` is `webhook-id`.
|
|
39
|
+
* Otherwise falls back to `X-Bastion-Signature-V2`
|
|
28
40
|
* (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
|
|
29
41
|
* which binds the timestamp and the delivery id so a stale or replayed
|
|
30
42
|
* delivery is rejected without trusting any unsigned header. The legacy
|
|
31
43
|
* `X-Bastion-Signature: sha256=<hex>` over the body alone is accepted only
|
|
32
|
-
* when
|
|
44
|
+
* when both of the above are absent **and** `allowLegacySignature` is set. Any of the caller's
|
|
45
|
+
* `secrets` matching any `v1` entry accepts:
|
|
33
46
|
* that is how a secret rotation stays zero-downtime on both sides.
|
|
34
47
|
*/
|
|
35
48
|
function verifyWebhookSignature(options) {
|
|
36
49
|
const body = typeof options.rawBody === 'string' ? Buffer.from(options.rawBody, 'utf8') : options.rawBody;
|
|
37
|
-
const secrets = (Array.isArray(options.secrets) ? options.secrets : [options.secrets]).filter(
|
|
50
|
+
const secrets = (Array.isArray(options.secrets) ? options.secrets : [options.secrets]).filter(
|
|
51
|
+
// A whitespace-only secret is a misconfiguration, never a key; the rest are used byte-exact.
|
|
52
|
+
(s) => typeof s === 'string' && s.trim() !== '');
|
|
38
53
|
if (secrets.length === 0)
|
|
39
54
|
return { ok: false, reason: 'missing' };
|
|
55
|
+
const stdId = header(options.headers, exports.WEBHOOK_ID_HEADER);
|
|
56
|
+
const stdTs = header(options.headers, exports.WEBHOOK_TIMESTAMP_HEADER);
|
|
57
|
+
const stdSig = header(options.headers, exports.WEBHOOK_SIGNATURE_HEADER);
|
|
58
|
+
if (stdId || stdTs || stdSig) {
|
|
59
|
+
const macs = (stdSig ?? '')
|
|
60
|
+
.split(' ')
|
|
61
|
+
.map((entry) => entry.split(','))
|
|
62
|
+
.filter(([version, value]) => version === 'v1' && value)
|
|
63
|
+
.map(([, value]) => Buffer.from(value, 'base64'));
|
|
64
|
+
if (!stdId || !stdTs || !/^\d+$/.test(stdTs) || macs.length === 0)
|
|
65
|
+
return { ok: false, reason: 'malformed' };
|
|
66
|
+
const timestamp = Number(stdTs);
|
|
67
|
+
if (!isFresh(timestamp, options))
|
|
68
|
+
return { ok: false, reason: 'stale' };
|
|
69
|
+
const signed = Buffer.concat([Buffer.from(`${stdId}.${stdTs}.`, 'utf8'), body]);
|
|
70
|
+
for (const secret of secrets) {
|
|
71
|
+
const expected = (0, node_crypto_1.createHmac)('sha256', secret).update(signed).digest();
|
|
72
|
+
if (macs.some((mac) => mac.length === expected.length && (0, node_crypto_1.timingSafeEqual)(mac, expected))) {
|
|
73
|
+
return { ok: true, version: 'standard', timestamp, deliveryId: stdId };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { ok: false, reason: 'mismatch' };
|
|
77
|
+
}
|
|
40
78
|
const v2 = header(options.headers, exports.BASTION_SIGNATURE_V2_HEADER);
|
|
41
79
|
if (v2) {
|
|
42
80
|
const parts = v2.split(',').map((p) => p.trim());
|
|
@@ -46,8 +84,7 @@ function verifyWebhookSignature(options) {
|
|
|
46
84
|
const timestamp = Number(t);
|
|
47
85
|
if (!t || !id || macs.length === 0 || !Number.isFinite(timestamp))
|
|
48
86
|
return { ok: false, reason: 'malformed' };
|
|
49
|
-
|
|
50
|
-
if (Math.abs(now - timestamp) > (options.toleranceSeconds ?? 300))
|
|
87
|
+
if (!isFresh(timestamp, options))
|
|
51
88
|
return { ok: false, reason: 'stale' };
|
|
52
89
|
const signed = Buffer.concat([Buffer.from(`${timestamp}.${id}.`, 'utf8'), body]);
|
|
53
90
|
for (const secret of secrets) {
|
|
@@ -222,14 +222,20 @@ export class BastionApiClient {
|
|
|
222
222
|
}
|
|
223
223
|
// ── GDPR ─────────────────────────────────────────────────────────────────
|
|
224
224
|
/** The user's own audit trail (`GET /auth/me/events`), paginated. */
|
|
225
|
-
listMyEvents(accessToken, page = {}) {
|
|
225
|
+
async listMyEvents(accessToken, page = {}) {
|
|
226
226
|
const params = new URLSearchParams();
|
|
227
227
|
if (page.page !== undefined)
|
|
228
228
|
params.set('page', String(page.page));
|
|
229
229
|
if (page.limit !== undefined)
|
|
230
230
|
params.set('limit', String(page.limit));
|
|
231
231
|
const qs = params.toString();
|
|
232
|
-
|
|
232
|
+
const res = await this.call('GET', `/auth/me/events${qs ? `?${qs}` : ''}`, undefined, { token: accessToken });
|
|
233
|
+
// No body (a proxy in between): nothing to normalise, same as every other method.
|
|
234
|
+
if (!res || typeof res !== 'object' || 'meta' in res)
|
|
235
|
+
return res;
|
|
236
|
+
// Bastion before 2.0: the page fields sit next to `data`.
|
|
237
|
+
const { data, total, page: current, limit } = res;
|
|
238
|
+
return { data, meta: { total, page: current, limit, totalPages: limit > 0 ? Math.ceil(total / limit) : 0 } };
|
|
233
239
|
}
|
|
234
240
|
exportData(accessToken) {
|
|
235
241
|
return this.call('GET', '/auth/me/export', undefined, { token: accessToken });
|
|
@@ -136,9 +136,13 @@ export interface BastionUserAuditEvent {
|
|
|
136
136
|
metadata: unknown;
|
|
137
137
|
createdAt: string;
|
|
138
138
|
}
|
|
139
|
-
export interface
|
|
140
|
-
data: BastionUserAuditEvent[];
|
|
139
|
+
export interface BastionPageMeta {
|
|
141
140
|
total: number;
|
|
142
141
|
page: number;
|
|
143
142
|
limit: number;
|
|
143
|
+
totalPages: number;
|
|
144
|
+
}
|
|
145
|
+
export interface BastionUserAuditEventsPage {
|
|
146
|
+
data: BastionUserAuditEvent[];
|
|
147
|
+
meta: BastionPageMeta;
|
|
144
148
|
}
|
|
@@ -11,6 +11,12 @@ export interface BastionHttpOptions {
|
|
|
11
11
|
*/
|
|
12
12
|
requestId?: () => string | undefined;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Bastion answers `{ data }` (or `{ data, meta }` for a page) from 2.0; earlier builds answer the
|
|
16
|
+
* bare body. An object whose only key is `data` gives back `data`; a page, an array, a bare body
|
|
17
|
+
* or no body passes through unchanged, so every caller reads the same shape from both.
|
|
18
|
+
*/
|
|
19
|
+
export declare function unwrapData(body: unknown): unknown;
|
|
14
20
|
export interface BastionRequestInit {
|
|
15
21
|
token?: string;
|
|
16
22
|
headers?: Record<string, string>;
|
|
@@ -24,9 +30,10 @@ export interface ClientAuthInput {
|
|
|
24
30
|
/**
|
|
25
31
|
* Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
|
|
26
32
|
* `JwksVerifier`. `call` is public so a service can reach any route with the
|
|
27
|
-
* same error handling
|
|
28
|
-
* status and a stable `code`;
|
|
29
|
-
* `UNAVAILABLE`. Pass the browser's
|
|
33
|
+
* same error handling and the same success shape (`unwrapData`): a non-2xx
|
|
34
|
+
* answer becomes `BastionHttpError` with the status and a stable `code`;
|
|
35
|
+
* Bastion unreachable or timed out is a 503 `UNAVAILABLE`. Pass the browser's
|
|
36
|
+
* IP as `X-Real-IP` on user-facing calls.
|
|
30
37
|
*/
|
|
31
38
|
export declare class BastionHttp {
|
|
32
39
|
private readonly baseUrl;
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { BastionErrorCode, BastionHttpError, isBastionErrorCode } from './errors.js';
|
|
2
2
|
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
3
|
+
/**
|
|
4
|
+
* Bastion answers `{ data }` (or `{ data, meta }` for a page) from 2.0; earlier builds answer the
|
|
5
|
+
* bare body. An object whose only key is `data` gives back `data`; a page, an array, a bare body
|
|
6
|
+
* or no body passes through unchanged, so every caller reads the same shape from both.
|
|
7
|
+
*/
|
|
8
|
+
export function unwrapData(body) {
|
|
9
|
+
if (body && typeof body === 'object' && !Array.isArray(body)) {
|
|
10
|
+
const keys = Object.keys(body);
|
|
11
|
+
if (keys.length === 1 && keys[0] === 'data')
|
|
12
|
+
return body.data;
|
|
13
|
+
}
|
|
14
|
+
return body;
|
|
15
|
+
}
|
|
3
16
|
/**
|
|
4
17
|
* Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
|
|
5
18
|
* `JwksVerifier`. `call` is public so a service can reach any route with the
|
|
6
|
-
* same error handling
|
|
7
|
-
* status and a stable `code`;
|
|
8
|
-
* `UNAVAILABLE`. Pass the browser's
|
|
19
|
+
* same error handling and the same success shape (`unwrapData`): a non-2xx
|
|
20
|
+
* answer becomes `BastionHttpError` with the status and a stable `code`;
|
|
21
|
+
* Bastion unreachable or timed out is a 503 `UNAVAILABLE`. Pass the browser's
|
|
22
|
+
* IP as `X-Real-IP` on user-facing calls.
|
|
9
23
|
*/
|
|
10
24
|
export class BastionHttp {
|
|
11
25
|
baseUrl;
|
|
@@ -54,7 +68,9 @@ export class BastionHttp {
|
|
|
54
68
|
: typeof parsed.message === 'string'
|
|
55
69
|
? parsed.message
|
|
56
70
|
: `Bastion error ${res.status}`;
|
|
57
|
-
// A code
|
|
71
|
+
// A code in `message` wins: an older Bastion sends it under a generic `code` that is also one
|
|
72
|
+
// of ours (ALREADY_LINKED under `code: CONFLICT`); classify reads it. Otherwise Bastion's
|
|
73
|
+
// `code` wins when it is one of ours, so a cleaned-up `message` still carries its code.
|
|
58
74
|
const code = isBastionErrorCode(message)
|
|
59
75
|
? undefined
|
|
60
76
|
: isBastionErrorCode(parsed.code)
|
|
@@ -67,7 +83,7 @@ export class BastionHttp {
|
|
|
67
83
|
}
|
|
68
84
|
if (res.status === 204)
|
|
69
85
|
return undefined;
|
|
70
|
-
return (await res.json().catch(() => undefined));
|
|
86
|
+
return unwrapData(await res.json().catch(() => undefined));
|
|
71
87
|
}
|
|
72
88
|
clientAuth(input) {
|
|
73
89
|
return this.call('POST', '/auth/client', input);
|
|
@@ -6,9 +6,9 @@ export declare class BastionTokenError extends Error {
|
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
8
|
* Stable classification of a Bastion error answer, so an app can branch on a
|
|
9
|
-
* code instead of on Bastion's message text.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* code instead of on Bastion's message text. Bastion's own `code` is used when
|
|
10
|
+
* it is one of these; for an older Bastion that sends none, the code is derived
|
|
11
|
+
* from status + message (`classifyBastionError`).
|
|
12
12
|
*/
|
|
13
13
|
export declare const BastionErrorCode: {
|
|
14
14
|
readonly EMAIL_NOT_VERIFIED: "EMAIL_NOT_VERIFIED";
|
|
@@ -23,6 +23,8 @@ export declare const BastionErrorCode: {
|
|
|
23
23
|
readonly INVALID_OAUTH_CODE: "INVALID_OAUTH_CODE";
|
|
24
24
|
/** A 400 carrying field errors (`BastionHttpError.errors`): Bastion's `ValidationPipe`. */
|
|
25
25
|
readonly VALIDATION_FAILED: "VALIDATION_FAILED";
|
|
26
|
+
/** A 401 from a step-up protected route (password change, email change, 2FA setup/confirm/disable, account delete): the current password or 2FA code is wrong, or the step-up counter is locked. The session is fine — do not log the user out. Bastion sends it as `code`; an older Bastion sends a plain 401, classified `INVALID_CREDENTIALS`. */
|
|
27
|
+
readonly STEP_UP_FAILED: "STEP_UP_FAILED";
|
|
26
28
|
readonly CONFLICT: "CONFLICT";
|
|
27
29
|
readonly RATE_LIMITED: "RATE_LIMITED";
|
|
28
30
|
readonly BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED";
|
|
@@ -34,6 +36,20 @@ export declare const BastionErrorCode: {
|
|
|
34
36
|
readonly OAUTH_NO_ACCOUNT: "OAUTH_NO_ACCOUNT";
|
|
35
37
|
/** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
|
|
36
38
|
readonly OAUTH_LINK_USER_MISMATCH: "OAUTH_LINK_USER_MISMATCH";
|
|
39
|
+
/** OAuth signup with an email that a verified account of the tenant already owns. */
|
|
40
|
+
readonly OAUTH_EMAIL_TAKEN: "OAUTH_EMAIL_TAKEN";
|
|
41
|
+
readonly OAUTH_USER_INACTIVE: "OAUTH_USER_INACTIVE";
|
|
42
|
+
readonly OAUTH_UNDER_MINIMUM_AGE: "OAUTH_UNDER_MINIMUM_AGE";
|
|
43
|
+
/** Linking a provider account that belongs to another user. */
|
|
44
|
+
readonly PROVIDER_TAKEN: "PROVIDER_TAKEN";
|
|
45
|
+
/** The user already has an account of this provider linked. */
|
|
46
|
+
readonly ALREADY_LINKED: "ALREADY_LINKED";
|
|
47
|
+
readonly AGE_ALREADY_VERIFIED: "AGE_ALREADY_VERIFIED";
|
|
48
|
+
readonly AGE_GATE_NOT_CONFIGURED: "AGE_GATE_NOT_CONFIGURED";
|
|
49
|
+
/** A step-up route was called without `currentPassword`. Ask for it; the session is fine. */
|
|
50
|
+
readonly CURRENT_PASSWORD_REQUIRED: "CURRENT_PASSWORD_REQUIRED";
|
|
51
|
+
/** A step-up route was called without `twoFactorCode`. Ask for it; the session is fine. */
|
|
52
|
+
readonly TWO_FACTOR_CODE_REQUIRED: "TWO_FACTOR_CODE_REQUIRED";
|
|
37
53
|
readonly UNAVAILABLE: "UNAVAILABLE";
|
|
38
54
|
readonly UPSTREAM: "UPSTREAM";
|
|
39
55
|
};
|
package/dist/esm/core/errors.js
CHANGED
|
@@ -9,9 +9,9 @@ export class BastionTokenError extends Error {
|
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
11
|
* Stable classification of a Bastion error answer, so an app can branch on a
|
|
12
|
-
* code instead of on Bastion's message text.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* code instead of on Bastion's message text. Bastion's own `code` is used when
|
|
13
|
+
* it is one of these; for an older Bastion that sends none, the code is derived
|
|
14
|
+
* from status + message (`classifyBastionError`).
|
|
15
15
|
*/
|
|
16
16
|
export const BastionErrorCode = {
|
|
17
17
|
EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED',
|
|
@@ -26,6 +26,8 @@ export const BastionErrorCode = {
|
|
|
26
26
|
INVALID_OAUTH_CODE: 'INVALID_OAUTH_CODE',
|
|
27
27
|
/** A 400 carrying field errors (`BastionHttpError.errors`): Bastion's `ValidationPipe`. */
|
|
28
28
|
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
|
29
|
+
/** A 401 from a step-up protected route (password change, email change, 2FA setup/confirm/disable, account delete): the current password or 2FA code is wrong, or the step-up counter is locked. The session is fine — do not log the user out. Bastion sends it as `code`; an older Bastion sends a plain 401, classified `INVALID_CREDENTIALS`. */
|
|
30
|
+
STEP_UP_FAILED: 'STEP_UP_FAILED',
|
|
29
31
|
CONFLICT: 'CONFLICT',
|
|
30
32
|
RATE_LIMITED: 'RATE_LIMITED',
|
|
31
33
|
BIRTH_DATE_REQUIRED: 'BIRTH_DATE_REQUIRED',
|
|
@@ -37,6 +39,20 @@ export const BastionErrorCode = {
|
|
|
37
39
|
OAUTH_NO_ACCOUNT: 'OAUTH_NO_ACCOUNT',
|
|
38
40
|
/** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
|
|
39
41
|
OAUTH_LINK_USER_MISMATCH: 'OAUTH_LINK_USER_MISMATCH',
|
|
42
|
+
/** OAuth signup with an email that a verified account of the tenant already owns. */
|
|
43
|
+
OAUTH_EMAIL_TAKEN: 'OAUTH_EMAIL_TAKEN',
|
|
44
|
+
OAUTH_USER_INACTIVE: 'OAUTH_USER_INACTIVE',
|
|
45
|
+
OAUTH_UNDER_MINIMUM_AGE: 'OAUTH_UNDER_MINIMUM_AGE',
|
|
46
|
+
/** Linking a provider account that belongs to another user. */
|
|
47
|
+
PROVIDER_TAKEN: 'PROVIDER_TAKEN',
|
|
48
|
+
/** The user already has an account of this provider linked. */
|
|
49
|
+
ALREADY_LINKED: 'ALREADY_LINKED',
|
|
50
|
+
AGE_ALREADY_VERIFIED: 'AGE_ALREADY_VERIFIED',
|
|
51
|
+
AGE_GATE_NOT_CONFIGURED: 'AGE_GATE_NOT_CONFIGURED',
|
|
52
|
+
/** A step-up route was called without `currentPassword`. Ask for it; the session is fine. */
|
|
53
|
+
CURRENT_PASSWORD_REQUIRED: 'CURRENT_PASSWORD_REQUIRED',
|
|
54
|
+
/** A step-up route was called without `twoFactorCode`. Ask for it; the session is fine. */
|
|
55
|
+
TWO_FACTOR_CODE_REQUIRED: 'TWO_FACTOR_CODE_REQUIRED',
|
|
40
56
|
UNAVAILABLE: 'UNAVAILABLE',
|
|
41
57
|
UPSTREAM: 'UPSTREAM',
|
|
42
58
|
};
|
|
@@ -50,6 +66,15 @@ const CODE_MESSAGES = new Set([
|
|
|
50
66
|
BastionErrorCode.OAUTH_EMAIL_REQUIRED,
|
|
51
67
|
BastionErrorCode.OAUTH_NO_ACCOUNT,
|
|
52
68
|
BastionErrorCode.OAUTH_LINK_USER_MISMATCH,
|
|
69
|
+
BastionErrorCode.OAUTH_EMAIL_TAKEN,
|
|
70
|
+
BastionErrorCode.OAUTH_USER_INACTIVE,
|
|
71
|
+
BastionErrorCode.OAUTH_UNDER_MINIMUM_AGE,
|
|
72
|
+
BastionErrorCode.PROVIDER_TAKEN,
|
|
73
|
+
BastionErrorCode.ALREADY_LINKED,
|
|
74
|
+
BastionErrorCode.AGE_ALREADY_VERIFIED,
|
|
75
|
+
BastionErrorCode.AGE_GATE_NOT_CONFIGURED,
|
|
76
|
+
BastionErrorCode.CURRENT_PASSWORD_REQUIRED,
|
|
77
|
+
BastionErrorCode.TWO_FACTOR_CODE_REQUIRED,
|
|
53
78
|
]);
|
|
54
79
|
const APP_CONTEXT_RE = /app not found|tenant not found|tenant is inactive|no tenants configured|tenantslug is required/i;
|
|
55
80
|
const OAUTH_CODE_RE = /already used code|code expired|code\/app mismatch/i;
|
|
@@ -3,6 +3,10 @@ export declare const BASTION_SIGNATURE_HEADER = "x-bastion-signature";
|
|
|
3
3
|
export declare const BASTION_SIGNATURE_V2_HEADER = "x-bastion-signature-v2";
|
|
4
4
|
export declare const BASTION_EVENT_HEADER = "x-bastion-event";
|
|
5
5
|
export declare const BASTION_DELIVERY_ID_HEADER = "x-bastion-delivery-id";
|
|
6
|
+
/** Standard Webhooks (standardwebhooks.com) headers, sent alongside v2 during the migration. */
|
|
7
|
+
export declare const WEBHOOK_ID_HEADER = "webhook-id";
|
|
8
|
+
export declare const WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
|
|
9
|
+
export declare const WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
|
|
6
10
|
/** Sent to every app with a webhook URL, whether or not the user has a role in it. */
|
|
7
11
|
export declare const BASTION_LIFECYCLE_EVENTS: readonly ["user.deleted", "user.expired_unverified", "user.deactivated"];
|
|
8
12
|
export interface BastionWebhookPayload {
|
|
@@ -33,12 +37,17 @@ export interface VerifyWebhookOptions {
|
|
|
33
37
|
* left to migrate: leave it off.
|
|
34
38
|
*/
|
|
35
39
|
allowLegacySignature?: boolean;
|
|
36
|
-
/** Max
|
|
40
|
+
/** Max distance of the signed timestamp from now (Standard Webhooks and v2), default 300 s. */
|
|
37
41
|
toleranceSeconds?: number;
|
|
38
42
|
/** For tests. */
|
|
39
43
|
now?: () => number;
|
|
40
44
|
}
|
|
41
45
|
export type VerifyWebhookResult = {
|
|
46
|
+
ok: true;
|
|
47
|
+
version: 'standard';
|
|
48
|
+
timestamp: number;
|
|
49
|
+
deliveryId: string;
|
|
50
|
+
} | {
|
|
42
51
|
ok: true;
|
|
43
52
|
version: 'v2';
|
|
44
53
|
timestamp: number;
|
|
@@ -51,12 +60,17 @@ export type VerifyWebhookResult = {
|
|
|
51
60
|
reason: 'missing' | 'malformed' | 'stale' | 'mismatch';
|
|
52
61
|
};
|
|
53
62
|
/**
|
|
54
|
-
* Verifies a Bastion delivery. Prefers `
|
|
63
|
+
* Verifies a Bastion delivery. Prefers Standard Webhooks when any of `webhook-id`,
|
|
64
|
+
* `webhook-timestamp`, `webhook-signature` is present: `webhook-signature` is a
|
|
65
|
+
* space-separated list of `v1,<base64 HMAC-SHA256(secret, "<id>.<timestamp>.<body>")>`,
|
|
66
|
+
* keyed with the secret as UTF-8 (not a `whsec_` base64 key); `deliveryId` is `webhook-id`.
|
|
67
|
+
* Otherwise falls back to `X-Bastion-Signature-V2`
|
|
55
68
|
* (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
|
|
56
69
|
* which binds the timestamp and the delivery id so a stale or replayed
|
|
57
70
|
* delivery is rejected without trusting any unsigned header. The legacy
|
|
58
71
|
* `X-Bastion-Signature: sha256=<hex>` over the body alone is accepted only
|
|
59
|
-
* when
|
|
72
|
+
* when both of the above are absent **and** `allowLegacySignature` is set. Any of the caller's
|
|
73
|
+
* `secrets` matching any `v1` entry accepts:
|
|
60
74
|
* that is how a secret rotation stays zero-downtime on both sides.
|
|
61
75
|
*/
|
|
62
76
|
export declare function verifyWebhookSignature(options: VerifyWebhookOptions): VerifyWebhookResult;
|
|
@@ -4,14 +4,18 @@ export const BASTION_SIGNATURE_HEADER = 'x-bastion-signature';
|
|
|
4
4
|
export const BASTION_SIGNATURE_V2_HEADER = 'x-bastion-signature-v2';
|
|
5
5
|
export const BASTION_EVENT_HEADER = 'x-bastion-event';
|
|
6
6
|
export const BASTION_DELIVERY_ID_HEADER = 'x-bastion-delivery-id';
|
|
7
|
+
/** Standard Webhooks (standardwebhooks.com) headers, sent alongside v2 during the migration. */
|
|
8
|
+
export const WEBHOOK_ID_HEADER = 'webhook-id';
|
|
9
|
+
export const WEBHOOK_TIMESTAMP_HEADER = 'webhook-timestamp';
|
|
10
|
+
export const WEBHOOK_SIGNATURE_HEADER = 'webhook-signature';
|
|
7
11
|
/** Sent to every app with a webhook URL, whether or not the user has a role in it. */
|
|
8
12
|
export const BASTION_LIFECYCLE_EVENTS = ['user.deleted', 'user.expired_unverified', 'user.deactivated'];
|
|
9
13
|
function header(headers, name) {
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
let v = headers[name];
|
|
15
|
+
if (v === undefined) {
|
|
16
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === name);
|
|
17
|
+
v = key === undefined ? undefined : headers[key];
|
|
18
|
+
}
|
|
15
19
|
return Array.isArray(v) ? v[0] : v;
|
|
16
20
|
}
|
|
17
21
|
function macEquals(hexA, hexB) {
|
|
@@ -19,20 +23,54 @@ function macEquals(hexA, hexB) {
|
|
|
19
23
|
const b = Buffer.from(hexB, 'hex');
|
|
20
24
|
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
|
|
21
25
|
}
|
|
26
|
+
function isFresh(timestamp, options) {
|
|
27
|
+
const now = Math.floor((options.now ?? Date.now)() / 1000);
|
|
28
|
+
return Math.abs(now - timestamp) <= (options.toleranceSeconds ?? 300);
|
|
29
|
+
}
|
|
22
30
|
/**
|
|
23
|
-
* Verifies a Bastion delivery. Prefers `
|
|
31
|
+
* Verifies a Bastion delivery. Prefers Standard Webhooks when any of `webhook-id`,
|
|
32
|
+
* `webhook-timestamp`, `webhook-signature` is present: `webhook-signature` is a
|
|
33
|
+
* space-separated list of `v1,<base64 HMAC-SHA256(secret, "<id>.<timestamp>.<body>")>`,
|
|
34
|
+
* keyed with the secret as UTF-8 (not a `whsec_` base64 key); `deliveryId` is `webhook-id`.
|
|
35
|
+
* Otherwise falls back to `X-Bastion-Signature-V2`
|
|
24
36
|
* (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
|
|
25
37
|
* which binds the timestamp and the delivery id so a stale or replayed
|
|
26
38
|
* delivery is rejected without trusting any unsigned header. The legacy
|
|
27
39
|
* `X-Bastion-Signature: sha256=<hex>` over the body alone is accepted only
|
|
28
|
-
* when
|
|
40
|
+
* when both of the above are absent **and** `allowLegacySignature` is set. Any of the caller's
|
|
41
|
+
* `secrets` matching any `v1` entry accepts:
|
|
29
42
|
* that is how a secret rotation stays zero-downtime on both sides.
|
|
30
43
|
*/
|
|
31
44
|
export function verifyWebhookSignature(options) {
|
|
32
45
|
const body = typeof options.rawBody === 'string' ? Buffer.from(options.rawBody, 'utf8') : options.rawBody;
|
|
33
|
-
const secrets = (Array.isArray(options.secrets) ? options.secrets : [options.secrets]).filter(
|
|
46
|
+
const secrets = (Array.isArray(options.secrets) ? options.secrets : [options.secrets]).filter(
|
|
47
|
+
// A whitespace-only secret is a misconfiguration, never a key; the rest are used byte-exact.
|
|
48
|
+
(s) => typeof s === 'string' && s.trim() !== '');
|
|
34
49
|
if (secrets.length === 0)
|
|
35
50
|
return { ok: false, reason: 'missing' };
|
|
51
|
+
const stdId = header(options.headers, WEBHOOK_ID_HEADER);
|
|
52
|
+
const stdTs = header(options.headers, WEBHOOK_TIMESTAMP_HEADER);
|
|
53
|
+
const stdSig = header(options.headers, WEBHOOK_SIGNATURE_HEADER);
|
|
54
|
+
if (stdId || stdTs || stdSig) {
|
|
55
|
+
const macs = (stdSig ?? '')
|
|
56
|
+
.split(' ')
|
|
57
|
+
.map((entry) => entry.split(','))
|
|
58
|
+
.filter(([version, value]) => version === 'v1' && value)
|
|
59
|
+
.map(([, value]) => Buffer.from(value, 'base64'));
|
|
60
|
+
if (!stdId || !stdTs || !/^\d+$/.test(stdTs) || macs.length === 0)
|
|
61
|
+
return { ok: false, reason: 'malformed' };
|
|
62
|
+
const timestamp = Number(stdTs);
|
|
63
|
+
if (!isFresh(timestamp, options))
|
|
64
|
+
return { ok: false, reason: 'stale' };
|
|
65
|
+
const signed = Buffer.concat([Buffer.from(`${stdId}.${stdTs}.`, 'utf8'), body]);
|
|
66
|
+
for (const secret of secrets) {
|
|
67
|
+
const expected = createHmac('sha256', secret).update(signed).digest();
|
|
68
|
+
if (macs.some((mac) => mac.length === expected.length && timingSafeEqual(mac, expected))) {
|
|
69
|
+
return { ok: true, version: 'standard', timestamp, deliveryId: stdId };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { ok: false, reason: 'mismatch' };
|
|
73
|
+
}
|
|
36
74
|
const v2 = header(options.headers, BASTION_SIGNATURE_V2_HEADER);
|
|
37
75
|
if (v2) {
|
|
38
76
|
const parts = v2.split(',').map((p) => p.trim());
|
|
@@ -42,8 +80,7 @@ export function verifyWebhookSignature(options) {
|
|
|
42
80
|
const timestamp = Number(t);
|
|
43
81
|
if (!t || !id || macs.length === 0 || !Number.isFinite(timestamp))
|
|
44
82
|
return { ok: false, reason: 'malformed' };
|
|
45
|
-
|
|
46
|
-
if (Math.abs(now - timestamp) > (options.toleranceSeconds ?? 300))
|
|
83
|
+
if (!isFresh(timestamp, options))
|
|
47
84
|
return { ok: false, reason: 'stale' };
|
|
48
85
|
const signed = Buffer.concat([Buffer.from(`${timestamp}.${id}.`, 'utf8'), body]);
|
|
49
86
|
for (const secret of secrets) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyatom/bastion-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.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)",
|