@heyatom/bastion-client 0.9.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 +15 -0
- package/README.md +21 -3
- 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 +17 -3
- package/dist/cjs/core/errors.js +26 -3
- 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 +17 -3
- package/dist/esm/core/errors.js +26 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
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
|
+
|
|
3
18
|
## 0.9.0
|
|
4
19
|
|
|
5
20
|
### Added
|
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
|
|
@@ -144,9 +160,11 @@ Every failed call throws `BastionHttpError` (core) or `BastionHttpException` (Ne
|
|
|
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
162
|
`INVALID_OAUTH_CODE`, `VALIDATION_FAILED`, `STEP_UP_FAILED` (wrong current password or 2FA
|
|
147
|
-
code on a step-up route: the session is fine), `
|
|
148
|
-
|
|
149
|
-
|
|
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`.
|
|
150
168
|
|
|
151
169
|
- A 400 carries the field errors in `errors: string[]`. Show them: `message` is only
|
|
152
170
|
"Validation failed".
|
|
@@ -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";
|
|
@@ -36,6 +36,20 @@ export declare const BastionErrorCode: {
|
|
|
36
36
|
readonly OAUTH_NO_ACCOUNT: "OAUTH_NO_ACCOUNT";
|
|
37
37
|
/** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
|
|
38
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";
|
|
39
53
|
readonly UNAVAILABLE: "UNAVAILABLE";
|
|
40
54
|
readonly UPSTREAM: "UPSTREAM";
|
|
41
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',
|
|
@@ -45,6 +45,20 @@ exports.BastionErrorCode = {
|
|
|
45
45
|
OAUTH_NO_ACCOUNT: 'OAUTH_NO_ACCOUNT',
|
|
46
46
|
/** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
|
|
47
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',
|
|
48
62
|
UNAVAILABLE: 'UNAVAILABLE',
|
|
49
63
|
UPSTREAM: 'UPSTREAM',
|
|
50
64
|
};
|
|
@@ -58,6 +72,15 @@ const CODE_MESSAGES = new Set([
|
|
|
58
72
|
exports.BastionErrorCode.OAUTH_EMAIL_REQUIRED,
|
|
59
73
|
exports.BastionErrorCode.OAUTH_NO_ACCOUNT,
|
|
60
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,
|
|
61
84
|
]);
|
|
62
85
|
const APP_CONTEXT_RE = /app not found|tenant not found|tenant is inactive|no tenants configured|tenantslug is required/i;
|
|
63
86
|
const OAUTH_CODE_RE = /already used code|code expired|code\/app mismatch/i;
|
|
@@ -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";
|
|
@@ -36,6 +36,20 @@ export declare const BastionErrorCode: {
|
|
|
36
36
|
readonly OAUTH_NO_ACCOUNT: "OAUTH_NO_ACCOUNT";
|
|
37
37
|
/** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
|
|
38
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";
|
|
39
53
|
readonly UNAVAILABLE: "UNAVAILABLE";
|
|
40
54
|
readonly UPSTREAM: "UPSTREAM";
|
|
41
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',
|
|
@@ -39,6 +39,20 @@ export const BastionErrorCode = {
|
|
|
39
39
|
OAUTH_NO_ACCOUNT: 'OAUTH_NO_ACCOUNT',
|
|
40
40
|
/** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
|
|
41
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',
|
|
42
56
|
UNAVAILABLE: 'UNAVAILABLE',
|
|
43
57
|
UPSTREAM: 'UPSTREAM',
|
|
44
58
|
};
|
|
@@ -52,6 +66,15 @@ const CODE_MESSAGES = new Set([
|
|
|
52
66
|
BastionErrorCode.OAUTH_EMAIL_REQUIRED,
|
|
53
67
|
BastionErrorCode.OAUTH_NO_ACCOUNT,
|
|
54
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,
|
|
55
78
|
]);
|
|
56
79
|
const APP_CONTEXT_RE = /app not found|tenant not found|tenant is inactive|no tenants configured|tenantslug is required/i;
|
|
57
80
|
const OAUTH_CODE_RE = /already used code|code expired|code\/app mismatch/i;
|
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)",
|