@heyatom/bastion-client 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cjs/core/api-client.d.ts +24 -8
- package/dist/cjs/core/api-client.js +29 -16
- package/dist/cjs/core/api-types.d.ts +4 -0
- package/dist/cjs/core/errors.d.ts +12 -0
- package/dist/cjs/core/errors.js +34 -4
- package/dist/esm/core/api-client.d.ts +24 -8
- package/dist/esm/core/api-client.js +29 -16
- package/dist/esm/core/api-types.d.ts +4 -0
- package/dist/esm/core/errors.d.ts +12 -0
- package/dist/esm/core/errors.js +34 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Consumer SDK for [Bastion](https://github.com/heyatomdev/bastion), the HeyAtom i
|
|
|
4
4
|
Replaces the `src/modules/bastion/` boilerplate every service used to copy by hand.
|
|
5
5
|
|
|
6
6
|
- `@heyatom/bastion-client` — framework-free core: `JwksVerifier` (kid-indexed cache, unknown-kid refetch with cooldown, stale-on-error), `BastionHttp` (`POST /auth/client`, `POST /events`, any other route), `ServiceTokenProvider` (single-flight refresh ahead of `exp`), payload types.
|
|
7
|
-
- `@heyatom/bastion-client` also carries Bastion's user-API contract: `BastionApiClient` (login, register, OAuth exchange, 2FA, single-flight refresh, logout, sessions, `/me`, profile, social accounts, email/password flows, export, delete), its types (`BastionMe`, `BastionTokenPair`, …) and `BastionErrorCode` (a stable code on every `BastionHttpError`).
|
|
7
|
+
- `@heyatom/bastion-client` also carries Bastion's user-API contract: `BastionApiClient` (login, register, OAuth exchange, 2FA, single-flight refresh, logout, sessions, `/me`, profile, social accounts, email/password flows, export, delete), its types (`BastionMe`, `BastionTokenPair`, …) and `BastionErrorCode` (a stable code on every `BastionHttpError`: credentials, app context, refresh token, OAuth code, age gate, OAuth flow, unavailable, upstream). `refreshMemoryMs` tunes how long a refresh answer is remembered per token (default 60 s).
|
|
8
8
|
- `@heyatom/bastion-client/webhooks` — `verifyWebhookSignature` (`X-Bastion-Signature-V2` with timestamp + delivery id, legacy fallback, secret rotation on both sides), header constants, payload type.
|
|
9
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
|
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import type { BastionHttp } from './bastion-http.js';
|
|
2
2
|
import { type BastionLogger } from './types.js';
|
|
3
|
-
import type { BastionAgeVerificationResult, BastionAuthMethods, BastionClientContext, BastionEmailChangeRequest, BastionLinkTicket, BastionLoginResult, BastionMe, BastionProfile, BastionProfileUpdate, BastionRegisterInput, BastionSession, BastionSocialAccount, BastionTokenPair, BastionTwoFactorConfirmed, BastionTwoFactorSetup, BastionTwoFactorStatus, BastionUserAuditEventsPage } from './api-types.js';
|
|
3
|
+
import type { BastionAgeVerificationResult, BastionAuthMethods, BastionClientContext, BastionEmailChangeRequest, BastionLinkCompleted, BastionLinkTicket, BastionLoginResult, BastionMe, BastionProfile, BastionProfileUpdate, BastionRegisterInput, BastionSession, BastionSocialAccount, BastionTokenPair, BastionTwoFactorConfirmed, BastionTwoFactorSetup, BastionTwoFactorStatus, BastionUserAuditEventsPage } from './api-types.js';
|
|
4
4
|
export interface BastionApiClientOptions {
|
|
5
5
|
http: BastionHttp;
|
|
6
6
|
/** This app's slug — sent as `appSlug` on every user-facing call. */
|
|
7
7
|
appSlug: string;
|
|
8
8
|
/** Required by Bastion when the app is registered in more than one tenant, or is `isGlobal`. */
|
|
9
9
|
tenantSlug?: string;
|
|
10
|
+
/**
|
|
11
|
+
* How long a refresh answer is remembered per refresh token, default 60 000 ms.
|
|
12
|
+
* Within the window a second `refresh` with the same (now revoked) token
|
|
13
|
+
* returns the remembered pair instead of tripping Bastion's reuse detection,
|
|
14
|
+
* and `logout` still finds the successor. A BFF whose pages fire several
|
|
15
|
+
* calls before the browser sees the new cookie may want a few minutes.
|
|
16
|
+
*/
|
|
17
|
+
refreshMemoryMs?: number;
|
|
10
18
|
logger?: BastionLogger;
|
|
11
19
|
}
|
|
12
20
|
/**
|
|
@@ -27,6 +35,7 @@ export declare class BastionApiClient {
|
|
|
27
35
|
private readonly http;
|
|
28
36
|
private readonly appSlug;
|
|
29
37
|
private readonly tenantSlug?;
|
|
38
|
+
private readonly refreshMemoryMs;
|
|
30
39
|
private readonly logger;
|
|
31
40
|
private readonly inFlight;
|
|
32
41
|
private readonly recent;
|
|
@@ -45,7 +54,7 @@ export declare class BastionApiClient {
|
|
|
45
54
|
exchange(code: string, ctx?: BastionClientContext): Promise<BastionLoginResult>;
|
|
46
55
|
completeTwoFactor(twoFactorToken: string, code: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
|
|
47
56
|
refresh(refreshToken: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
|
|
48
|
-
/** Revokes the token and any successor issued through `refresh`
|
|
57
|
+
/** Revokes the token and any successor issued through `refresh` within the memory window. Never throws. */
|
|
49
58
|
logout(refreshToken: string | null | undefined): Promise<void>;
|
|
50
59
|
listSessions(accessToken: string): Promise<BastionSession[]>;
|
|
51
60
|
revokeSession(accessToken: string, sessionId: string): Promise<void>;
|
|
@@ -66,20 +75,27 @@ export declare class BastionApiClient {
|
|
|
66
75
|
}>;
|
|
67
76
|
listSocialAccounts(accessToken: string): Promise<BastionSocialAccount[]>;
|
|
68
77
|
createSocialAccountLinkTicket(accessToken: string, provider: string): Promise<BastionLinkTicket>;
|
|
78
|
+
/**
|
|
79
|
+
* Second step of a link: spends the one-time `linkCode` Bastion put on the
|
|
80
|
+
* redirect to `/auth/callback`. Pass the signed-in user's own token — Bastion
|
|
81
|
+
* links only if its `sub` is the user the ticket was issued to (403
|
|
82
|
+
* `OAUTH_LINK_USER_MISMATCH` otherwise; the code is spent either way).
|
|
83
|
+
*/
|
|
84
|
+
completeSocialAccountLink(accessToken: string, code: string): Promise<BastionLinkCompleted>;
|
|
69
85
|
unlinkSocialAccount(accessToken: string, provider: string): Promise<void>;
|
|
70
86
|
updateSocialAccountSyncAvatar(accessToken: string, provider: string, syncAvatar: boolean): Promise<BastionSocialAccount>;
|
|
71
|
-
resendVerification(email: string): Promise<void>;
|
|
72
|
-
verifyEmail(token: string): Promise<void>;
|
|
73
|
-
forgotPassword(email: string): Promise<void>;
|
|
74
|
-
resetPassword(token: string, newPassword: string): Promise<void>;
|
|
87
|
+
resendVerification(email: string, ctx?: BastionClientContext): Promise<void>;
|
|
88
|
+
verifyEmail(token: string, ctx?: BastionClientContext): Promise<void>;
|
|
89
|
+
forgotPassword(email: string, ctx?: BastionClientContext): Promise<void>;
|
|
90
|
+
resetPassword(token: string, newPassword: string, ctx?: BastionClientContext): Promise<void>;
|
|
75
91
|
changePassword(accessToken: string, currentPassword: string, newPassword: string): Promise<void>;
|
|
76
92
|
requestEmailChange(accessToken: string, input: BastionEmailChangeRequest): Promise<{
|
|
77
93
|
message: string;
|
|
78
94
|
}>;
|
|
79
|
-
confirmEmailChange(token: string): Promise<{
|
|
95
|
+
confirmEmailChange(token: string, ctx?: BastionClientContext): Promise<{
|
|
80
96
|
message: string;
|
|
81
97
|
}>;
|
|
82
|
-
revokeEmailChange(token: string): Promise<{
|
|
98
|
+
revokeEmailChange(token: string, ctx?: BastionClientContext): Promise<{
|
|
83
99
|
message: string;
|
|
84
100
|
}>;
|
|
85
101
|
/** The user's own audit trail (`GET /auth/me/events`), paginated. */
|
|
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.BastionApiClient = void 0;
|
|
4
4
|
const node_crypto_1 = require("node:crypto");
|
|
5
5
|
const types_js_1 = require("./types.js");
|
|
6
|
-
const
|
|
6
|
+
const DEFAULT_REFRESH_MEMORY_MS = 60_000;
|
|
7
7
|
const AUTH_METHODS_CACHE_MS = 5 * 60_000;
|
|
8
8
|
const MAX_ROTATION_HOPS = 32;
|
|
9
9
|
/**
|
|
@@ -24,6 +24,7 @@ class BastionApiClient {
|
|
|
24
24
|
http;
|
|
25
25
|
appSlug;
|
|
26
26
|
tenantSlug;
|
|
27
|
+
refreshMemoryMs;
|
|
27
28
|
logger;
|
|
28
29
|
inFlight = new Map();
|
|
29
30
|
recent = new Map();
|
|
@@ -33,6 +34,7 @@ class BastionApiClient {
|
|
|
33
34
|
this.http = options.http;
|
|
34
35
|
this.appSlug = options.appSlug;
|
|
35
36
|
this.tenantSlug = options.tenantSlug || undefined;
|
|
37
|
+
this.refreshMemoryMs = options.refreshMemoryMs ?? DEFAULT_REFRESH_MEMORY_MS;
|
|
36
38
|
this.logger = options.logger ?? types_js_1.silentLogger;
|
|
37
39
|
}
|
|
38
40
|
get appContext() {
|
|
@@ -86,7 +88,7 @@ class BastionApiClient {
|
|
|
86
88
|
this.inFlight.set(key, pending);
|
|
87
89
|
return pending;
|
|
88
90
|
}
|
|
89
|
-
/** Revokes the token and any successor issued through `refresh`
|
|
91
|
+
/** Revokes the token and any successor issued through `refresh` within the memory window. Never throws. */
|
|
90
92
|
async logout(refreshToken) {
|
|
91
93
|
if (!refreshToken)
|
|
92
94
|
return;
|
|
@@ -154,6 +156,15 @@ class BastionApiClient {
|
|
|
154
156
|
createSocialAccountLinkTicket(accessToken, provider) {
|
|
155
157
|
return this.call('POST', `/auth/me/social-accounts/${encodeURIComponent(provider)}/link-ticket`, undefined, { token: accessToken });
|
|
156
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Second step of a link: spends the one-time `linkCode` Bastion put on the
|
|
161
|
+
* redirect to `/auth/callback`. Pass the signed-in user's own token — Bastion
|
|
162
|
+
* links only if its `sub` is the user the ticket was issued to (403
|
|
163
|
+
* `OAUTH_LINK_USER_MISMATCH` otherwise; the code is spent either way).
|
|
164
|
+
*/
|
|
165
|
+
completeSocialAccountLink(accessToken, code) {
|
|
166
|
+
return this.call('POST', '/auth/me/social-accounts/link/complete', { code }, { token: accessToken });
|
|
167
|
+
}
|
|
157
168
|
unlinkSocialAccount(accessToken, provider) {
|
|
158
169
|
return this.call('DELETE', `/auth/me/social-accounts/${encodeURIComponent(provider)}`, undefined, { token: accessToken });
|
|
159
170
|
}
|
|
@@ -161,17 +172,19 @@ class BastionApiClient {
|
|
|
161
172
|
return this.call('PATCH', `/auth/me/social-accounts/${encodeURIComponent(provider)}`, { syncAvatar }, { token: accessToken });
|
|
162
173
|
}
|
|
163
174
|
// ── email & password ─────────────────────────────────────────────────────
|
|
164
|
-
|
|
165
|
-
|
|
175
|
+
// Unauthenticated routes: Bastion rate-limits them by IP, so pass the
|
|
176
|
+
// browser's context or every user behind this backend shares one bucket.
|
|
177
|
+
resendVerification(email, ctx) {
|
|
178
|
+
return this.call('POST', '/auth/resend-verification', { email, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
166
179
|
}
|
|
167
|
-
verifyEmail(token) {
|
|
168
|
-
return this.call('GET', `/auth/verify-email?token=${encodeURIComponent(token)}
|
|
180
|
+
verifyEmail(token, ctx) {
|
|
181
|
+
return this.call('GET', `/auth/verify-email?token=${encodeURIComponent(token)}`, undefined, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
169
182
|
}
|
|
170
|
-
forgotPassword(email) {
|
|
171
|
-
return this.call('POST', '/auth/forgot-password', { email, ...this.appContext });
|
|
183
|
+
forgotPassword(email, ctx) {
|
|
184
|
+
return this.call('POST', '/auth/forgot-password', { email, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
172
185
|
}
|
|
173
|
-
resetPassword(token, newPassword) {
|
|
174
|
-
return this.call('POST', '/auth/reset-password', { token, newPassword });
|
|
186
|
+
resetPassword(token, newPassword, ctx) {
|
|
187
|
+
return this.call('POST', '/auth/reset-password', { token, newPassword }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
175
188
|
}
|
|
176
189
|
changePassword(accessToken, currentPassword, newPassword) {
|
|
177
190
|
return this.call('PATCH', '/auth/me/password', { currentPassword, newPassword }, { token: accessToken });
|
|
@@ -179,11 +192,11 @@ class BastionApiClient {
|
|
|
179
192
|
requestEmailChange(accessToken, input) {
|
|
180
193
|
return this.call('PATCH', '/auth/me/email', input, { token: accessToken });
|
|
181
194
|
}
|
|
182
|
-
confirmEmailChange(token) {
|
|
183
|
-
return this.call('POST', '/auth/me/confirm-email', { token });
|
|
195
|
+
confirmEmailChange(token, ctx) {
|
|
196
|
+
return this.call('POST', '/auth/me/confirm-email', { token }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
184
197
|
}
|
|
185
|
-
revokeEmailChange(token) {
|
|
186
|
-
return this.call('POST', '/auth/me/revoke-email-change', { token });
|
|
198
|
+
revokeEmailChange(token, ctx) {
|
|
199
|
+
return this.call('POST', '/auth/me/revoke-email-change', { token }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
187
200
|
}
|
|
188
201
|
// ── GDPR ─────────────────────────────────────────────────────────────────
|
|
189
202
|
/** The user's own audit trail (`GET /auth/me/events`), paginated. */
|
|
@@ -216,10 +229,10 @@ class BastionApiClient {
|
|
|
216
229
|
}
|
|
217
230
|
prune(now) {
|
|
218
231
|
for (const [key, entry] of this.recent)
|
|
219
|
-
if (now - entry.at >=
|
|
232
|
+
if (now - entry.at >= this.refreshMemoryMs)
|
|
220
233
|
this.recent.delete(key);
|
|
221
234
|
for (const [key, entry] of this.successors)
|
|
222
|
-
if (now - entry.at >=
|
|
235
|
+
if (now - entry.at >= this.refreshMemoryMs)
|
|
223
236
|
this.successors.delete(key);
|
|
224
237
|
}
|
|
225
238
|
}
|
|
@@ -119,6 +119,10 @@ export interface BastionAuthMethods {
|
|
|
119
119
|
export interface BastionLinkTicket {
|
|
120
120
|
url: string;
|
|
121
121
|
}
|
|
122
|
+
/** `POST /auth/me/social-accounts/link/complete` — the provider now linked (lowercase slug). */
|
|
123
|
+
export interface BastionLinkCompleted {
|
|
124
|
+
provider: string;
|
|
125
|
+
}
|
|
122
126
|
/** One row of `GET /auth/me/events` — the user-facing projection, deliberately without `ip`/`userAgent`. */
|
|
123
127
|
export interface BastionUserAuditEvent {
|
|
124
128
|
id: string;
|
|
@@ -15,10 +15,22 @@ export declare const BastionErrorCode: {
|
|
|
15
15
|
readonly INVALID_CREDENTIALS: "INVALID_CREDENTIALS";
|
|
16
16
|
readonly NO_APP_ACCESS: "NO_APP_ACCESS";
|
|
17
17
|
readonly ACCOUNT_INACTIVE: "ACCOUNT_INACTIVE";
|
|
18
|
+
/** The app/tenant pair in the request does not resolve: unknown app or tenant, inactive tenant, missing or wrong `tenantSlug`. A configuration error, never the user's. */
|
|
19
|
+
readonly INVALID_APP_CONTEXT: "INVALID_APP_CONTEXT";
|
|
20
|
+
/** `POST /auth/refresh` refused the token: expired, unknown, or replayed (the whole family is revoked). Re-authenticate. */
|
|
21
|
+
readonly INVALID_REFRESH_TOKEN: "INVALID_REFRESH_TOKEN";
|
|
22
|
+
/** `POST /auth/exchange` refused the one-time OAuth code: expired, already used, or minted for another app. */
|
|
23
|
+
readonly INVALID_OAUTH_CODE: "INVALID_OAUTH_CODE";
|
|
18
24
|
readonly CONFLICT: "CONFLICT";
|
|
19
25
|
readonly BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED";
|
|
20
26
|
readonly BIRTH_DATE_INVALID: "BIRTH_DATE_INVALID";
|
|
21
27
|
readonly UNDER_MINIMUM_AGE: "UNDER_MINIMUM_AGE";
|
|
28
|
+
readonly OAUTH_FLOW_INVALID: "OAUTH_FLOW_INVALID";
|
|
29
|
+
readonly OAUTH_EMAIL_UNVERIFIED: "OAUTH_EMAIL_UNVERIFIED";
|
|
30
|
+
readonly OAUTH_EMAIL_REQUIRED: "OAUTH_EMAIL_REQUIRED";
|
|
31
|
+
readonly OAUTH_NO_ACCOUNT: "OAUTH_NO_ACCOUNT";
|
|
32
|
+
/** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
|
|
33
|
+
readonly OAUTH_LINK_USER_MISMATCH: "OAUTH_LINK_USER_MISMATCH";
|
|
22
34
|
readonly UNAVAILABLE: "UNAVAILABLE";
|
|
23
35
|
readonly UPSTREAM: "UPSTREAM";
|
|
24
36
|
};
|
package/dist/cjs/core/errors.js
CHANGED
|
@@ -23,28 +23,58 @@ exports.BastionErrorCode = {
|
|
|
23
23
|
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
|
|
24
24
|
NO_APP_ACCESS: 'NO_APP_ACCESS',
|
|
25
25
|
ACCOUNT_INACTIVE: 'ACCOUNT_INACTIVE',
|
|
26
|
+
/** The app/tenant pair in the request does not resolve: unknown app or tenant, inactive tenant, missing or wrong `tenantSlug`. A configuration error, never the user's. */
|
|
27
|
+
INVALID_APP_CONTEXT: 'INVALID_APP_CONTEXT',
|
|
28
|
+
/** `POST /auth/refresh` refused the token: expired, unknown, or replayed (the whole family is revoked). Re-authenticate. */
|
|
29
|
+
INVALID_REFRESH_TOKEN: 'INVALID_REFRESH_TOKEN',
|
|
30
|
+
/** `POST /auth/exchange` refused the one-time OAuth code: expired, already used, or minted for another app. */
|
|
31
|
+
INVALID_OAUTH_CODE: 'INVALID_OAUTH_CODE',
|
|
26
32
|
CONFLICT: 'CONFLICT',
|
|
27
33
|
BIRTH_DATE_REQUIRED: 'BIRTH_DATE_REQUIRED',
|
|
28
34
|
BIRTH_DATE_INVALID: 'BIRTH_DATE_INVALID',
|
|
29
35
|
UNDER_MINIMUM_AGE: 'UNDER_MINIMUM_AGE',
|
|
36
|
+
OAUTH_FLOW_INVALID: 'OAUTH_FLOW_INVALID',
|
|
37
|
+
OAUTH_EMAIL_UNVERIFIED: 'OAUTH_EMAIL_UNVERIFIED',
|
|
38
|
+
OAUTH_EMAIL_REQUIRED: 'OAUTH_EMAIL_REQUIRED',
|
|
39
|
+
OAUTH_NO_ACCOUNT: 'OAUTH_NO_ACCOUNT',
|
|
40
|
+
/** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
|
|
41
|
+
OAUTH_LINK_USER_MISMATCH: 'OAUTH_LINK_USER_MISMATCH',
|
|
30
42
|
UNAVAILABLE: 'UNAVAILABLE',
|
|
31
43
|
UPSTREAM: 'UPSTREAM',
|
|
32
44
|
};
|
|
45
|
+
/** Messages Bastion already emits as a bare code: passed through unchanged. */
|
|
46
|
+
const CODE_MESSAGES = new Set([
|
|
47
|
+
exports.BastionErrorCode.BIRTH_DATE_REQUIRED,
|
|
48
|
+
exports.BastionErrorCode.BIRTH_DATE_INVALID,
|
|
49
|
+
exports.BastionErrorCode.UNDER_MINIMUM_AGE,
|
|
50
|
+
exports.BastionErrorCode.OAUTH_FLOW_INVALID,
|
|
51
|
+
exports.BastionErrorCode.OAUTH_EMAIL_UNVERIFIED,
|
|
52
|
+
exports.BastionErrorCode.OAUTH_EMAIL_REQUIRED,
|
|
53
|
+
exports.BastionErrorCode.OAUTH_NO_ACCOUNT,
|
|
54
|
+
exports.BastionErrorCode.OAUTH_LINK_USER_MISMATCH,
|
|
55
|
+
]);
|
|
56
|
+
const APP_CONTEXT_RE = /app not found|tenant not found|tenant is inactive|no tenants configured|tenantslug is required/i;
|
|
57
|
+
const OAUTH_CODE_RE = /already used code|code expired|code\/app mismatch/i;
|
|
33
58
|
function classifyBastionError(status, message) {
|
|
34
|
-
if (message
|
|
35
|
-
message === exports.BastionErrorCode.BIRTH_DATE_INVALID ||
|
|
36
|
-
message === exports.BastionErrorCode.UNDER_MINIMUM_AGE) {
|
|
59
|
+
if (CODE_MESSAGES.has(message))
|
|
37
60
|
return message;
|
|
38
|
-
|
|
61
|
+
if (APP_CONTEXT_RE.test(message) && (status === 401 || status === 400))
|
|
62
|
+
return exports.BastionErrorCode.INVALID_APP_CONTEXT;
|
|
39
63
|
if (status === 403 && /email not verified/i.test(message))
|
|
40
64
|
return exports.BastionErrorCode.EMAIL_NOT_VERIFIED;
|
|
41
65
|
if (status === 401) {
|
|
66
|
+
if (/refresh token/i.test(message))
|
|
67
|
+
return exports.BastionErrorCode.INVALID_REFRESH_TOKEN;
|
|
68
|
+
if (OAUTH_CODE_RE.test(message))
|
|
69
|
+
return exports.BastionErrorCode.INVALID_OAUTH_CODE;
|
|
42
70
|
if (/no access to this app/i.test(message))
|
|
43
71
|
return exports.BastionErrorCode.NO_APP_ACCESS;
|
|
44
72
|
if (/inactive|locked/i.test(message))
|
|
45
73
|
return exports.BastionErrorCode.ACCOUNT_INACTIVE;
|
|
46
74
|
return exports.BastionErrorCode.INVALID_CREDENTIALS;
|
|
47
75
|
}
|
|
76
|
+
if (status === 403 && /no access to this app/i.test(message))
|
|
77
|
+
return exports.BastionErrorCode.NO_APP_ACCESS;
|
|
48
78
|
if (status === 409)
|
|
49
79
|
return exports.BastionErrorCode.CONFLICT;
|
|
50
80
|
if (status === 503)
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import type { BastionHttp } from './bastion-http.js';
|
|
2
2
|
import { type BastionLogger } from './types.js';
|
|
3
|
-
import type { BastionAgeVerificationResult, BastionAuthMethods, BastionClientContext, BastionEmailChangeRequest, BastionLinkTicket, BastionLoginResult, BastionMe, BastionProfile, BastionProfileUpdate, BastionRegisterInput, BastionSession, BastionSocialAccount, BastionTokenPair, BastionTwoFactorConfirmed, BastionTwoFactorSetup, BastionTwoFactorStatus, BastionUserAuditEventsPage } from './api-types.js';
|
|
3
|
+
import type { BastionAgeVerificationResult, BastionAuthMethods, BastionClientContext, BastionEmailChangeRequest, BastionLinkCompleted, BastionLinkTicket, BastionLoginResult, BastionMe, BastionProfile, BastionProfileUpdate, BastionRegisterInput, BastionSession, BastionSocialAccount, BastionTokenPair, BastionTwoFactorConfirmed, BastionTwoFactorSetup, BastionTwoFactorStatus, BastionUserAuditEventsPage } from './api-types.js';
|
|
4
4
|
export interface BastionApiClientOptions {
|
|
5
5
|
http: BastionHttp;
|
|
6
6
|
/** This app's slug — sent as `appSlug` on every user-facing call. */
|
|
7
7
|
appSlug: string;
|
|
8
8
|
/** Required by Bastion when the app is registered in more than one tenant, or is `isGlobal`. */
|
|
9
9
|
tenantSlug?: string;
|
|
10
|
+
/**
|
|
11
|
+
* How long a refresh answer is remembered per refresh token, default 60 000 ms.
|
|
12
|
+
* Within the window a second `refresh` with the same (now revoked) token
|
|
13
|
+
* returns the remembered pair instead of tripping Bastion's reuse detection,
|
|
14
|
+
* and `logout` still finds the successor. A BFF whose pages fire several
|
|
15
|
+
* calls before the browser sees the new cookie may want a few minutes.
|
|
16
|
+
*/
|
|
17
|
+
refreshMemoryMs?: number;
|
|
10
18
|
logger?: BastionLogger;
|
|
11
19
|
}
|
|
12
20
|
/**
|
|
@@ -27,6 +35,7 @@ export declare class BastionApiClient {
|
|
|
27
35
|
private readonly http;
|
|
28
36
|
private readonly appSlug;
|
|
29
37
|
private readonly tenantSlug?;
|
|
38
|
+
private readonly refreshMemoryMs;
|
|
30
39
|
private readonly logger;
|
|
31
40
|
private readonly inFlight;
|
|
32
41
|
private readonly recent;
|
|
@@ -45,7 +54,7 @@ export declare class BastionApiClient {
|
|
|
45
54
|
exchange(code: string, ctx?: BastionClientContext): Promise<BastionLoginResult>;
|
|
46
55
|
completeTwoFactor(twoFactorToken: string, code: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
|
|
47
56
|
refresh(refreshToken: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
|
|
48
|
-
/** Revokes the token and any successor issued through `refresh`
|
|
57
|
+
/** Revokes the token and any successor issued through `refresh` within the memory window. Never throws. */
|
|
49
58
|
logout(refreshToken: string | null | undefined): Promise<void>;
|
|
50
59
|
listSessions(accessToken: string): Promise<BastionSession[]>;
|
|
51
60
|
revokeSession(accessToken: string, sessionId: string): Promise<void>;
|
|
@@ -66,20 +75,27 @@ export declare class BastionApiClient {
|
|
|
66
75
|
}>;
|
|
67
76
|
listSocialAccounts(accessToken: string): Promise<BastionSocialAccount[]>;
|
|
68
77
|
createSocialAccountLinkTicket(accessToken: string, provider: string): Promise<BastionLinkTicket>;
|
|
78
|
+
/**
|
|
79
|
+
* Second step of a link: spends the one-time `linkCode` Bastion put on the
|
|
80
|
+
* redirect to `/auth/callback`. Pass the signed-in user's own token — Bastion
|
|
81
|
+
* links only if its `sub` is the user the ticket was issued to (403
|
|
82
|
+
* `OAUTH_LINK_USER_MISMATCH` otherwise; the code is spent either way).
|
|
83
|
+
*/
|
|
84
|
+
completeSocialAccountLink(accessToken: string, code: string): Promise<BastionLinkCompleted>;
|
|
69
85
|
unlinkSocialAccount(accessToken: string, provider: string): Promise<void>;
|
|
70
86
|
updateSocialAccountSyncAvatar(accessToken: string, provider: string, syncAvatar: boolean): Promise<BastionSocialAccount>;
|
|
71
|
-
resendVerification(email: string): Promise<void>;
|
|
72
|
-
verifyEmail(token: string): Promise<void>;
|
|
73
|
-
forgotPassword(email: string): Promise<void>;
|
|
74
|
-
resetPassword(token: string, newPassword: string): Promise<void>;
|
|
87
|
+
resendVerification(email: string, ctx?: BastionClientContext): Promise<void>;
|
|
88
|
+
verifyEmail(token: string, ctx?: BastionClientContext): Promise<void>;
|
|
89
|
+
forgotPassword(email: string, ctx?: BastionClientContext): Promise<void>;
|
|
90
|
+
resetPassword(token: string, newPassword: string, ctx?: BastionClientContext): Promise<void>;
|
|
75
91
|
changePassword(accessToken: string, currentPassword: string, newPassword: string): Promise<void>;
|
|
76
92
|
requestEmailChange(accessToken: string, input: BastionEmailChangeRequest): Promise<{
|
|
77
93
|
message: string;
|
|
78
94
|
}>;
|
|
79
|
-
confirmEmailChange(token: string): Promise<{
|
|
95
|
+
confirmEmailChange(token: string, ctx?: BastionClientContext): Promise<{
|
|
80
96
|
message: string;
|
|
81
97
|
}>;
|
|
82
|
-
revokeEmailChange(token: string): Promise<{
|
|
98
|
+
revokeEmailChange(token: string, ctx?: BastionClientContext): Promise<{
|
|
83
99
|
message: string;
|
|
84
100
|
}>;
|
|
85
101
|
/** The user's own audit trail (`GET /auth/me/events`), paginated. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { silentLogger } from './types.js';
|
|
3
|
-
const
|
|
3
|
+
const DEFAULT_REFRESH_MEMORY_MS = 60_000;
|
|
4
4
|
const AUTH_METHODS_CACHE_MS = 5 * 60_000;
|
|
5
5
|
const MAX_ROTATION_HOPS = 32;
|
|
6
6
|
/**
|
|
@@ -21,6 +21,7 @@ export class BastionApiClient {
|
|
|
21
21
|
http;
|
|
22
22
|
appSlug;
|
|
23
23
|
tenantSlug;
|
|
24
|
+
refreshMemoryMs;
|
|
24
25
|
logger;
|
|
25
26
|
inFlight = new Map();
|
|
26
27
|
recent = new Map();
|
|
@@ -30,6 +31,7 @@ export class BastionApiClient {
|
|
|
30
31
|
this.http = options.http;
|
|
31
32
|
this.appSlug = options.appSlug;
|
|
32
33
|
this.tenantSlug = options.tenantSlug || undefined;
|
|
34
|
+
this.refreshMemoryMs = options.refreshMemoryMs ?? DEFAULT_REFRESH_MEMORY_MS;
|
|
33
35
|
this.logger = options.logger ?? silentLogger;
|
|
34
36
|
}
|
|
35
37
|
get appContext() {
|
|
@@ -83,7 +85,7 @@ export class BastionApiClient {
|
|
|
83
85
|
this.inFlight.set(key, pending);
|
|
84
86
|
return pending;
|
|
85
87
|
}
|
|
86
|
-
/** Revokes the token and any successor issued through `refresh`
|
|
88
|
+
/** Revokes the token and any successor issued through `refresh` within the memory window. Never throws. */
|
|
87
89
|
async logout(refreshToken) {
|
|
88
90
|
if (!refreshToken)
|
|
89
91
|
return;
|
|
@@ -151,6 +153,15 @@ export class BastionApiClient {
|
|
|
151
153
|
createSocialAccountLinkTicket(accessToken, provider) {
|
|
152
154
|
return this.call('POST', `/auth/me/social-accounts/${encodeURIComponent(provider)}/link-ticket`, undefined, { token: accessToken });
|
|
153
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Second step of a link: spends the one-time `linkCode` Bastion put on the
|
|
158
|
+
* redirect to `/auth/callback`. Pass the signed-in user's own token — Bastion
|
|
159
|
+
* links only if its `sub` is the user the ticket was issued to (403
|
|
160
|
+
* `OAUTH_LINK_USER_MISMATCH` otherwise; the code is spent either way).
|
|
161
|
+
*/
|
|
162
|
+
completeSocialAccountLink(accessToken, code) {
|
|
163
|
+
return this.call('POST', '/auth/me/social-accounts/link/complete', { code }, { token: accessToken });
|
|
164
|
+
}
|
|
154
165
|
unlinkSocialAccount(accessToken, provider) {
|
|
155
166
|
return this.call('DELETE', `/auth/me/social-accounts/${encodeURIComponent(provider)}`, undefined, { token: accessToken });
|
|
156
167
|
}
|
|
@@ -158,17 +169,19 @@ export class BastionApiClient {
|
|
|
158
169
|
return this.call('PATCH', `/auth/me/social-accounts/${encodeURIComponent(provider)}`, { syncAvatar }, { token: accessToken });
|
|
159
170
|
}
|
|
160
171
|
// ── email & password ─────────────────────────────────────────────────────
|
|
161
|
-
|
|
162
|
-
|
|
172
|
+
// Unauthenticated routes: Bastion rate-limits them by IP, so pass the
|
|
173
|
+
// browser's context or every user behind this backend shares one bucket.
|
|
174
|
+
resendVerification(email, ctx) {
|
|
175
|
+
return this.call('POST', '/auth/resend-verification', { email, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
163
176
|
}
|
|
164
|
-
verifyEmail(token) {
|
|
165
|
-
return this.call('GET', `/auth/verify-email?token=${encodeURIComponent(token)}
|
|
177
|
+
verifyEmail(token, ctx) {
|
|
178
|
+
return this.call('GET', `/auth/verify-email?token=${encodeURIComponent(token)}`, undefined, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
166
179
|
}
|
|
167
|
-
forgotPassword(email) {
|
|
168
|
-
return this.call('POST', '/auth/forgot-password', { email, ...this.appContext });
|
|
180
|
+
forgotPassword(email, ctx) {
|
|
181
|
+
return this.call('POST', '/auth/forgot-password', { email, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
169
182
|
}
|
|
170
|
-
resetPassword(token, newPassword) {
|
|
171
|
-
return this.call('POST', '/auth/reset-password', { token, newPassword });
|
|
183
|
+
resetPassword(token, newPassword, ctx) {
|
|
184
|
+
return this.call('POST', '/auth/reset-password', { token, newPassword }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
172
185
|
}
|
|
173
186
|
changePassword(accessToken, currentPassword, newPassword) {
|
|
174
187
|
return this.call('PATCH', '/auth/me/password', { currentPassword, newPassword }, { token: accessToken });
|
|
@@ -176,11 +189,11 @@ export class BastionApiClient {
|
|
|
176
189
|
requestEmailChange(accessToken, input) {
|
|
177
190
|
return this.call('PATCH', '/auth/me/email', input, { token: accessToken });
|
|
178
191
|
}
|
|
179
|
-
confirmEmailChange(token) {
|
|
180
|
-
return this.call('POST', '/auth/me/confirm-email', { token });
|
|
192
|
+
confirmEmailChange(token, ctx) {
|
|
193
|
+
return this.call('POST', '/auth/me/confirm-email', { token }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
181
194
|
}
|
|
182
|
-
revokeEmailChange(token) {
|
|
183
|
-
return this.call('POST', '/auth/me/revoke-email-change', { token });
|
|
195
|
+
revokeEmailChange(token, ctx) {
|
|
196
|
+
return this.call('POST', '/auth/me/revoke-email-change', { token }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
184
197
|
}
|
|
185
198
|
// ── GDPR ─────────────────────────────────────────────────────────────────
|
|
186
199
|
/** The user's own audit trail (`GET /auth/me/events`), paginated. */
|
|
@@ -213,10 +226,10 @@ export class BastionApiClient {
|
|
|
213
226
|
}
|
|
214
227
|
prune(now) {
|
|
215
228
|
for (const [key, entry] of this.recent)
|
|
216
|
-
if (now - entry.at >=
|
|
229
|
+
if (now - entry.at >= this.refreshMemoryMs)
|
|
217
230
|
this.recent.delete(key);
|
|
218
231
|
for (const [key, entry] of this.successors)
|
|
219
|
-
if (now - entry.at >=
|
|
232
|
+
if (now - entry.at >= this.refreshMemoryMs)
|
|
220
233
|
this.successors.delete(key);
|
|
221
234
|
}
|
|
222
235
|
}
|
|
@@ -119,6 +119,10 @@ export interface BastionAuthMethods {
|
|
|
119
119
|
export interface BastionLinkTicket {
|
|
120
120
|
url: string;
|
|
121
121
|
}
|
|
122
|
+
/** `POST /auth/me/social-accounts/link/complete` — the provider now linked (lowercase slug). */
|
|
123
|
+
export interface BastionLinkCompleted {
|
|
124
|
+
provider: string;
|
|
125
|
+
}
|
|
122
126
|
/** One row of `GET /auth/me/events` — the user-facing projection, deliberately without `ip`/`userAgent`. */
|
|
123
127
|
export interface BastionUserAuditEvent {
|
|
124
128
|
id: string;
|
|
@@ -15,10 +15,22 @@ export declare const BastionErrorCode: {
|
|
|
15
15
|
readonly INVALID_CREDENTIALS: "INVALID_CREDENTIALS";
|
|
16
16
|
readonly NO_APP_ACCESS: "NO_APP_ACCESS";
|
|
17
17
|
readonly ACCOUNT_INACTIVE: "ACCOUNT_INACTIVE";
|
|
18
|
+
/** The app/tenant pair in the request does not resolve: unknown app or tenant, inactive tenant, missing or wrong `tenantSlug`. A configuration error, never the user's. */
|
|
19
|
+
readonly INVALID_APP_CONTEXT: "INVALID_APP_CONTEXT";
|
|
20
|
+
/** `POST /auth/refresh` refused the token: expired, unknown, or replayed (the whole family is revoked). Re-authenticate. */
|
|
21
|
+
readonly INVALID_REFRESH_TOKEN: "INVALID_REFRESH_TOKEN";
|
|
22
|
+
/** `POST /auth/exchange` refused the one-time OAuth code: expired, already used, or minted for another app. */
|
|
23
|
+
readonly INVALID_OAUTH_CODE: "INVALID_OAUTH_CODE";
|
|
18
24
|
readonly CONFLICT: "CONFLICT";
|
|
19
25
|
readonly BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED";
|
|
20
26
|
readonly BIRTH_DATE_INVALID: "BIRTH_DATE_INVALID";
|
|
21
27
|
readonly UNDER_MINIMUM_AGE: "UNDER_MINIMUM_AGE";
|
|
28
|
+
readonly OAUTH_FLOW_INVALID: "OAUTH_FLOW_INVALID";
|
|
29
|
+
readonly OAUTH_EMAIL_UNVERIFIED: "OAUTH_EMAIL_UNVERIFIED";
|
|
30
|
+
readonly OAUTH_EMAIL_REQUIRED: "OAUTH_EMAIL_REQUIRED";
|
|
31
|
+
readonly OAUTH_NO_ACCOUNT: "OAUTH_NO_ACCOUNT";
|
|
32
|
+
/** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
|
|
33
|
+
readonly OAUTH_LINK_USER_MISMATCH: "OAUTH_LINK_USER_MISMATCH";
|
|
22
34
|
readonly UNAVAILABLE: "UNAVAILABLE";
|
|
23
35
|
readonly UPSTREAM: "UPSTREAM";
|
|
24
36
|
};
|
package/dist/esm/core/errors.js
CHANGED
|
@@ -18,28 +18,58 @@ export const BastionErrorCode = {
|
|
|
18
18
|
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
|
|
19
19
|
NO_APP_ACCESS: 'NO_APP_ACCESS',
|
|
20
20
|
ACCOUNT_INACTIVE: 'ACCOUNT_INACTIVE',
|
|
21
|
+
/** The app/tenant pair in the request does not resolve: unknown app or tenant, inactive tenant, missing or wrong `tenantSlug`. A configuration error, never the user's. */
|
|
22
|
+
INVALID_APP_CONTEXT: 'INVALID_APP_CONTEXT',
|
|
23
|
+
/** `POST /auth/refresh` refused the token: expired, unknown, or replayed (the whole family is revoked). Re-authenticate. */
|
|
24
|
+
INVALID_REFRESH_TOKEN: 'INVALID_REFRESH_TOKEN',
|
|
25
|
+
/** `POST /auth/exchange` refused the one-time OAuth code: expired, already used, or minted for another app. */
|
|
26
|
+
INVALID_OAUTH_CODE: 'INVALID_OAUTH_CODE',
|
|
21
27
|
CONFLICT: 'CONFLICT',
|
|
22
28
|
BIRTH_DATE_REQUIRED: 'BIRTH_DATE_REQUIRED',
|
|
23
29
|
BIRTH_DATE_INVALID: 'BIRTH_DATE_INVALID',
|
|
24
30
|
UNDER_MINIMUM_AGE: 'UNDER_MINIMUM_AGE',
|
|
31
|
+
OAUTH_FLOW_INVALID: 'OAUTH_FLOW_INVALID',
|
|
32
|
+
OAUTH_EMAIL_UNVERIFIED: 'OAUTH_EMAIL_UNVERIFIED',
|
|
33
|
+
OAUTH_EMAIL_REQUIRED: 'OAUTH_EMAIL_REQUIRED',
|
|
34
|
+
OAUTH_NO_ACCOUNT: 'OAUTH_NO_ACCOUNT',
|
|
35
|
+
/** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
|
|
36
|
+
OAUTH_LINK_USER_MISMATCH: 'OAUTH_LINK_USER_MISMATCH',
|
|
25
37
|
UNAVAILABLE: 'UNAVAILABLE',
|
|
26
38
|
UPSTREAM: 'UPSTREAM',
|
|
27
39
|
};
|
|
40
|
+
/** Messages Bastion already emits as a bare code: passed through unchanged. */
|
|
41
|
+
const CODE_MESSAGES = new Set([
|
|
42
|
+
BastionErrorCode.BIRTH_DATE_REQUIRED,
|
|
43
|
+
BastionErrorCode.BIRTH_DATE_INVALID,
|
|
44
|
+
BastionErrorCode.UNDER_MINIMUM_AGE,
|
|
45
|
+
BastionErrorCode.OAUTH_FLOW_INVALID,
|
|
46
|
+
BastionErrorCode.OAUTH_EMAIL_UNVERIFIED,
|
|
47
|
+
BastionErrorCode.OAUTH_EMAIL_REQUIRED,
|
|
48
|
+
BastionErrorCode.OAUTH_NO_ACCOUNT,
|
|
49
|
+
BastionErrorCode.OAUTH_LINK_USER_MISMATCH,
|
|
50
|
+
]);
|
|
51
|
+
const APP_CONTEXT_RE = /app not found|tenant not found|tenant is inactive|no tenants configured|tenantslug is required/i;
|
|
52
|
+
const OAUTH_CODE_RE = /already used code|code expired|code\/app mismatch/i;
|
|
28
53
|
export function classifyBastionError(status, message) {
|
|
29
|
-
if (message
|
|
30
|
-
message === BastionErrorCode.BIRTH_DATE_INVALID ||
|
|
31
|
-
message === BastionErrorCode.UNDER_MINIMUM_AGE) {
|
|
54
|
+
if (CODE_MESSAGES.has(message))
|
|
32
55
|
return message;
|
|
33
|
-
|
|
56
|
+
if (APP_CONTEXT_RE.test(message) && (status === 401 || status === 400))
|
|
57
|
+
return BastionErrorCode.INVALID_APP_CONTEXT;
|
|
34
58
|
if (status === 403 && /email not verified/i.test(message))
|
|
35
59
|
return BastionErrorCode.EMAIL_NOT_VERIFIED;
|
|
36
60
|
if (status === 401) {
|
|
61
|
+
if (/refresh token/i.test(message))
|
|
62
|
+
return BastionErrorCode.INVALID_REFRESH_TOKEN;
|
|
63
|
+
if (OAUTH_CODE_RE.test(message))
|
|
64
|
+
return BastionErrorCode.INVALID_OAUTH_CODE;
|
|
37
65
|
if (/no access to this app/i.test(message))
|
|
38
66
|
return BastionErrorCode.NO_APP_ACCESS;
|
|
39
67
|
if (/inactive|locked/i.test(message))
|
|
40
68
|
return BastionErrorCode.ACCOUNT_INACTIVE;
|
|
41
69
|
return BastionErrorCode.INVALID_CREDENTIALS;
|
|
42
70
|
}
|
|
71
|
+
if (status === 403 && /no access to this app/i.test(message))
|
|
72
|
+
return BastionErrorCode.NO_APP_ACCESS;
|
|
43
73
|
if (status === 409)
|
|
44
74
|
return BastionErrorCode.CONFLICT;
|
|
45
75
|
if (status === 503)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyatom/bastion-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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)",
|