@heyatom/bastion-client 0.7.2 → 0.8.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 +41 -0
- package/README.md +144 -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 +17 -2
- package/dist/cjs/core/errors.js +22 -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 +2 -1
- 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 +17 -2
- package/dist/esm/core/errors.js +21 -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 +2 -1
- package/package.json +3 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- `BastionApiClient.exchange(code, ctx, state)` requires `state`; an empty one rejects before calling Bastion. Get it from the new `oauthStart`.
|
|
8
|
+
- `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`.
|
|
9
|
+
- `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`.
|
|
10
|
+
- Tokens whose segments are not strict base64url (padding, stray characters) are rejected as `malformed`.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `BastionApiClient.oauthStart(provider)` → `{ url, state } | null`.
|
|
15
|
+
- `AppUserGuard` and `@CurrentUser()` for an app's own users.
|
|
16
|
+
- `X-Request-Id` on every call to Bastion: `BastionHttpOptions.requestId`; in NestJS the served request's `req.id` by default (`BastionModuleOptions.requestId` to override).
|
|
17
|
+
- `BastionHttpError.errors` (field errors of a 400) and `upstreamCode` (a code outside `BastionErrorCode`).
|
|
18
|
+
- Error codes `VALIDATION_FAILED` and `RATE_LIMITED`.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Validation errors from Bastion were reduced to "Validation failed": the field errors and Bastion's `code` are now kept.
|
|
23
|
+
- 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`, …).
|
|
24
|
+
|
|
25
|
+
## 0.7.2
|
|
26
|
+
|
|
27
|
+
- `completeAgeVerification(token, birthDate, { currentPassword?, twoFactorCode? })` sends the required step-up.
|
|
28
|
+
- `UserJwtPayload.ageVerificationRequired` while the account owes the app's age screen.
|
|
29
|
+
|
|
30
|
+
## 0.7.1
|
|
31
|
+
|
|
32
|
+
- `exchange` sends the OAuth flow `state`.
|
|
33
|
+
|
|
34
|
+
## 0.7.0
|
|
35
|
+
|
|
36
|
+
- Step-up on `completeSocialAccountLink`.
|
|
37
|
+
- **Breaking**: webhooks with only the legacy `X-Bastion-Signature` are rejected unless `allowLegacySignature` is set.
|
|
38
|
+
|
|
39
|
+
## 0.6.0
|
|
40
|
+
|
|
41
|
+
- `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,147 @@ 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`, `CONFLICT`, `RATE_LIMITED`, the age-gate codes
|
|
147
|
+
(`BIRTH_DATE_REQUIRED`, `BIRTH_DATE_INVALID`, `UNDER_MINIMUM_AGE`), the OAuth flow codes
|
|
148
|
+
(`OAUTH_*`), `UNAVAILABLE` (Bastion unreachable or timed out) and `UPSTREAM`.
|
|
149
|
+
|
|
150
|
+
- A 400 carries the field errors in `errors: string[]`. Show them: `message` is only
|
|
151
|
+
"Validation failed".
|
|
152
|
+
- A code outside `BastionErrorCode` (e.g. a password-policy code) is on `upstreamCode`.
|
|
153
|
+
|
|
154
|
+
`BastionHttpException` is safe to let through to the client:
|
|
155
|
+
|
|
156
|
+
- a 4xx keeps Bastion's status and answers `{ code, message, errors? }`;
|
|
157
|
+
- a 5xx becomes 502 `UPSTREAM`, or 503 `UNAVAILABLE` when Bastion is down, with a generic message.
|
|
158
|
+
Bastion's own status and message stay on `upstreamStatus` / `upstreamMessage` and in
|
|
159
|
+
`message`, for your logs.
|
|
160
|
+
|
|
97
161
|
```ts
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (
|
|
162
|
+
try {
|
|
163
|
+
return await this.bastion.login(email, password, ctx);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (err instanceof BastionHttpException && err.code === 'EMAIL_NOT_VERIFIED') { /* ... */ }
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
102
168
|
```
|
|
103
169
|
|
|
104
170
|
## Without NestJS
|
|
105
171
|
|
|
106
172
|
```ts
|
|
107
|
-
import { JwksVerifier, BastionHttp, ServiceTokenProvider } from '@heyatom/bastion-client';
|
|
173
|
+
import { JwksVerifier, BastionHttp, ServiceTokenProvider, BastionApiClient, assertUserTokenFor } from '@heyatom/bastion-client';
|
|
108
174
|
|
|
175
|
+
// Verify incoming tokens (throws BastionTokenError)
|
|
109
176
|
const verifier = new JwksVerifier({ baseUrl: process.env.BASTION_URL! });
|
|
110
|
-
const payload = await verifier.verify(bearerToken);
|
|
111
|
-
|
|
112
|
-
|
|
177
|
+
const payload = await verifier.verify(bearerToken);
|
|
178
|
+
const user = assertUserTokenFor(payload, 'my-app'); // only your app's user tokens
|
|
179
|
+
|
|
180
|
+
// Call Bastion
|
|
181
|
+
const http = new BastionHttp({
|
|
182
|
+
baseUrl: process.env.BASTION_URL!,
|
|
183
|
+
requestId: () => currentRequestIdFromYourFramework(), // optional, sent as X-Request-Id
|
|
184
|
+
});
|
|
113
185
|
const tokens = new ServiceTokenProvider(http, { apiKey, serviceSlug: 'my-service' });
|
|
114
|
-
// several targets: a record keyed by serviceSlug, or { defaultSlug, resolve: (slug) => credential | null }
|
|
115
186
|
await http.writeAuditEvent(await tokens.getToken(), { event: 'thing.done' });
|
|
187
|
+
|
|
188
|
+
const api = new BastionApiClient({ http, appSlug: 'my-app' });
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`ServiceTokenProvider` also takes several credentials, as a record keyed by `serviceSlug` or as
|
|
192
|
+
`{ defaultSlug, resolve: (slug) => credential | null }`.
|
|
193
|
+
|
|
194
|
+
## Webhooks
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
import { verifyWebhookSignature } from '@heyatom/bastion-client/webhooks';
|
|
198
|
+
|
|
199
|
+
// Needs the raw body: NestFactory.create(AppModule, { rawBody: true })
|
|
200
|
+
const result = verifyWebhookSignature({
|
|
201
|
+
rawBody: req.rawBody,
|
|
202
|
+
headers: req.headers,
|
|
203
|
+
secrets: [secret, previousSecret], // both accepted during a rotation
|
|
204
|
+
});
|
|
205
|
+
if (!result.ok) throw new UnauthorizedException(result.reason);
|
|
206
|
+
// result.deliveryId: store it and ignore repeats
|
|
116
207
|
```
|
|
117
208
|
|
|
118
|
-
|
|
209
|
+
Checks `X-Bastion-Signature-V2` (timestamp + delivery id + body, 5 min tolerance). Leave
|
|
210
|
+
`allowLegacySignature` off.
|
|
211
|
+
|
|
212
|
+
## Changelog
|
|
213
|
+
|
|
214
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
|
215
|
+
|
|
216
|
+
## License
|
|
119
217
|
|
|
120
|
-
|
|
218
|
+
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,10 @@ 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";
|
|
24
26
|
readonly CONFLICT: "CONFLICT";
|
|
27
|
+
readonly RATE_LIMITED: "RATE_LIMITED";
|
|
25
28
|
readonly BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED";
|
|
26
29
|
readonly BIRTH_DATE_INVALID: "BIRTH_DATE_INVALID";
|
|
27
30
|
readonly UNDER_MINIMUM_AGE: "UNDER_MINIMUM_AGE";
|
|
@@ -36,9 +39,21 @@ export declare const BastionErrorCode: {
|
|
|
36
39
|
};
|
|
37
40
|
export type BastionErrorCode = (typeof BastionErrorCode)[keyof typeof BastionErrorCode];
|
|
38
41
|
export declare function classifyBastionError(status: number, message: string): BastionErrorCode;
|
|
39
|
-
|
|
42
|
+
export declare function isBastionErrorCode(value: unknown): value is BastionErrorCode;
|
|
43
|
+
/**
|
|
44
|
+
* A non-2xx answer from Bastion's HTTP API, or Bastion unreachable (`status` 503, code `UNAVAILABLE`).
|
|
45
|
+
* `message` and `errors` are Bastion's own: fine to show for a 4xx, never for a 5xx (api.md §14).
|
|
46
|
+
*/
|
|
40
47
|
export declare class BastionHttpError extends Error {
|
|
41
48
|
readonly status: number;
|
|
49
|
+
/** Field errors of a 400 (`VALIDATION_FAILED`), one line each. */
|
|
50
|
+
readonly errors?: string[] | undefined;
|
|
51
|
+
/** Bastion's own `code` when it is not a `BastionErrorCode` (`NOT_FOUND`, `PASSWORD_TOO_SHORT`, …). */
|
|
52
|
+
readonly upstreamCode?: string | undefined;
|
|
42
53
|
readonly code: BastionErrorCode;
|
|
43
|
-
constructor(status: number, message: string, code?: BastionErrorCode
|
|
54
|
+
constructor(status: number, message: string, code?: BastionErrorCode,
|
|
55
|
+
/** Field errors of a 400 (`VALIDATION_FAILED`), one line each. */
|
|
56
|
+
errors?: string[] | undefined,
|
|
57
|
+
/** Bastion's own `code` when it is not a `BastionErrorCode` (`NOT_FOUND`, `PASSWORD_TOO_SHORT`, …). */
|
|
58
|
+
upstreamCode?: string | undefined);
|
|
44
59
|
}
|
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,10 @@ 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',
|
|
32
35
|
CONFLICT: 'CONFLICT',
|
|
36
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
33
37
|
BIRTH_DATE_REQUIRED: 'BIRTH_DATE_REQUIRED',
|
|
34
38
|
BIRTH_DATE_INVALID: 'BIRTH_DATE_INVALID',
|
|
35
39
|
UNDER_MINIMUM_AGE: 'UNDER_MINIMUM_AGE',
|
|
@@ -77,17 +81,33 @@ function classifyBastionError(status, message) {
|
|
|
77
81
|
return exports.BastionErrorCode.NO_APP_ACCESS;
|
|
78
82
|
if (status === 409)
|
|
79
83
|
return exports.BastionErrorCode.CONFLICT;
|
|
84
|
+
if (status === 429)
|
|
85
|
+
return exports.BastionErrorCode.RATE_LIMITED;
|
|
80
86
|
if (status === 503)
|
|
81
87
|
return exports.BastionErrorCode.UNAVAILABLE;
|
|
82
88
|
return exports.BastionErrorCode.UPSTREAM;
|
|
83
89
|
}
|
|
84
|
-
|
|
90
|
+
function isBastionErrorCode(value) {
|
|
91
|
+
return typeof value === 'string' && Object.hasOwn(exports.BastionErrorCode, value);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A non-2xx answer from Bastion's HTTP API, or Bastion unreachable (`status` 503, code `UNAVAILABLE`).
|
|
95
|
+
* `message` and `errors` are Bastion's own: fine to show for a 4xx, never for a 5xx (api.md §14).
|
|
96
|
+
*/
|
|
85
97
|
class BastionHttpError extends Error {
|
|
86
98
|
status;
|
|
99
|
+
errors;
|
|
100
|
+
upstreamCode;
|
|
87
101
|
code;
|
|
88
|
-
constructor(status, message, code
|
|
102
|
+
constructor(status, message, code,
|
|
103
|
+
/** Field errors of a 400 (`VALIDATION_FAILED`), one line each. */
|
|
104
|
+
errors,
|
|
105
|
+
/** Bastion's own `code` when it is not a `BastionErrorCode` (`NOT_FOUND`, `PASSWORD_TOO_SHORT`, …). */
|
|
106
|
+
upstreamCode) {
|
|
89
107
|
super(message);
|
|
90
108
|
this.status = status;
|
|
109
|
+
this.errors = errors;
|
|
110
|
+
this.upstreamCode = upstreamCode;
|
|
91
111
|
this.name = 'BastionHttpError';
|
|
92
112
|
this.code = code ?? classifyBastionError(status, message);
|
|
93
113
|
}
|
|
@@ -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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.JwksVerifier = void 0;
|
|
4
|
-
const
|
|
4
|
+
const jwt_js_1 = require("./jwt.js");
|
|
5
5
|
const errors_js_1 = require("./errors.js");
|
|
6
6
|
const types_js_1 = require("./types.js");
|
|
7
7
|
/**
|
|
@@ -12,7 +12,7 @@ const types_js_1 = require("./types.js");
|
|
|
12
12
|
*/
|
|
13
13
|
const UNKNOWN_KID_REFETCH_COOLDOWN_MS = 30_000;
|
|
14
14
|
/**
|
|
15
|
-
* The canonical consumer of Bastion's JWKS (docs/
|
|
15
|
+
* The canonical consumer of Bastion's JWKS (docs/integrations/bastion.md in the platform repo):
|
|
16
16
|
* keys cached and indexed by `kid`, refetch on an unknown kid with a
|
|
17
17
|
* cooldown, stale cache served when a refetch fails. Second safety net only:
|
|
18
18
|
* Bastion publishes a key before signing with it (publish-before-use), so a
|
|
@@ -39,12 +39,12 @@ class JwksVerifier {
|
|
|
39
39
|
async verify(token) {
|
|
40
40
|
let kid;
|
|
41
41
|
try {
|
|
42
|
-
|
|
42
|
+
kid = (0, jwt_js_1.decodeJwt)(token).header.kid;
|
|
43
43
|
}
|
|
44
44
|
catch {
|
|
45
45
|
throw new errors_js_1.BastionTokenError('malformed', 'Malformed token');
|
|
46
46
|
}
|
|
47
|
-
if (!kid)
|
|
47
|
+
if (typeof kid !== 'string' || !kid)
|
|
48
48
|
throw new errors_js_1.BastionTokenError('missing_kid', 'Missing kid in JWT header');
|
|
49
49
|
await this.ensureFresh();
|
|
50
50
|
let key = this.keys.get(kid);
|
|
@@ -57,11 +57,7 @@ class JwksVerifier {
|
|
|
57
57
|
throw new errors_js_1.BastionTokenError('unknown_kid', 'Unknown or expired kid');
|
|
58
58
|
}
|
|
59
59
|
try {
|
|
60
|
-
|
|
61
|
-
algorithms: ['RS256'],
|
|
62
|
-
...(this.issuer ? { issuer: this.issuer } : {}),
|
|
63
|
-
});
|
|
64
|
-
return payload;
|
|
60
|
+
return (0, jwt_js_1.verifyRs256)(token, key, this.issuer);
|
|
65
61
|
}
|
|
66
62
|
catch {
|
|
67
63
|
throw new errors_js_1.BastionTokenError('invalid', 'Invalid token');
|
|
@@ -79,7 +75,12 @@ class JwksVerifier {
|
|
|
79
75
|
for (const jwk of keys) {
|
|
80
76
|
if (!jwk.kid)
|
|
81
77
|
continue;
|
|
82
|
-
|
|
78
|
+
try {
|
|
79
|
+
next.set(jwk.kid, (0, jwt_js_1.importRs256Jwk)(jwk));
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
this.logger.warn(`JWKS: skipping kid=${jwk.kid}: ${err.message}`);
|
|
83
|
+
}
|
|
83
84
|
}
|
|
84
85
|
this.keys = next;
|
|
85
86
|
this.fetchedAt = Date.now();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type KeyObject } from 'node:crypto';
|
|
2
|
+
/** Header and payload of a compact JWS, unverified. Throws on anything that isn't one. */
|
|
3
|
+
export declare function decodeJwt(token: string): {
|
|
4
|
+
header: Record<string, unknown>;
|
|
5
|
+
payload: Record<string, unknown>;
|
|
6
|
+
};
|
|
7
|
+
/** An RS256 verification key from a JWKS entry; RSA ≥ 2048 bits only. */
|
|
8
|
+
export declare function importRs256Jwk(jwk: JsonWebKey): KeyObject;
|
|
9
|
+
/**
|
|
10
|
+
* RS256 signature, `exp`/`nbf`, optional `iss`. What `jose.jwtVerify` checked for us,
|
|
11
|
+
* in node:crypto so the CJS build has no ESM-only dependency. Throws on any failure.
|
|
12
|
+
*/
|
|
13
|
+
export declare function verifyRs256(token: string, key: KeyObject, issuer: string | null, now?: number): Record<string, unknown>;
|