@heyatom/bastion-client 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/dist/cjs/core/api-client.d.ts +98 -0
- package/dist/cjs/core/api-client.js +226 -0
- package/dist/cjs/core/api-types.d.ts +138 -0
- package/dist/cjs/core/api-types.js +10 -0
- package/dist/cjs/core/bastion-http.d.ts +13 -8
- package/dist/cjs/core/bastion-http.js +29 -17
- package/dist/cjs/core/errors.d.ts +23 -2
- package/dist/cjs/core/errors.js +45 -3
- package/dist/cjs/core/index.d.ts +4 -2
- package/dist/cjs/core/index.js +4 -1
- package/dist/cjs/core/jwks-verifier.js +2 -1
- package/dist/cjs/core/service-token-provider.d.ts +20 -12
- package/dist/cjs/core/service-token-provider.js +55 -27
- package/dist/cjs/nest/bastion-api-client.provider.d.ts +13 -0
- package/dist/cjs/nest/bastion-api-client.provider.js +43 -0
- package/dist/cjs/nest/bastion-audit.service.d.ts +13 -2
- package/dist/cjs/nest/bastion-audit.service.js +7 -1
- package/dist/cjs/nest/bastion-http-exception.d.ts +19 -0
- package/dist/cjs/nest/bastion-http-exception.js +39 -0
- package/dist/cjs/nest/bastion.module.js +2 -0
- package/dist/cjs/nest/bastion.service.d.ts +5 -7
- package/dist/cjs/nest/bastion.service.js +5 -13
- package/dist/cjs/nest/index.d.ts +3 -1
- package/dist/cjs/nest/index.js +6 -1
- package/dist/cjs/nest/options.d.ts +2 -0
- package/dist/cjs/webhooks/index.d.ts +54 -0
- package/dist/cjs/webhooks/index.js +72 -0
- package/dist/esm/core/api-client.d.ts +98 -0
- package/dist/esm/core/api-client.js +222 -0
- package/dist/esm/core/api-types.d.ts +138 -0
- package/dist/esm/core/api-types.js +7 -0
- package/dist/esm/core/bastion-http.d.ts +13 -8
- package/dist/esm/core/bastion-http.js +30 -18
- package/dist/esm/core/errors.d.ts +23 -2
- package/dist/esm/core/errors.js +43 -2
- package/dist/esm/core/index.d.ts +4 -2
- package/dist/esm/core/index.js +2 -0
- package/dist/esm/core/jwks-verifier.js +2 -1
- package/dist/esm/core/service-token-provider.d.ts +20 -12
- package/dist/esm/core/service-token-provider.js +55 -27
- package/dist/esm/nest/bastion-api-client.provider.d.ts +13 -0
- package/dist/esm/nest/bastion-api-client.provider.js +40 -0
- package/dist/esm/nest/bastion-audit.service.d.ts +13 -2
- package/dist/esm/nest/bastion-audit.service.js +7 -1
- package/dist/esm/nest/bastion-http-exception.d.ts +19 -0
- package/dist/esm/nest/bastion-http-exception.js +34 -0
- package/dist/esm/nest/bastion.module.js +2 -0
- package/dist/esm/nest/bastion.service.d.ts +5 -7
- package/dist/esm/nest/bastion.service.js +6 -14
- package/dist/esm/nest/index.d.ts +3 -1
- package/dist/esm/nest/index.js +2 -0
- package/dist/esm/nest/options.d.ts +2 -0
- package/dist/esm/webhooks/index.d.ts +54 -0
- package/dist/esm/webhooks/index.js +68 -0
- package/package.json +14 -1
package/README.md
CHANGED
|
@@ -4,7 +4,9 @@ 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
|
|
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`).
|
|
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
|
+
- `@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()`.
|
|
8
10
|
|
|
9
11
|
Ships CJS and ESM. `jose` v6 is ESM-only, so the CJS build relies on Node's `require(esm)` — Node ≥ 20.19 / 22.12. Peer deps: `jose` (always), `@nestjs/common`, `@nestjs/core`, `rxjs`, `reflect-metadata` (for `/nest`).
|
|
10
12
|
|
|
@@ -71,6 +73,34 @@ Bastion stores app events as `<serviceSlug>.<event>` — send the bare `resource
|
|
|
71
73
|
// Core equivalent: assertUserTokenFor(await verifier.verify(token), 'dbd-builds').
|
|
72
74
|
```
|
|
73
75
|
|
|
76
|
+
## App backends (a BFF for your users)
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { BastionApiClientProvider, BastionHttpException } from '@heyatom/bastion-client/nest';
|
|
80
|
+
|
|
81
|
+
@Controller('auth')
|
|
82
|
+
export class AuthController {
|
|
83
|
+
constructor(private readonly bastion: BastionApiClientProvider) {}
|
|
84
|
+
|
|
85
|
+
@Post('login')
|
|
86
|
+
async login(@Body() dto: LoginDto, @Req() req: Request) {
|
|
87
|
+
try {
|
|
88
|
+
return await this.bastion.login(dto.email, dto.password, { ip: clientIp(req), userAgent: req.headers['user-agent'] });
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err instanceof BastionHttpException && err.code === 'EMAIL_NOT_VERIFIED') throw new ForbiddenException(err.code);
|
|
91
|
+
throw err; // any other BastionHttpException already carries Bastion's status
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
// Webhook receiver — needs the raw body (NestFactory.create(AppModule, { rawBody: true }))
|
|
99
|
+
import { verifyWebhookSignature } from '@heyatom/bastion-client/webhooks';
|
|
100
|
+
const result = verifyWebhookSignature({ rawBody: req.rawBody, headers: req.headers, secrets: [secret, previousSecret] });
|
|
101
|
+
if (!result.ok) throw new UnauthorizedException(result.reason);
|
|
102
|
+
```
|
|
103
|
+
|
|
74
104
|
## Without NestJS
|
|
75
105
|
|
|
76
106
|
```ts
|
|
@@ -81,6 +111,7 @@ const payload = await verifier.verify(bearerToken); // throws BastionTokenError
|
|
|
81
111
|
|
|
82
112
|
const http = new BastionHttp({ baseUrl: process.env.BASTION_URL! });
|
|
83
113
|
const tokens = new ServiceTokenProvider(http, { apiKey, serviceSlug: 'my-service' });
|
|
114
|
+
// several targets: a record keyed by serviceSlug, or { defaultSlug, resolve: (slug) => credential | null }
|
|
84
115
|
await http.writeAuditEvent(await tokens.getToken(), { event: 'thing.done' });
|
|
85
116
|
```
|
|
86
117
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { BastionHttp } from './bastion-http.js';
|
|
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';
|
|
4
|
+
export interface BastionApiClientOptions {
|
|
5
|
+
http: BastionHttp;
|
|
6
|
+
/** This app's slug — sent as `appSlug` on every user-facing call. */
|
|
7
|
+
appSlug: string;
|
|
8
|
+
/** Required by Bastion when the app is registered in more than one tenant, or is `isGlobal`. */
|
|
9
|
+
tenantSlug?: string;
|
|
10
|
+
logger?: BastionLogger;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The user-facing half of Bastion's API, for an app backend that proxies its
|
|
14
|
+
* users' auth: login, register, OAuth exchange, 2FA, refresh, logout,
|
|
15
|
+
* sessions, `/me`, profile, social accounts, email/password flows, export,
|
|
16
|
+
* self-deletion. Every call takes the user's `accessToken` where Bastion
|
|
17
|
+
* needs one; nothing here holds state about who the user is.
|
|
18
|
+
*
|
|
19
|
+
* `refresh` is single-flight per refresh token and remembers each answer for
|
|
20
|
+
* 60 s: Bastion rotates the token on every use and treats a replay as
|
|
21
|
+
* theft (the whole family is revoked), so two concurrent refreshes with the
|
|
22
|
+
* same token from a racing browser must collapse into one call. `logout`
|
|
23
|
+
* follows the rotation chain so it revokes the token the browser is actually
|
|
24
|
+
* holding, not the one it started with.
|
|
25
|
+
*/
|
|
26
|
+
export declare class BastionApiClient {
|
|
27
|
+
private readonly http;
|
|
28
|
+
private readonly appSlug;
|
|
29
|
+
private readonly tenantSlug?;
|
|
30
|
+
private readonly logger;
|
|
31
|
+
private readonly inFlight;
|
|
32
|
+
private readonly recent;
|
|
33
|
+
private readonly successors;
|
|
34
|
+
private authMethods?;
|
|
35
|
+
constructor(options: BastionApiClientOptions);
|
|
36
|
+
private get appContext();
|
|
37
|
+
private static clientHeaders;
|
|
38
|
+
private call;
|
|
39
|
+
private static hash;
|
|
40
|
+
login(email: string, password: string, ctx?: BastionClientContext): Promise<BastionLoginResult>;
|
|
41
|
+
register(input: BastionRegisterInput, ctx?: BastionClientContext): Promise<{
|
|
42
|
+
message: string;
|
|
43
|
+
}>;
|
|
44
|
+
/** Trades the one-time OAuth code for tokens. */
|
|
45
|
+
exchange(code: string, ctx?: BastionClientContext): Promise<BastionLoginResult>;
|
|
46
|
+
completeTwoFactor(twoFactorToken: string, code: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
|
|
47
|
+
refresh(refreshToken: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
|
|
48
|
+
/** Revokes the token and any successor issued through `refresh` in the last minute. Never throws. */
|
|
49
|
+
logout(refreshToken: string | null | undefined): Promise<void>;
|
|
50
|
+
listSessions(accessToken: string): Promise<BastionSession[]>;
|
|
51
|
+
revokeSession(accessToken: string, sessionId: string): Promise<void>;
|
|
52
|
+
revokeOtherSessions(accessToken: string): Promise<{
|
|
53
|
+
count: number;
|
|
54
|
+
}>;
|
|
55
|
+
me(accessToken: string): Promise<BastionMe>;
|
|
56
|
+
/** Answers the updated columns only (`BastionProfile`), not the full `BastionMe`. */
|
|
57
|
+
updateProfile(accessToken: string, update: BastionProfileUpdate): Promise<BastionProfile>;
|
|
58
|
+
completeAgeVerification(accessToken: string, birthDate: string): Promise<BastionAgeVerificationResult>;
|
|
59
|
+
/** Cached 5 min; a failed refetch serves the cached answer. */
|
|
60
|
+
getAuthMethods(): Promise<BastionAuthMethods>;
|
|
61
|
+
twoFactorStatus(accessToken: string): Promise<BastionTwoFactorStatus>;
|
|
62
|
+
twoFactorSetup(accessToken: string, currentPassword?: string): Promise<BastionTwoFactorSetup>;
|
|
63
|
+
twoFactorConfirm(accessToken: string, code: string, currentPassword?: string): Promise<BastionTwoFactorConfirmed>;
|
|
64
|
+
twoFactorDisable(accessToken: string, code: string, currentPassword?: string): Promise<{
|
|
65
|
+
message: string;
|
|
66
|
+
}>;
|
|
67
|
+
listSocialAccounts(accessToken: string): Promise<BastionSocialAccount[]>;
|
|
68
|
+
createSocialAccountLinkTicket(accessToken: string, provider: string): Promise<BastionLinkTicket>;
|
|
69
|
+
unlinkSocialAccount(accessToken: string, provider: string): Promise<void>;
|
|
70
|
+
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>;
|
|
75
|
+
changePassword(accessToken: string, currentPassword: string, newPassword: string): Promise<void>;
|
|
76
|
+
requestEmailChange(accessToken: string, input: BastionEmailChangeRequest): Promise<{
|
|
77
|
+
message: string;
|
|
78
|
+
}>;
|
|
79
|
+
confirmEmailChange(token: string): Promise<{
|
|
80
|
+
message: string;
|
|
81
|
+
}>;
|
|
82
|
+
revokeEmailChange(token: string): Promise<{
|
|
83
|
+
message: string;
|
|
84
|
+
}>;
|
|
85
|
+
/** The user's own audit trail (`GET /auth/me/events`), paginated. */
|
|
86
|
+
listMyEvents(accessToken: string, page?: {
|
|
87
|
+
page?: number;
|
|
88
|
+
limit?: number;
|
|
89
|
+
}): Promise<BastionUserAuditEventsPage>;
|
|
90
|
+
exportData(accessToken: string): Promise<Record<string, unknown>>;
|
|
91
|
+
/** Step-up: `currentPassword` when the account has one, `twoFactorCode` when 2FA is on. */
|
|
92
|
+
deleteSelf(accessToken: string, stepUp?: {
|
|
93
|
+
currentPassword?: string;
|
|
94
|
+
twoFactorCode?: string;
|
|
95
|
+
}): Promise<void>;
|
|
96
|
+
private latestRefreshToken;
|
|
97
|
+
private prune;
|
|
98
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BastionApiClient = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const types_js_1 = require("./types.js");
|
|
6
|
+
const REFRESH_CACHE_MS = 60_000;
|
|
7
|
+
const AUTH_METHODS_CACHE_MS = 5 * 60_000;
|
|
8
|
+
const MAX_ROTATION_HOPS = 32;
|
|
9
|
+
/**
|
|
10
|
+
* The user-facing half of Bastion's API, for an app backend that proxies its
|
|
11
|
+
* users' auth: login, register, OAuth exchange, 2FA, refresh, logout,
|
|
12
|
+
* sessions, `/me`, profile, social accounts, email/password flows, export,
|
|
13
|
+
* self-deletion. Every call takes the user's `accessToken` where Bastion
|
|
14
|
+
* needs one; nothing here holds state about who the user is.
|
|
15
|
+
*
|
|
16
|
+
* `refresh` is single-flight per refresh token and remembers each answer for
|
|
17
|
+
* 60 s: Bastion rotates the token on every use and treats a replay as
|
|
18
|
+
* theft (the whole family is revoked), so two concurrent refreshes with the
|
|
19
|
+
* same token from a racing browser must collapse into one call. `logout`
|
|
20
|
+
* follows the rotation chain so it revokes the token the browser is actually
|
|
21
|
+
* holding, not the one it started with.
|
|
22
|
+
*/
|
|
23
|
+
class BastionApiClient {
|
|
24
|
+
http;
|
|
25
|
+
appSlug;
|
|
26
|
+
tenantSlug;
|
|
27
|
+
logger;
|
|
28
|
+
inFlight = new Map();
|
|
29
|
+
recent = new Map();
|
|
30
|
+
successors = new Map();
|
|
31
|
+
authMethods;
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.http = options.http;
|
|
34
|
+
this.appSlug = options.appSlug;
|
|
35
|
+
this.tenantSlug = options.tenantSlug || undefined;
|
|
36
|
+
this.logger = options.logger ?? types_js_1.silentLogger;
|
|
37
|
+
}
|
|
38
|
+
get appContext() {
|
|
39
|
+
return { appSlug: this.appSlug, ...(this.tenantSlug ? { tenantSlug: this.tenantSlug } : {}) };
|
|
40
|
+
}
|
|
41
|
+
static clientHeaders(ctx) {
|
|
42
|
+
const headers = {};
|
|
43
|
+
if (ctx?.userAgent)
|
|
44
|
+
headers['User-Agent'] = ctx.userAgent;
|
|
45
|
+
if (ctx?.ip)
|
|
46
|
+
headers['X-Real-IP'] = ctx.ip;
|
|
47
|
+
return headers;
|
|
48
|
+
}
|
|
49
|
+
call(method, path, body, init) {
|
|
50
|
+
return this.http.call(method, path, body, init);
|
|
51
|
+
}
|
|
52
|
+
static hash(token) {
|
|
53
|
+
return (0, node_crypto_1.createHash)('sha256').update(token).digest('hex');
|
|
54
|
+
}
|
|
55
|
+
// ── session ──────────────────────────────────────────────────────────────
|
|
56
|
+
login(email, password, ctx) {
|
|
57
|
+
return this.call('POST', '/auth/login', { email, password, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
58
|
+
}
|
|
59
|
+
register(input, ctx) {
|
|
60
|
+
return this.call('POST', '/auth/register', { ...input, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
61
|
+
}
|
|
62
|
+
/** Trades the one-time OAuth code for tokens. */
|
|
63
|
+
exchange(code, ctx) {
|
|
64
|
+
return this.call('POST', '/auth/exchange', { code, appSlug: this.appSlug }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
65
|
+
}
|
|
66
|
+
completeTwoFactor(twoFactorToken, code, ctx) {
|
|
67
|
+
return this.call('POST', '/auth/2fa/authenticate', { twoFactorToken, code }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
68
|
+
}
|
|
69
|
+
async refresh(refreshToken, ctx) {
|
|
70
|
+
const key = BastionApiClient.hash(refreshToken);
|
|
71
|
+
this.prune(Date.now());
|
|
72
|
+
const cached = this.recent.get(key);
|
|
73
|
+
if (cached)
|
|
74
|
+
return cached.value;
|
|
75
|
+
const running = this.inFlight.get(key);
|
|
76
|
+
if (running)
|
|
77
|
+
return running;
|
|
78
|
+
const pending = this.call('POST', '/auth/refresh', { refreshToken, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) })
|
|
79
|
+
.then((value) => {
|
|
80
|
+
const at = Date.now();
|
|
81
|
+
this.recent.set(key, { value, at });
|
|
82
|
+
this.successors.set(key, { next: value.refreshToken, at });
|
|
83
|
+
return value;
|
|
84
|
+
})
|
|
85
|
+
.finally(() => this.inFlight.delete(key));
|
|
86
|
+
this.inFlight.set(key, pending);
|
|
87
|
+
return pending;
|
|
88
|
+
}
|
|
89
|
+
/** Revokes the token and any successor issued through `refresh` in the last minute. Never throws. */
|
|
90
|
+
async logout(refreshToken) {
|
|
91
|
+
if (!refreshToken)
|
|
92
|
+
return;
|
|
93
|
+
const tokens = new Set([refreshToken, this.latestRefreshToken(refreshToken)]);
|
|
94
|
+
this.recent.delete(BastionApiClient.hash(refreshToken));
|
|
95
|
+
await Promise.all([...tokens].map((token) => this.call('POST', '/auth/logout', { refreshToken: token }).catch((err) => this.logger.warn(`bastion: logout failed: ${err.message}`))));
|
|
96
|
+
}
|
|
97
|
+
listSessions(accessToken) {
|
|
98
|
+
return this.call('GET', '/auth/sessions', undefined, { token: accessToken });
|
|
99
|
+
}
|
|
100
|
+
revokeSession(accessToken, sessionId) {
|
|
101
|
+
return this.call('DELETE', `/auth/sessions/${encodeURIComponent(sessionId)}`, undefined, { token: accessToken });
|
|
102
|
+
}
|
|
103
|
+
revokeOtherSessions(accessToken) {
|
|
104
|
+
return this.call('DELETE', '/auth/sessions', undefined, { token: accessToken });
|
|
105
|
+
}
|
|
106
|
+
// ── account ──────────────────────────────────────────────────────────────
|
|
107
|
+
me(accessToken) {
|
|
108
|
+
return this.call('GET', '/auth/me', undefined, { token: accessToken });
|
|
109
|
+
}
|
|
110
|
+
/** Answers the updated columns only (`BastionProfile`), not the full `BastionMe`. */
|
|
111
|
+
updateProfile(accessToken, update) {
|
|
112
|
+
return this.call('PATCH', '/auth/me', update, { token: accessToken });
|
|
113
|
+
}
|
|
114
|
+
completeAgeVerification(accessToken, birthDate) {
|
|
115
|
+
return this.call('POST', '/auth/age-verification', { birthDate }, { token: accessToken });
|
|
116
|
+
}
|
|
117
|
+
/** Cached 5 min; a failed refetch serves the cached answer. */
|
|
118
|
+
async getAuthMethods() {
|
|
119
|
+
const cached = this.authMethods;
|
|
120
|
+
if (cached && Date.now() - cached.at < AUTH_METHODS_CACHE_MS)
|
|
121
|
+
return cached.value;
|
|
122
|
+
const params = new URLSearchParams({ appSlug: this.appSlug });
|
|
123
|
+
if (this.tenantSlug)
|
|
124
|
+
params.set('tenantSlug', this.tenantSlug);
|
|
125
|
+
try {
|
|
126
|
+
const value = await this.call('GET', `/auth/methods?${params}`);
|
|
127
|
+
this.authMethods = { value, at: Date.now() };
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
if (!cached)
|
|
132
|
+
throw err;
|
|
133
|
+
this.logger.warn(`bastion: /auth/methods failed (${err.message}), serving the cached answer`);
|
|
134
|
+
return cached.value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// ── 2FA ──────────────────────────────────────────────────────────────────
|
|
138
|
+
twoFactorStatus(accessToken) {
|
|
139
|
+
return this.call('GET', '/auth/2fa/status', undefined, { token: accessToken });
|
|
140
|
+
}
|
|
141
|
+
twoFactorSetup(accessToken, currentPassword) {
|
|
142
|
+
return this.call('POST', '/auth/2fa/setup', currentPassword ? { currentPassword } : {}, { token: accessToken });
|
|
143
|
+
}
|
|
144
|
+
twoFactorConfirm(accessToken, code, currentPassword) {
|
|
145
|
+
return this.call('POST', '/auth/2fa/confirm', { code, ...(currentPassword ? { currentPassword } : {}) }, { token: accessToken });
|
|
146
|
+
}
|
|
147
|
+
twoFactorDisable(accessToken, code, currentPassword) {
|
|
148
|
+
return this.call('POST', '/auth/2fa/disable', { code, ...(currentPassword ? { currentPassword } : {}) }, { token: accessToken });
|
|
149
|
+
}
|
|
150
|
+
// ── social accounts ──────────────────────────────────────────────────────
|
|
151
|
+
listSocialAccounts(accessToken) {
|
|
152
|
+
return this.call('GET', '/auth/me/social-accounts', undefined, { token: accessToken });
|
|
153
|
+
}
|
|
154
|
+
createSocialAccountLinkTicket(accessToken, provider) {
|
|
155
|
+
return this.call('POST', `/auth/me/social-accounts/${encodeURIComponent(provider)}/link-ticket`, undefined, { token: accessToken });
|
|
156
|
+
}
|
|
157
|
+
unlinkSocialAccount(accessToken, provider) {
|
|
158
|
+
return this.call('DELETE', `/auth/me/social-accounts/${encodeURIComponent(provider)}`, undefined, { token: accessToken });
|
|
159
|
+
}
|
|
160
|
+
updateSocialAccountSyncAvatar(accessToken, provider, syncAvatar) {
|
|
161
|
+
return this.call('PATCH', `/auth/me/social-accounts/${encodeURIComponent(provider)}`, { syncAvatar }, { token: accessToken });
|
|
162
|
+
}
|
|
163
|
+
// ── email & password ─────────────────────────────────────────────────────
|
|
164
|
+
resendVerification(email) {
|
|
165
|
+
return this.call('POST', '/auth/resend-verification', { email, ...this.appContext });
|
|
166
|
+
}
|
|
167
|
+
verifyEmail(token) {
|
|
168
|
+
return this.call('GET', `/auth/verify-email?token=${encodeURIComponent(token)}`);
|
|
169
|
+
}
|
|
170
|
+
forgotPassword(email) {
|
|
171
|
+
return this.call('POST', '/auth/forgot-password', { email, ...this.appContext });
|
|
172
|
+
}
|
|
173
|
+
resetPassword(token, newPassword) {
|
|
174
|
+
return this.call('POST', '/auth/reset-password', { token, newPassword });
|
|
175
|
+
}
|
|
176
|
+
changePassword(accessToken, currentPassword, newPassword) {
|
|
177
|
+
return this.call('PATCH', '/auth/me/password', { currentPassword, newPassword }, { token: accessToken });
|
|
178
|
+
}
|
|
179
|
+
requestEmailChange(accessToken, input) {
|
|
180
|
+
return this.call('PATCH', '/auth/me/email', input, { token: accessToken });
|
|
181
|
+
}
|
|
182
|
+
confirmEmailChange(token) {
|
|
183
|
+
return this.call('POST', '/auth/me/confirm-email', { token });
|
|
184
|
+
}
|
|
185
|
+
revokeEmailChange(token) {
|
|
186
|
+
return this.call('POST', '/auth/me/revoke-email-change', { token });
|
|
187
|
+
}
|
|
188
|
+
// ── GDPR ─────────────────────────────────────────────────────────────────
|
|
189
|
+
/** The user's own audit trail (`GET /auth/me/events`), paginated. */
|
|
190
|
+
listMyEvents(accessToken, page = {}) {
|
|
191
|
+
const params = new URLSearchParams();
|
|
192
|
+
if (page.page !== undefined)
|
|
193
|
+
params.set('page', String(page.page));
|
|
194
|
+
if (page.limit !== undefined)
|
|
195
|
+
params.set('limit', String(page.limit));
|
|
196
|
+
const qs = params.toString();
|
|
197
|
+
return this.call('GET', `/auth/me/events${qs ? `?${qs}` : ''}`, undefined, { token: accessToken });
|
|
198
|
+
}
|
|
199
|
+
exportData(accessToken) {
|
|
200
|
+
return this.call('GET', '/auth/me/export', undefined, { token: accessToken });
|
|
201
|
+
}
|
|
202
|
+
/** Step-up: `currentPassword` when the account has one, `twoFactorCode` when 2FA is on. */
|
|
203
|
+
deleteSelf(accessToken, stepUp = {}) {
|
|
204
|
+
return this.call('DELETE', '/auth/me', stepUp, { token: accessToken });
|
|
205
|
+
}
|
|
206
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
207
|
+
latestRefreshToken(refreshToken) {
|
|
208
|
+
let current = refreshToken;
|
|
209
|
+
for (let hop = 0; hop < MAX_ROTATION_HOPS; hop++) {
|
|
210
|
+
const next = this.successors.get(BastionApiClient.hash(current))?.next;
|
|
211
|
+
if (!next)
|
|
212
|
+
break;
|
|
213
|
+
current = next;
|
|
214
|
+
}
|
|
215
|
+
return current;
|
|
216
|
+
}
|
|
217
|
+
prune(now) {
|
|
218
|
+
for (const [key, entry] of this.recent)
|
|
219
|
+
if (now - entry.at >= REFRESH_CACHE_MS)
|
|
220
|
+
this.recent.delete(key);
|
|
221
|
+
for (const [key, entry] of this.successors)
|
|
222
|
+
if (now - entry.at >= REFRESH_CACHE_MS)
|
|
223
|
+
this.successors.delete(key);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
exports.BastionApiClient = BastionApiClient;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bastion's public API contract as seen by an app backend. Field names and
|
|
3
|
+
* nullability mirror Bastion's DTOs; when Bastion changes one, it changes here.
|
|
4
|
+
*/
|
|
5
|
+
export interface BastionTokenPair {
|
|
6
|
+
accessToken: string;
|
|
7
|
+
refreshToken: string;
|
|
8
|
+
/** ISO timestamp — the refresh token is opaque, this is the only way to learn its lifetime. */
|
|
9
|
+
refreshTokenExpiresAt?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface BastionTwoFactorPending {
|
|
12
|
+
requiresTwoFactor: true;
|
|
13
|
+
twoFactorToken: string;
|
|
14
|
+
}
|
|
15
|
+
export type BastionLoginResult = BastionTokenPair | BastionTwoFactorPending;
|
|
16
|
+
export declare function isTwoFactorPending(result: BastionLoginResult): result is BastionTwoFactorPending;
|
|
17
|
+
/** `GET /auth/me`. */
|
|
18
|
+
export interface BastionMe {
|
|
19
|
+
id: string;
|
|
20
|
+
email: string;
|
|
21
|
+
username: string | null;
|
|
22
|
+
image: string | null;
|
|
23
|
+
preferredLocale: string | null;
|
|
24
|
+
emailVerified: boolean;
|
|
25
|
+
hasPassword: boolean;
|
|
26
|
+
role: string | null;
|
|
27
|
+
appSlug: string;
|
|
28
|
+
tenantId: string;
|
|
29
|
+
tenantSlug: string;
|
|
30
|
+
tenantName: string;
|
|
31
|
+
tenantActive: boolean;
|
|
32
|
+
apps: string[];
|
|
33
|
+
ageVerified: boolean;
|
|
34
|
+
ageVerificationRequired: boolean;
|
|
35
|
+
/** `null` = never screened, which is NOT "adult". */
|
|
36
|
+
isMinor: boolean | null;
|
|
37
|
+
}
|
|
38
|
+
export interface BastionSession {
|
|
39
|
+
id: string;
|
|
40
|
+
appSlug: string;
|
|
41
|
+
createdAt: string;
|
|
42
|
+
lastUsedAt: string | null;
|
|
43
|
+
userAgent: string | null;
|
|
44
|
+
ip: string | null;
|
|
45
|
+
isCurrent: boolean;
|
|
46
|
+
}
|
|
47
|
+
export interface BastionRegisterInput {
|
|
48
|
+
email: string;
|
|
49
|
+
password: string;
|
|
50
|
+
username: string;
|
|
51
|
+
preferredLocale?: string;
|
|
52
|
+
/** ISO `YYYY-MM-DD`, required only when the app has `minimumAge`. */
|
|
53
|
+
birthDate?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface BastionAgeVerificationResult {
|
|
56
|
+
ageVerified: true;
|
|
57
|
+
isMinor: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** What `PATCH /auth/me` answers: the updated columns, not the full `BastionMe`. */
|
|
60
|
+
export interface BastionProfile {
|
|
61
|
+
id: string;
|
|
62
|
+
email: string;
|
|
63
|
+
username: string | null;
|
|
64
|
+
image: string | null;
|
|
65
|
+
preferredLocale: string | null;
|
|
66
|
+
emailVerified: boolean;
|
|
67
|
+
}
|
|
68
|
+
export interface BastionProfileUpdate {
|
|
69
|
+
username?: string;
|
|
70
|
+
image?: string | null;
|
|
71
|
+
preferredLocale?: string;
|
|
72
|
+
}
|
|
73
|
+
export interface BastionEmailChangeRequest {
|
|
74
|
+
email: string;
|
|
75
|
+
currentPassword?: string;
|
|
76
|
+
twoFactorCode?: string;
|
|
77
|
+
}
|
|
78
|
+
export interface BastionTwoFactorStatus {
|
|
79
|
+
enabled: boolean;
|
|
80
|
+
}
|
|
81
|
+
export interface BastionTwoFactorSetup {
|
|
82
|
+
qrCodeDataUrl: string;
|
|
83
|
+
secret: string;
|
|
84
|
+
}
|
|
85
|
+
export interface BastionTwoFactorConfirmed {
|
|
86
|
+
recoveryCodes: string[];
|
|
87
|
+
}
|
|
88
|
+
export interface BastionTwoFactorRequest {
|
|
89
|
+
code?: string;
|
|
90
|
+
currentPassword?: string;
|
|
91
|
+
}
|
|
92
|
+
/** The end user behind a call: forwarded as `X-Real-IP` / `User-Agent` so Bastion's rate-limit and audit see the browser, not your container. */
|
|
93
|
+
export interface BastionClientContext {
|
|
94
|
+
ip?: string;
|
|
95
|
+
userAgent?: string;
|
|
96
|
+
}
|
|
97
|
+
export interface BastionSocialAccount {
|
|
98
|
+
provider: string;
|
|
99
|
+
username: string | null;
|
|
100
|
+
email: string | null;
|
|
101
|
+
avatar: string | null;
|
|
102
|
+
syncAvatar: boolean;
|
|
103
|
+
createdAt: string;
|
|
104
|
+
}
|
|
105
|
+
/** `GET /auth/methods`. */
|
|
106
|
+
export interface BastionAuthMethods {
|
|
107
|
+
tenantSlug: string;
|
|
108
|
+
appSlug: string;
|
|
109
|
+
password: {
|
|
110
|
+
login: boolean;
|
|
111
|
+
register: boolean;
|
|
112
|
+
};
|
|
113
|
+
oauth: {
|
|
114
|
+
provider: string;
|
|
115
|
+
authorizeUrl: string;
|
|
116
|
+
}[];
|
|
117
|
+
oauthSignup: boolean;
|
|
118
|
+
}
|
|
119
|
+
export interface BastionLinkTicket {
|
|
120
|
+
url: string;
|
|
121
|
+
}
|
|
122
|
+
/** One row of `GET /auth/me/events` — the user-facing projection, deliberately without `ip`/`userAgent`. */
|
|
123
|
+
export interface BastionUserAuditEvent {
|
|
124
|
+
id: string;
|
|
125
|
+
tenantId: string;
|
|
126
|
+
userId: string | null;
|
|
127
|
+
appSlug: string;
|
|
128
|
+
event: string;
|
|
129
|
+
source: 'BASTION' | 'APP';
|
|
130
|
+
metadata: unknown;
|
|
131
|
+
createdAt: string;
|
|
132
|
+
}
|
|
133
|
+
export interface BastionUserAuditEventsPage {
|
|
134
|
+
data: BastionUserAuditEvent[];
|
|
135
|
+
total: number;
|
|
136
|
+
page: number;
|
|
137
|
+
limit: number;
|
|
138
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Bastion's public API contract as seen by an app backend. Field names and
|
|
4
|
+
* nullability mirror Bastion's DTOs; when Bastion changes one, it changes here.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.isTwoFactorPending = isTwoFactorPending;
|
|
8
|
+
function isTwoFactorPending(result) {
|
|
9
|
+
return result.requiresTwoFactor === true;
|
|
10
|
+
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { AuditEventInput, TokenResponse } from './types.js';
|
|
2
2
|
export interface BastionHttpOptions {
|
|
3
3
|
baseUrl: string;
|
|
4
|
+
/** Per-request timeout, default 8 000 ms. */
|
|
5
|
+
timeoutMs?: number;
|
|
4
6
|
fetch?: typeof fetch;
|
|
5
7
|
}
|
|
8
|
+
export interface BastionRequestInit {
|
|
9
|
+
token?: string;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
}
|
|
6
13
|
export interface ClientAuthInput {
|
|
7
14
|
apiKey: string;
|
|
8
15
|
serviceSlug: string;
|
|
@@ -10,19 +17,17 @@ export interface ClientAuthInput {
|
|
|
10
17
|
}
|
|
11
18
|
/**
|
|
12
19
|
* Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
|
|
13
|
-
* `JwksVerifier`. `call` is public so a service can reach any
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
20
|
+
* `JwksVerifier`. `call` is public so a service can reach any route with the
|
|
21
|
+
* same error handling: a non-2xx answer becomes `BastionHttpError` with the
|
|
22
|
+
* status and a stable `code`; Bastion unreachable or timed out is a 503
|
|
23
|
+
* `UNAVAILABLE`. Pass the browser's IP as `X-Real-IP` on user-facing calls.
|
|
17
24
|
*/
|
|
18
25
|
export declare class BastionHttp {
|
|
19
26
|
private readonly baseUrl;
|
|
27
|
+
private readonly timeoutMs;
|
|
20
28
|
private readonly fetchImpl;
|
|
21
29
|
constructor(options: BastionHttpOptions);
|
|
22
|
-
call<T>(method: string, path: string, body?: unknown, init?:
|
|
23
|
-
token?: string;
|
|
24
|
-
headers?: Record<string, string>;
|
|
25
|
-
}): Promise<T>;
|
|
30
|
+
call<T>(method: string, path: string, body?: unknown, init?: BastionRequestInit): Promise<T>;
|
|
26
31
|
clientAuth(input: ClientAuthInput): Promise<TokenResponse>;
|
|
27
32
|
/** `POST /events`. Bastion stores the event as `<serviceSlug>.<event>`; send the bare `resource.action`. */
|
|
28
33
|
writeAuditEvent(token: string, data: AuditEventInput): Promise<{
|
|
@@ -4,37 +4,49 @@ exports.BastionHttp = void 0;
|
|
|
4
4
|
const errors_js_1 = require("./errors.js");
|
|
5
5
|
/**
|
|
6
6
|
* Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
|
|
7
|
-
* `JwksVerifier`. `call` is public so a service can reach any
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* `JwksVerifier`. `call` is public so a service can reach any route with the
|
|
8
|
+
* same error handling: a non-2xx answer becomes `BastionHttpError` with the
|
|
9
|
+
* status and a stable `code`; Bastion unreachable or timed out is a 503
|
|
10
|
+
* `UNAVAILABLE`. Pass the browser's IP as `X-Real-IP` on user-facing calls.
|
|
11
11
|
*/
|
|
12
12
|
class BastionHttp {
|
|
13
13
|
baseUrl;
|
|
14
|
+
timeoutMs;
|
|
14
15
|
fetchImpl;
|
|
15
16
|
constructor(options) {
|
|
16
17
|
this.baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
17
|
-
this.
|
|
18
|
+
this.timeoutMs = options.timeoutMs ?? 8_000;
|
|
19
|
+
// Late-bound on purpose: a consumer (or its tests) may replace global fetch after construction.
|
|
20
|
+
this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
|
|
18
21
|
}
|
|
19
22
|
async call(method, path, body, init = {}) {
|
|
20
|
-
const headers = {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
};
|
|
23
|
+
const headers = { ...init.headers };
|
|
24
|
+
if (body !== undefined)
|
|
25
|
+
headers['Content-Type'] = 'application/json';
|
|
24
26
|
if (init.token)
|
|
25
27
|
headers['Authorization'] = `Bearer ${init.token}`;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
let res;
|
|
29
|
+
try {
|
|
30
|
+
res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
31
|
+
method,
|
|
32
|
+
headers,
|
|
33
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
34
|
+
signal: AbortSignal.timeout(init.timeoutMs ?? this.timeoutMs),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
throw new errors_js_1.BastionHttpError(503, `Bastion unreachable: ${err.message}`, errors_js_1.BastionErrorCode.UNAVAILABLE);
|
|
39
|
+
}
|
|
31
40
|
if (!res.ok) {
|
|
32
|
-
const
|
|
33
|
-
|
|
41
|
+
const parsed = (await res.json().catch(() => ({})));
|
|
42
|
+
const message = Array.isArray(parsed.message)
|
|
43
|
+
? parsed.message.join(', ')
|
|
44
|
+
: (parsed.message ?? `Bastion error ${res.status}`);
|
|
45
|
+
throw new errors_js_1.BastionHttpError(res.status, message);
|
|
34
46
|
}
|
|
35
47
|
if (res.status === 204)
|
|
36
48
|
return undefined;
|
|
37
|
-
return (await res.json());
|
|
49
|
+
return (await res.json().catch(() => undefined));
|
|
38
50
|
}
|
|
39
51
|
clientAuth(input) {
|
|
40
52
|
return this.call('POST', '/auth/client', input);
|