@heyatom/bastion-client 0.7.2 → 0.9.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 +63 -0
- package/README.md +149 -46
- package/dist/cjs/core/api-client.d.ts +16 -5
- package/dist/cjs/core/api-client.js +23 -5
- package/dist/cjs/core/bastion-http.d.ts +7 -0
- package/dist/cjs/core/bastion-http.js +27 -3
- package/dist/cjs/core/errors.d.ts +19 -2
- package/dist/cjs/core/errors.js +24 -2
- package/dist/cjs/core/jwks-verifier.d.ts +1 -1
- package/dist/cjs/core/jwks-verifier.js +11 -10
- package/dist/cjs/core/jwt.d.ts +13 -0
- package/dist/cjs/core/jwt.js +55 -0
- package/dist/cjs/core/service-token-provider.js +2 -2
- package/dist/cjs/nest/bastion-api-client.provider.d.ts +3 -2
- package/dist/cjs/nest/bastion-api-client.provider.js +3 -2
- package/dist/cjs/nest/bastion-http-exception.d.ts +16 -6
- package/dist/cjs/nest/bastion-http-exception.js +38 -7
- package/dist/cjs/nest/bastion.module.d.ts +5 -3
- package/dist/cjs/nest/bastion.module.js +8 -1
- package/dist/cjs/nest/bastion.service.d.ts +1 -1
- package/dist/cjs/nest/bastion.service.js +7 -2
- package/dist/cjs/nest/decorators/current-user.decorator.d.ts +2 -0
- package/dist/cjs/nest/decorators/current-user.decorator.js +3 -1
- package/dist/cjs/nest/guards/app-user.guard.d.ts +16 -0
- package/dist/cjs/nest/guards/app-user.guard.js +47 -0
- package/dist/cjs/nest/index.d.ts +3 -1
- package/dist/cjs/nest/index.js +6 -1
- package/dist/cjs/nest/options.d.ts +7 -0
- package/dist/cjs/nest/request-context.d.ts +10 -0
- package/dist/cjs/nest/request-context.js +19 -0
- package/dist/cjs/webhooks/index.d.ts +19 -4
- package/dist/cjs/webhooks/index.js +48 -11
- package/dist/esm/core/api-client.d.ts +16 -5
- package/dist/esm/core/api-client.js +24 -6
- package/dist/esm/core/bastion-http.d.ts +7 -0
- package/dist/esm/core/bastion-http.js +28 -4
- package/dist/esm/core/errors.d.ts +19 -2
- package/dist/esm/core/errors.js +23 -2
- package/dist/esm/core/jwks-verifier.d.ts +1 -1
- package/dist/esm/core/jwks-verifier.js +11 -10
- package/dist/esm/core/jwt.d.ts +13 -0
- package/dist/esm/core/jwt.js +50 -0
- package/dist/esm/core/service-token-provider.js +2 -2
- package/dist/esm/nest/bastion-api-client.provider.d.ts +3 -2
- package/dist/esm/nest/bastion-api-client.provider.js +3 -2
- package/dist/esm/nest/bastion-http-exception.d.ts +16 -6
- package/dist/esm/nest/bastion-http-exception.js +40 -9
- package/dist/esm/nest/bastion.module.d.ts +5 -3
- package/dist/esm/nest/bastion.module.js +9 -2
- package/dist/esm/nest/bastion.service.d.ts +1 -1
- package/dist/esm/nest/bastion.service.js +7 -2
- package/dist/esm/nest/decorators/current-user.decorator.d.ts +2 -0
- package/dist/esm/nest/decorators/current-user.decorator.js +2 -0
- package/dist/esm/nest/guards/app-user.guard.d.ts +16 -0
- package/dist/esm/nest/guards/app-user.guard.js +44 -0
- package/dist/esm/nest/index.d.ts +3 -1
- package/dist/esm/nest/index.js +3 -1
- package/dist/esm/nest/options.d.ts +7 -0
- package/dist/esm/nest/request-context.d.ts +10 -0
- package/dist/esm/nest/request-context.js +15 -0
- package/dist/esm/webhooks/index.d.ts +19 -4
- package/dist/esm/webhooks/index.js +47 -10
- package/package.json +3 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `verifyWebhookSignature` verifies [Standard Webhooks](https://www.standardwebhooks.com/) headers (`webhook-id`, `webhook-timestamp`, `webhook-signature`: space-separated `v1,<base64 HMAC-SHA256("<id>.<timestamp>.<body>")>`, secret used as UTF-8). When any of them is present they are the ones checked: same tolerance, any secret against any `v1` entry, `deliveryId` = `webhook-id`, result `version: 'standard'`.
|
|
8
|
+
- Header name constants `WEBHOOK_ID_HEADER`, `WEBHOOK_TIMESTAMP_HEADER`, `WEBHOOK_SIGNATURE_HEADER`.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Without Standard Webhooks headers, `X-Bastion-Signature-V2` is verified exactly as before, so receivers keep working against an older Bastion. Bastion sends both for one release and will then drop V2. `allowLegacySignature` is unchanged.
|
|
13
|
+
- Whitespace-only entries in `secrets` (e.g. `' '` from an unset env var) are ignored instead of keying the HMAC; if none is left the result is `{ ok: false, reason: 'missing' }`. Other secrets are still used byte-exact (not trimmed).
|
|
14
|
+
|
|
15
|
+
### Breaking (types)
|
|
16
|
+
|
|
17
|
+
- `VerifyWebhookResult` gains the `version: 'standard'` variant: an exhaustive `switch` on `version` needs the new case.
|
|
18
|
+
|
|
19
|
+
## 0.8.1
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Error code `STEP_UP_FAILED`: the 401 Bastion answers when the current password or 2FA code of a step-up protected route is wrong (or the step-up counter is locked). Tells a wrong factor apart from a dead session, also a 401 (`INVALID_CREDENTIALS`). Needs a Bastion that sends `code: 'STEP_UP_FAILED'`; an older one still classifies as `INVALID_CREDENTIALS`.
|
|
24
|
+
|
|
25
|
+
## 0.8.0
|
|
26
|
+
|
|
27
|
+
### Breaking
|
|
28
|
+
|
|
29
|
+
- `BastionApiClient.exchange(code, ctx, state)` requires `state`; an empty one rejects before calling Bastion. Get it from the new `oauthStart`.
|
|
30
|
+
- `jose` is no longer a peer dependency: token verification runs on `node:crypto`. The CJS build no longer loads an ESM-only module, so Jest needs no `transformIgnorePatterns`.
|
|
31
|
+
- `BastionHttpException` no longer forwards Bastion's 5xx: it answers 502 `UPSTREAM` (503 `UNAVAILABLE` when Bastion is down) with a generic message. Bastion's status and message stay on `upstreamStatus`, `upstreamMessage` and `message`.
|
|
32
|
+
- Tokens whose segments are not strict base64url (padding, stray characters) are rejected as `malformed`.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- `BastionApiClient.oauthStart(provider)` → `{ url, state } | null`.
|
|
37
|
+
- `AppUserGuard` and `@CurrentUser()` for an app's own users.
|
|
38
|
+
- `X-Request-Id` on every call to Bastion: `BastionHttpOptions.requestId`; in NestJS the served request's `req.id` by default (`BastionModuleOptions.requestId` to override).
|
|
39
|
+
- `BastionHttpError.errors` (field errors of a 400) and `upstreamCode` (a code outside `BastionErrorCode`).
|
|
40
|
+
- Error codes `VALIDATION_FAILED` and `RATE_LIMITED`.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- Validation errors from Bastion were reduced to "Validation failed": the field errors and Bastion's `code` are now kept.
|
|
45
|
+
- A 4xx with a code the package does not know answered `UPSTREAM`: it now keeps Bastion's code or the status default (`NOT_FOUND`, `UNPROCESSABLE`, …).
|
|
46
|
+
|
|
47
|
+
## 0.7.2
|
|
48
|
+
|
|
49
|
+
- `completeAgeVerification(token, birthDate, { currentPassword?, twoFactorCode? })` sends the required step-up.
|
|
50
|
+
- `UserJwtPayload.ageVerificationRequired` while the account owes the app's age screen.
|
|
51
|
+
|
|
52
|
+
## 0.7.1
|
|
53
|
+
|
|
54
|
+
- `exchange` sends the OAuth flow `state`.
|
|
55
|
+
|
|
56
|
+
## 0.7.0
|
|
57
|
+
|
|
58
|
+
- Step-up on `completeSocialAccountLink`.
|
|
59
|
+
- **Breaking**: webhooks with only the legacy `X-Bastion-Signature` are rejected unless `allowLegacySignature` is set.
|
|
60
|
+
|
|
61
|
+
## 0.6.0
|
|
62
|
+
|
|
63
|
+
- `BastionSocialAccount.providerId`.
|
package/README.md
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
# @heyatom/bastion-client
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Client SDK for [Bastion](https://github.com/heyatomdev/bastion), the HeyAtom identity provider.
|
|
4
|
+
It verifies Bastion tokens locally against the JWKS, calls Bastion's API (machine-to-machine and
|
|
5
|
+
on behalf of your users), verifies Bastion webhooks, and ships ready-made NestJS guards.
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @heyatom/bastion-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- Node ≥ 20.19. CJS and ESM builds, no ESM-only runtime dependency (works in Jest as is).
|
|
12
|
+
- NestJS adapter: peer dependencies `@nestjs/common` and `@nestjs/core` 11, `rxjs`, `reflect-metadata`.
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
| Import | What |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `@heyatom/bastion-client` | Framework-free core: `JwksVerifier`, `BastionHttp`, `ServiceTokenProvider`, `BastionApiClient`, types, error codes |
|
|
17
|
+
| `@heyatom/bastion-client/nest` | `BastionModule`, guards, decorators, audit (re-exports the core) |
|
|
18
|
+
| `@heyatom/bastion-client/webhooks` | `verifyWebhookSignature` |
|
|
12
19
|
|
|
13
20
|
## NestJS
|
|
14
21
|
|
|
15
22
|
```ts
|
|
16
|
-
// app.module.ts
|
|
17
23
|
import { APP_GUARD } from '@nestjs/core';
|
|
18
24
|
import { BastionModule, ServiceClientJwtGuard } from '@heyatom/bastion-client/nest';
|
|
19
25
|
|
|
@@ -23,9 +29,8 @@ import { BastionModule, ServiceClientJwtGuard } from '@heyatom/bastion-client/ne
|
|
|
23
29
|
inject: [ConfigService],
|
|
24
30
|
useFactory: (config: ConfigService) => ({
|
|
25
31
|
baseUrl: config.getOrThrow('BASTION_URL'),
|
|
26
|
-
serviceSlug: config.getOrThrow('BASTION_APP_SLUG'),
|
|
27
|
-
apiKey: config.get('BASTION_CLIENT_API_KEY'),
|
|
28
|
-
// tenantSlug, jwksTtlMs, issuer, acceptedAppSlugs, acceptedRoles — optional
|
|
32
|
+
serviceSlug: config.getOrThrow('BASTION_APP_SLUG'), // this service's slug in Bastion
|
|
33
|
+
apiKey: config.get('BASTION_CLIENT_API_KEY'), // needed for outbound calls and audit
|
|
29
34
|
}),
|
|
30
35
|
}),
|
|
31
36
|
],
|
|
@@ -34,6 +39,31 @@ import { BastionModule, ServiceClientJwtGuard } from '@heyatom/bastion-client/ne
|
|
|
34
39
|
export class AppModule {}
|
|
35
40
|
```
|
|
36
41
|
|
|
42
|
+
`BastionModule` is global. Options:
|
|
43
|
+
|
|
44
|
+
| Option | Default | |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `baseUrl` | — | Bastion base URL |
|
|
47
|
+
| `serviceSlug` | — | Your app/service slug |
|
|
48
|
+
| `apiKey` | — | Service client key, for outbound calls |
|
|
49
|
+
| `tenantSlug` | — | Required when the app lives in more than one tenant |
|
|
50
|
+
| `timeoutMs` | `8000` | Outbound call timeout |
|
|
51
|
+
| `jwksTtlMs` | `300000` | JWKS cache lifetime |
|
|
52
|
+
| `issuer` | `'bastion'` | Expected `iss`; `null` skips the check |
|
|
53
|
+
| `acceptedAppSlugs` / `acceptedRoles` | `['meridian']` / `['ADMIN', 'OWNER', 'SUPER_ADMIN']` | Who `BastionUserGuard` lets in |
|
|
54
|
+
| `requestId` | the served request's `req.id` | Sent as `X-Request-Id` to Bastion. Express only by default; on Fastify pass a getter |
|
|
55
|
+
|
|
56
|
+
### Guards
|
|
57
|
+
|
|
58
|
+
| Guard | Accepts | Payload on |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `ServiceClientJwtGuard` | Machine tokens issued for this service; `@RequireScope('mail.send')` per route | `req.user`, `@CurrentClient()` |
|
|
61
|
+
| `AppUserGuard` | Your app's own users (rejects machine tokens and other apps' tokens) | `req.user`, `@CurrentUser()` |
|
|
62
|
+
| `BastionUserGuard` | Admin-console users with an accepted app and role | `req.adminUser`, `@CurrentAdminUser()` |
|
|
63
|
+
|
|
64
|
+
All of them skip routes marked `@Public()`. Use one as the global `APP_GUARD` and the others with
|
|
65
|
+
`@UseGuards()`. Suspension, age gate or custom roles are yours: add a guard after `AppUserGuard`.
|
|
66
|
+
|
|
37
67
|
```ts
|
|
38
68
|
@Controller('mail')
|
|
39
69
|
export class MailController {
|
|
@@ -42,79 +72,152 @@ export class MailController {
|
|
|
42
72
|
@Post('send')
|
|
43
73
|
@RequireScope('mail.send')
|
|
44
74
|
send(@CurrentClient() client: ServiceClientJwtPayload, @Body() dto: SendDto) {
|
|
45
|
-
//
|
|
46
|
-
void this.audit.write('mail.sent', { metadata: { toDomain: 'example.com' } });
|
|
75
|
+
void this.audit.write('mail.sent', { metadata: { toDomain: 'example.com' } }); // never throws
|
|
47
76
|
}
|
|
48
77
|
|
|
49
|
-
@Get('/health')
|
|
50
78
|
@Public()
|
|
79
|
+
@Get('health')
|
|
51
80
|
health() { return { status: 'ok' }; }
|
|
52
81
|
}
|
|
53
82
|
|
|
54
|
-
// Admin surface: user tokens from the console
|
|
55
83
|
@Controller('admin/config')
|
|
56
84
|
@UseGuards(BastionUserGuard)
|
|
57
85
|
@UseInterceptors(AuditInterceptor)
|
|
58
86
|
export class AdminController {
|
|
59
87
|
@Patch()
|
|
60
|
-
@Audit('config.updated'
|
|
88
|
+
@Audit('config.updated')
|
|
61
89
|
update(@CurrentAdminUser() admin: UserJwtPayload, @Body() dto: UpdateDto) { /* ... */ }
|
|
62
90
|
}
|
|
63
91
|
```
|
|
64
92
|
|
|
65
|
-
|
|
93
|
+
Audit events are stored by Bastion as `<serviceSlug>.<event>`: send `resource.action`.
|
|
66
94
|
|
|
67
|
-
|
|
95
|
+
### Backend for your users (BFF)
|
|
68
96
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// own guard on it — cookie extraction, suspension, age gate are the app's business.
|
|
73
|
-
// Core equivalent: assertUserTokenFor(await verifier.verify(token), 'dbd-builds').
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
## App backends (a BFF for your users)
|
|
97
|
+
`BastionApiClientProvider` is `BastionApiClient` bound to your `serviceSlug` and `tenantSlug`:
|
|
98
|
+
login, register, OAuth, 2FA, refresh, logout, sessions, profile, social accounts, email and
|
|
99
|
+
password flows, data export, account deletion.
|
|
77
100
|
|
|
78
101
|
```ts
|
|
79
|
-
import { BastionApiClientProvider, BastionHttpException } from '@heyatom/bastion-client/nest';
|
|
80
|
-
|
|
81
102
|
@Controller('auth')
|
|
82
103
|
export class AuthController {
|
|
83
104
|
constructor(private readonly bastion: BastionApiClientProvider) {}
|
|
84
105
|
|
|
106
|
+
@Public()
|
|
85
107
|
@Post('login')
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
108
|
+
login(@Body() dto: LoginDto, @Req() req: Request) {
|
|
109
|
+
// Pass the browser's IP and user agent: Bastion rate-limits per IP.
|
|
110
|
+
return this.bastion.login(dto.email, dto.password, { ip: req.ip, userAgent: req.headers['user-agent'] });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@Public()
|
|
114
|
+
@Get('oauth/:provider')
|
|
115
|
+
async oauthStart(@Param('provider') provider: string, @Res() res: Response) {
|
|
116
|
+
const start = await this.bastion.oauthStart(provider);
|
|
117
|
+
if (!start) throw new NotFoundException('Unknown OAuth provider');
|
|
118
|
+
res.cookie('oauthState', start.state, { httpOnly: true, secure: true, sameSite: 'lax', path: '/auth/callback', maxAge: 600_000 });
|
|
119
|
+
res.redirect(start.url);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@Public()
|
|
123
|
+
@Get('callback')
|
|
124
|
+
async callback(@Query('code') code: string, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
|
125
|
+
const state = req.cookies.oauthState; // from your cookie, never from the query string
|
|
126
|
+
res.clearCookie('oauthState', { path: '/auth/callback' });
|
|
127
|
+
if (!code || !state) throw new UnauthorizedException();
|
|
128
|
+
return this.bastion.exchange(code, { ip: req.ip, userAgent: req.headers['user-agent'] }, state);
|
|
93
129
|
}
|
|
94
130
|
}
|
|
95
131
|
```
|
|
96
132
|
|
|
133
|
+
- `exchange` requires the `state` returned by `oauthStart`: it is what stops login CSRF.
|
|
134
|
+
- `refresh` is single-flight per token and remembers each answer for `refreshMemoryMs` (60 s):
|
|
135
|
+
Bastion rotates refresh tokens and revokes the whole family on reuse, so concurrent refreshes
|
|
136
|
+
from one browser must collapse into one call. Use one client instance per process.
|
|
137
|
+
- `logout` revokes the latest token in the rotation chain and never throws.
|
|
138
|
+
|
|
139
|
+
## Errors
|
|
140
|
+
|
|
141
|
+
Every failed call throws `BastionHttpError` (core) or `BastionHttpException` (Nest) with a stable
|
|
142
|
+
`code` (`BastionErrorCode`), so you never match Bastion's message text:
|
|
143
|
+
|
|
144
|
+
`INVALID_CREDENTIALS`, `EMAIL_NOT_VERIFIED`, `NO_APP_ACCESS`, `ACCOUNT_INACTIVE`,
|
|
145
|
+
`INVALID_APP_CONTEXT` (your configuration, not the user), `INVALID_REFRESH_TOKEN`,
|
|
146
|
+
`INVALID_OAUTH_CODE`, `VALIDATION_FAILED`, `STEP_UP_FAILED` (wrong current password or 2FA
|
|
147
|
+
code on a step-up route: the session is fine), `CONFLICT`, `RATE_LIMITED`, the age-gate codes
|
|
148
|
+
(`BIRTH_DATE_REQUIRED`, `BIRTH_DATE_INVALID`, `UNDER_MINIMUM_AGE`), the OAuth flow codes
|
|
149
|
+
(`OAUTH_*`), `UNAVAILABLE` (Bastion unreachable or timed out) and `UPSTREAM`.
|
|
150
|
+
|
|
151
|
+
- A 400 carries the field errors in `errors: string[]`. Show them: `message` is only
|
|
152
|
+
"Validation failed".
|
|
153
|
+
- A code outside `BastionErrorCode` (e.g. a password-policy code) is on `upstreamCode`.
|
|
154
|
+
|
|
155
|
+
`BastionHttpException` is safe to let through to the client:
|
|
156
|
+
|
|
157
|
+
- a 4xx keeps Bastion's status and answers `{ code, message, errors? }`;
|
|
158
|
+
- a 5xx becomes 502 `UPSTREAM`, or 503 `UNAVAILABLE` when Bastion is down, with a generic message.
|
|
159
|
+
Bastion's own status and message stay on `upstreamStatus` / `upstreamMessage` and in
|
|
160
|
+
`message`, for your logs.
|
|
161
|
+
|
|
97
162
|
```ts
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (
|
|
163
|
+
try {
|
|
164
|
+
return await this.bastion.login(email, password, ctx);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (err instanceof BastionHttpException && err.code === 'EMAIL_NOT_VERIFIED') { /* ... */ }
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
102
169
|
```
|
|
103
170
|
|
|
104
171
|
## Without NestJS
|
|
105
172
|
|
|
106
173
|
```ts
|
|
107
|
-
import { JwksVerifier, BastionHttp, ServiceTokenProvider } from '@heyatom/bastion-client';
|
|
174
|
+
import { JwksVerifier, BastionHttp, ServiceTokenProvider, BastionApiClient, assertUserTokenFor } from '@heyatom/bastion-client';
|
|
108
175
|
|
|
176
|
+
// Verify incoming tokens (throws BastionTokenError)
|
|
109
177
|
const verifier = new JwksVerifier({ baseUrl: process.env.BASTION_URL! });
|
|
110
|
-
const payload = await verifier.verify(bearerToken);
|
|
111
|
-
|
|
112
|
-
|
|
178
|
+
const payload = await verifier.verify(bearerToken);
|
|
179
|
+
const user = assertUserTokenFor(payload, 'my-app'); // only your app's user tokens
|
|
180
|
+
|
|
181
|
+
// Call Bastion
|
|
182
|
+
const http = new BastionHttp({
|
|
183
|
+
baseUrl: process.env.BASTION_URL!,
|
|
184
|
+
requestId: () => currentRequestIdFromYourFramework(), // optional, sent as X-Request-Id
|
|
185
|
+
});
|
|
113
186
|
const tokens = new ServiceTokenProvider(http, { apiKey, serviceSlug: 'my-service' });
|
|
114
|
-
// several targets: a record keyed by serviceSlug, or { defaultSlug, resolve: (slug) => credential | null }
|
|
115
187
|
await http.writeAuditEvent(await tokens.getToken(), { event: 'thing.done' });
|
|
188
|
+
|
|
189
|
+
const api = new BastionApiClient({ http, appSlug: 'my-app' });
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`ServiceTokenProvider` also takes several credentials, as a record keyed by `serviceSlug` or as
|
|
193
|
+
`{ defaultSlug, resolve: (slug) => credential | null }`.
|
|
194
|
+
|
|
195
|
+
## Webhooks
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import { verifyWebhookSignature } from '@heyatom/bastion-client/webhooks';
|
|
199
|
+
|
|
200
|
+
// Needs the raw body: NestFactory.create(AppModule, { rawBody: true })
|
|
201
|
+
const result = verifyWebhookSignature({
|
|
202
|
+
rawBody: req.rawBody,
|
|
203
|
+
headers: req.headers,
|
|
204
|
+
secrets: [secret, previousSecret], // both accepted during a rotation
|
|
205
|
+
});
|
|
206
|
+
if (!result.ok) throw new UnauthorizedException(result.reason);
|
|
207
|
+
// result.deliveryId: store it and ignore repeats
|
|
116
208
|
```
|
|
117
209
|
|
|
118
|
-
|
|
210
|
+
Checks [Standard Webhooks](https://www.standardwebhooks.com/) headers (`webhook-id`,
|
|
211
|
+
`webhook-timestamp`, `webhook-signature`) when present: HMAC-SHA256 over id + timestamp + body,
|
|
212
|
+
5 min tolerance, secret used as a UTF-8 string, `result.deliveryId` = `webhook-id`. Without them
|
|
213
|
+
it falls back to `X-Bastion-Signature-V2` (same guarantees), so it works against an older Bastion
|
|
214
|
+
too; Bastion will stop sending V2 in a later release. `result.version` says which one matched.
|
|
215
|
+
Leave `allowLegacySignature` off.
|
|
216
|
+
|
|
217
|
+
## Changelog
|
|
218
|
+
|
|
219
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
|
220
|
+
|
|
221
|
+
## License
|
|
119
222
|
|
|
120
|
-
|
|
223
|
+
MIT
|
|
@@ -51,12 +51,23 @@ export declare class BastionApiClient {
|
|
|
51
51
|
message: string;
|
|
52
52
|
}>;
|
|
53
53
|
/**
|
|
54
|
-
*
|
|
55
|
-
* on
|
|
56
|
-
*
|
|
57
|
-
*
|
|
54
|
+
* Starts an OAuth login: the provider's Bastion initiate URL with a fresh
|
|
55
|
+
* `state` on it, or `null` for a provider the app doesn't offer. Keep `state`
|
|
56
|
+
* in an httpOnly `SameSite=Lax` cookie scoped to the callback path, redirect
|
|
57
|
+
* the browser to `url`, then pass the cookie's value to `exchange`.
|
|
58
58
|
*/
|
|
59
|
-
|
|
59
|
+
oauthStart(provider: string): Promise<{
|
|
60
|
+
url: string;
|
|
61
|
+
state: string;
|
|
62
|
+
} | null>;
|
|
63
|
+
/**
|
|
64
|
+
* Trades the one-time OAuth code for tokens. `state` is the value from
|
|
65
|
+
* `oauthStart`, read back from the app's own cookie (never from the callback
|
|
66
|
+
* query): Bastion refuses a code minted under a different one, which is what
|
|
67
|
+
* stops an attacker's code from being redeemed in a victim's browser (login CSRF).
|
|
68
|
+
* Required: an empty `state` throws before calling Bastion.
|
|
69
|
+
*/
|
|
70
|
+
exchange(code: string, ctx: BastionClientContext | undefined, state: string): Promise<BastionLoginResult>;
|
|
60
71
|
completeTwoFactor(twoFactorToken: string, code: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
|
|
61
72
|
refresh(refreshToken: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
|
|
62
73
|
/** Revokes the token and any successor issued through `refresh` within the memory window. Never throws. */
|
|
@@ -62,13 +62,31 @@ class BastionApiClient {
|
|
|
62
62
|
return this.call('POST', '/auth/register', { ...input, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
63
63
|
}
|
|
64
64
|
/**
|
|
65
|
-
*
|
|
66
|
-
* on
|
|
67
|
-
*
|
|
68
|
-
*
|
|
65
|
+
* Starts an OAuth login: the provider's Bastion initiate URL with a fresh
|
|
66
|
+
* `state` on it, or `null` for a provider the app doesn't offer. Keep `state`
|
|
67
|
+
* in an httpOnly `SameSite=Lax` cookie scoped to the callback path, redirect
|
|
68
|
+
* the browser to `url`, then pass the cookie's value to `exchange`.
|
|
69
|
+
*/
|
|
70
|
+
async oauthStart(provider) {
|
|
71
|
+
const method = (await this.getAuthMethods()).oauth.find((m) => m.provider === provider);
|
|
72
|
+
if (!method)
|
|
73
|
+
return null;
|
|
74
|
+
const state = (0, node_crypto_1.randomBytes)(32).toString('base64url');
|
|
75
|
+
const url = new URL(method.authorizeUrl);
|
|
76
|
+
url.searchParams.set('state', state);
|
|
77
|
+
return { url: url.toString(), state };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Trades the one-time OAuth code for tokens. `state` is the value from
|
|
81
|
+
* `oauthStart`, read back from the app's own cookie (never from the callback
|
|
82
|
+
* query): Bastion refuses a code minted under a different one, which is what
|
|
83
|
+
* stops an attacker's code from being redeemed in a victim's browser (login CSRF).
|
|
84
|
+
* Required: an empty `state` throws before calling Bastion.
|
|
69
85
|
*/
|
|
70
86
|
exchange(code, ctx, state) {
|
|
71
|
-
|
|
87
|
+
if (!state)
|
|
88
|
+
return Promise.reject(new Error('BastionApiClient.exchange: state is required (see oauthStart)'));
|
|
89
|
+
return this.call('POST', '/auth/exchange', { code, appSlug: this.appSlug, state }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
72
90
|
}
|
|
73
91
|
completeTwoFactor(twoFactorToken, code, ctx) {
|
|
74
92
|
return this.call('POST', '/auth/2fa/authenticate', { twoFactorToken, code }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
@@ -4,6 +4,12 @@ export interface BastionHttpOptions {
|
|
|
4
4
|
/** Per-request timeout, default 8 000 ms. */
|
|
5
5
|
timeoutMs?: number;
|
|
6
6
|
fetch?: typeof fetch;
|
|
7
|
+
/**
|
|
8
|
+
* The id of the request being served, sent as `X-Request-Id` on every call
|
|
9
|
+
* (api.md §7) so Bastion logs it as `clientRequestId`. Read per call: back it
|
|
10
|
+
* with the framework's request context. A value outside `[A-Za-z0-9._-]{1,64}` is not sent.
|
|
11
|
+
*/
|
|
12
|
+
requestId?: () => string | undefined;
|
|
7
13
|
}
|
|
8
14
|
export interface BastionRequestInit {
|
|
9
15
|
token?: string;
|
|
@@ -26,6 +32,7 @@ export declare class BastionHttp {
|
|
|
26
32
|
private readonly baseUrl;
|
|
27
33
|
private readonly timeoutMs;
|
|
28
34
|
private readonly fetchImpl;
|
|
35
|
+
private readonly requestId?;
|
|
29
36
|
constructor(options: BastionHttpOptions);
|
|
30
37
|
call<T>(method: string, path: string, body?: unknown, init?: BastionRequestInit): Promise<T>;
|
|
31
38
|
clientAuth(input: ClientAuthInput): Promise<TokenResponse>;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.BastionHttp = void 0;
|
|
4
4
|
const errors_js_1 = require("./errors.js");
|
|
5
|
+
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
5
6
|
/**
|
|
6
7
|
* Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
|
|
7
8
|
* `JwksVerifier`. `call` is public so a service can reach any route with the
|
|
@@ -13,11 +14,13 @@ class BastionHttp {
|
|
|
13
14
|
baseUrl;
|
|
14
15
|
timeoutMs;
|
|
15
16
|
fetchImpl;
|
|
17
|
+
requestId;
|
|
16
18
|
constructor(options) {
|
|
17
19
|
this.baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
18
20
|
this.timeoutMs = options.timeoutMs ?? 8_000;
|
|
19
21
|
// Late-bound on purpose: a consumer (or its tests) may replace global fetch after construction.
|
|
20
22
|
this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
|
|
23
|
+
this.requestId = options.requestId;
|
|
21
24
|
}
|
|
22
25
|
async call(method, path, body, init = {}) {
|
|
23
26
|
const headers = { ...init.headers };
|
|
@@ -25,6 +28,10 @@ class BastionHttp {
|
|
|
25
28
|
headers['Content-Type'] = 'application/json';
|
|
26
29
|
if (init.token)
|
|
27
30
|
headers['Authorization'] = `Bearer ${init.token}`;
|
|
31
|
+
const requestId = this.requestId?.();
|
|
32
|
+
const callerSetId = Object.keys(headers).some((k) => k.toLowerCase() === 'x-request-id');
|
|
33
|
+
if (requestId && REQUEST_ID_PATTERN.test(requestId) && !callerSetId)
|
|
34
|
+
headers['X-Request-Id'] = requestId;
|
|
28
35
|
let res;
|
|
29
36
|
try {
|
|
30
37
|
res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
@@ -38,11 +45,28 @@ class BastionHttp {
|
|
|
38
45
|
throw new errors_js_1.BastionHttpError(503, `Bastion unreachable: ${err.message}`, errors_js_1.BastionErrorCode.UNAVAILABLE);
|
|
39
46
|
}
|
|
40
47
|
if (!res.ok) {
|
|
41
|
-
|
|
48
|
+
// Bastion's error body (api.md §3): `{ code, message, errors? }`; older builds put the field errors in `message`.
|
|
49
|
+
const parsed = ((await res.json().catch(() => null)) ?? {});
|
|
50
|
+
const errors = Array.isArray(parsed.errors)
|
|
51
|
+
? parsed.errors.map(String)
|
|
52
|
+
: Array.isArray(parsed.message)
|
|
53
|
+
? parsed.message.map(String)
|
|
54
|
+
: undefined;
|
|
42
55
|
const message = Array.isArray(parsed.message)
|
|
43
56
|
? parsed.message.join(', ')
|
|
44
|
-
:
|
|
45
|
-
|
|
57
|
+
: typeof parsed.message === 'string'
|
|
58
|
+
? parsed.message
|
|
59
|
+
: `Bastion error ${res.status}`;
|
|
60
|
+
// A code Bastion still puts in `message` (UNDER_MINIMUM_AGE under `code: FORBIDDEN`) wins: classify reads it.
|
|
61
|
+
const code = (0, errors_js_1.isBastionErrorCode)(message)
|
|
62
|
+
? undefined
|
|
63
|
+
: (0, errors_js_1.isBastionErrorCode)(parsed.code)
|
|
64
|
+
? parsed.code
|
|
65
|
+
: res.status === 400 && errors?.length
|
|
66
|
+
? errors_js_1.BastionErrorCode.VALIDATION_FAILED
|
|
67
|
+
: undefined;
|
|
68
|
+
const upstreamCode = typeof parsed.code === 'string' && !(0, errors_js_1.isBastionErrorCode)(parsed.code) ? parsed.code : undefined;
|
|
69
|
+
throw new errors_js_1.BastionHttpError(res.status, message, code, errors, upstreamCode);
|
|
46
70
|
}
|
|
47
71
|
if (res.status === 204)
|
|
48
72
|
return undefined;
|
|
@@ -21,7 +21,12 @@ export declare const BastionErrorCode: {
|
|
|
21
21
|
readonly INVALID_REFRESH_TOKEN: "INVALID_REFRESH_TOKEN";
|
|
22
22
|
/** `POST /auth/exchange` refused the one-time OAuth code: expired, already used, or minted for another app. */
|
|
23
23
|
readonly INVALID_OAUTH_CODE: "INVALID_OAUTH_CODE";
|
|
24
|
+
/** A 400 carrying field errors (`BastionHttpError.errors`): Bastion's `ValidationPipe`. */
|
|
25
|
+
readonly VALIDATION_FAILED: "VALIDATION_FAILED";
|
|
26
|
+
/** A 401 from a step-up protected route (password change, email change, 2FA setup/confirm/disable, account delete): the current password or 2FA code is wrong, or the step-up counter is locked. The session is fine — do not log the user out. Bastion sends it as `code`; an older Bastion sends a plain 401, classified `INVALID_CREDENTIALS`. */
|
|
27
|
+
readonly STEP_UP_FAILED: "STEP_UP_FAILED";
|
|
24
28
|
readonly CONFLICT: "CONFLICT";
|
|
29
|
+
readonly RATE_LIMITED: "RATE_LIMITED";
|
|
25
30
|
readonly BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED";
|
|
26
31
|
readonly BIRTH_DATE_INVALID: "BIRTH_DATE_INVALID";
|
|
27
32
|
readonly UNDER_MINIMUM_AGE: "UNDER_MINIMUM_AGE";
|
|
@@ -36,9 +41,21 @@ export declare const BastionErrorCode: {
|
|
|
36
41
|
};
|
|
37
42
|
export type BastionErrorCode = (typeof BastionErrorCode)[keyof typeof BastionErrorCode];
|
|
38
43
|
export declare function classifyBastionError(status: number, message: string): BastionErrorCode;
|
|
39
|
-
|
|
44
|
+
export declare function isBastionErrorCode(value: unknown): value is BastionErrorCode;
|
|
45
|
+
/**
|
|
46
|
+
* A non-2xx answer from Bastion's HTTP API, or Bastion unreachable (`status` 503, code `UNAVAILABLE`).
|
|
47
|
+
* `message` and `errors` are Bastion's own: fine to show for a 4xx, never for a 5xx (api.md §14).
|
|
48
|
+
*/
|
|
40
49
|
export declare class BastionHttpError extends Error {
|
|
41
50
|
readonly status: number;
|
|
51
|
+
/** Field errors of a 400 (`VALIDATION_FAILED`), one line each. */
|
|
52
|
+
readonly errors?: string[] | undefined;
|
|
53
|
+
/** Bastion's own `code` when it is not a `BastionErrorCode` (`NOT_FOUND`, `PASSWORD_TOO_SHORT`, …). */
|
|
54
|
+
readonly upstreamCode?: string | undefined;
|
|
42
55
|
readonly code: BastionErrorCode;
|
|
43
|
-
constructor(status: number, message: string, code?: BastionErrorCode
|
|
56
|
+
constructor(status: number, message: string, code?: BastionErrorCode,
|
|
57
|
+
/** Field errors of a 400 (`VALIDATION_FAILED`), one line each. */
|
|
58
|
+
errors?: string[] | undefined,
|
|
59
|
+
/** Bastion's own `code` when it is not a `BastionErrorCode` (`NOT_FOUND`, `PASSWORD_TOO_SHORT`, …). */
|
|
60
|
+
upstreamCode?: string | undefined);
|
|
44
61
|
}
|
package/dist/cjs/core/errors.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.BastionHttpError = exports.BastionErrorCode = exports.BastionTokenError = void 0;
|
|
4
4
|
exports.classifyBastionError = classifyBastionError;
|
|
5
|
+
exports.isBastionErrorCode = isBastionErrorCode;
|
|
5
6
|
/** Verification failure. `code` is safe to log; the message is safe to return to the caller. */
|
|
6
7
|
class BastionTokenError extends Error {
|
|
7
8
|
code;
|
|
@@ -29,7 +30,12 @@ exports.BastionErrorCode = {
|
|
|
29
30
|
INVALID_REFRESH_TOKEN: 'INVALID_REFRESH_TOKEN',
|
|
30
31
|
/** `POST /auth/exchange` refused the one-time OAuth code: expired, already used, or minted for another app. */
|
|
31
32
|
INVALID_OAUTH_CODE: 'INVALID_OAUTH_CODE',
|
|
33
|
+
/** A 400 carrying field errors (`BastionHttpError.errors`): Bastion's `ValidationPipe`. */
|
|
34
|
+
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
|
35
|
+
/** A 401 from a step-up protected route (password change, email change, 2FA setup/confirm/disable, account delete): the current password or 2FA code is wrong, or the step-up counter is locked. The session is fine — do not log the user out. Bastion sends it as `code`; an older Bastion sends a plain 401, classified `INVALID_CREDENTIALS`. */
|
|
36
|
+
STEP_UP_FAILED: 'STEP_UP_FAILED',
|
|
32
37
|
CONFLICT: 'CONFLICT',
|
|
38
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
33
39
|
BIRTH_DATE_REQUIRED: 'BIRTH_DATE_REQUIRED',
|
|
34
40
|
BIRTH_DATE_INVALID: 'BIRTH_DATE_INVALID',
|
|
35
41
|
UNDER_MINIMUM_AGE: 'UNDER_MINIMUM_AGE',
|
|
@@ -77,17 +83,33 @@ function classifyBastionError(status, message) {
|
|
|
77
83
|
return exports.BastionErrorCode.NO_APP_ACCESS;
|
|
78
84
|
if (status === 409)
|
|
79
85
|
return exports.BastionErrorCode.CONFLICT;
|
|
86
|
+
if (status === 429)
|
|
87
|
+
return exports.BastionErrorCode.RATE_LIMITED;
|
|
80
88
|
if (status === 503)
|
|
81
89
|
return exports.BastionErrorCode.UNAVAILABLE;
|
|
82
90
|
return exports.BastionErrorCode.UPSTREAM;
|
|
83
91
|
}
|
|
84
|
-
|
|
92
|
+
function isBastionErrorCode(value) {
|
|
93
|
+
return typeof value === 'string' && Object.hasOwn(exports.BastionErrorCode, value);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A non-2xx answer from Bastion's HTTP API, or Bastion unreachable (`status` 503, code `UNAVAILABLE`).
|
|
97
|
+
* `message` and `errors` are Bastion's own: fine to show for a 4xx, never for a 5xx (api.md §14).
|
|
98
|
+
*/
|
|
85
99
|
class BastionHttpError extends Error {
|
|
86
100
|
status;
|
|
101
|
+
errors;
|
|
102
|
+
upstreamCode;
|
|
87
103
|
code;
|
|
88
|
-
constructor(status, message, code
|
|
104
|
+
constructor(status, message, code,
|
|
105
|
+
/** Field errors of a 400 (`VALIDATION_FAILED`), one line each. */
|
|
106
|
+
errors,
|
|
107
|
+
/** Bastion's own `code` when it is not a `BastionErrorCode` (`NOT_FOUND`, `PASSWORD_TOO_SHORT`, …). */
|
|
108
|
+
upstreamCode) {
|
|
89
109
|
super(message);
|
|
90
110
|
this.status = status;
|
|
111
|
+
this.errors = errors;
|
|
112
|
+
this.upstreamCode = upstreamCode;
|
|
91
113
|
this.name = 'BastionHttpError';
|
|
92
114
|
this.code = code ?? classifyBastionError(status, message);
|
|
93
115
|
}
|
|
@@ -10,7 +10,7 @@ export interface JwksVerifierOptions {
|
|
|
10
10
|
fetch?: typeof fetch;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* The canonical consumer of Bastion's JWKS (docs/
|
|
13
|
+
* The canonical consumer of Bastion's JWKS (docs/integrations/bastion.md in the platform repo):
|
|
14
14
|
* keys cached and indexed by `kid`, refetch on an unknown kid with a
|
|
15
15
|
* cooldown, stale cache served when a refetch fails. Second safety net only:
|
|
16
16
|
* Bastion publishes a key before signing with it (publish-before-use), so a
|