@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +144 -46
  3. package/dist/cjs/core/api-client.d.ts +16 -5
  4. package/dist/cjs/core/api-client.js +23 -5
  5. package/dist/cjs/core/bastion-http.d.ts +7 -0
  6. package/dist/cjs/core/bastion-http.js +27 -3
  7. package/dist/cjs/core/errors.d.ts +17 -2
  8. package/dist/cjs/core/errors.js +22 -2
  9. package/dist/cjs/core/jwks-verifier.d.ts +1 -1
  10. package/dist/cjs/core/jwks-verifier.js +11 -10
  11. package/dist/cjs/core/jwt.d.ts +13 -0
  12. package/dist/cjs/core/jwt.js +55 -0
  13. package/dist/cjs/core/service-token-provider.js +2 -2
  14. package/dist/cjs/nest/bastion-api-client.provider.d.ts +3 -2
  15. package/dist/cjs/nest/bastion-api-client.provider.js +3 -2
  16. package/dist/cjs/nest/bastion-http-exception.d.ts +16 -6
  17. package/dist/cjs/nest/bastion-http-exception.js +38 -7
  18. package/dist/cjs/nest/bastion.module.d.ts +5 -3
  19. package/dist/cjs/nest/bastion.module.js +8 -1
  20. package/dist/cjs/nest/bastion.service.d.ts +1 -1
  21. package/dist/cjs/nest/bastion.service.js +7 -2
  22. package/dist/cjs/nest/decorators/current-user.decorator.d.ts +2 -0
  23. package/dist/cjs/nest/decorators/current-user.decorator.js +3 -1
  24. package/dist/cjs/nest/guards/app-user.guard.d.ts +16 -0
  25. package/dist/cjs/nest/guards/app-user.guard.js +47 -0
  26. package/dist/cjs/nest/index.d.ts +3 -1
  27. package/dist/cjs/nest/index.js +6 -1
  28. package/dist/cjs/nest/options.d.ts +7 -0
  29. package/dist/cjs/nest/request-context.d.ts +10 -0
  30. package/dist/cjs/nest/request-context.js +19 -0
  31. package/dist/cjs/webhooks/index.d.ts +2 -1
  32. package/dist/esm/core/api-client.d.ts +16 -5
  33. package/dist/esm/core/api-client.js +24 -6
  34. package/dist/esm/core/bastion-http.d.ts +7 -0
  35. package/dist/esm/core/bastion-http.js +28 -4
  36. package/dist/esm/core/errors.d.ts +17 -2
  37. package/dist/esm/core/errors.js +21 -2
  38. package/dist/esm/core/jwks-verifier.d.ts +1 -1
  39. package/dist/esm/core/jwks-verifier.js +11 -10
  40. package/dist/esm/core/jwt.d.ts +13 -0
  41. package/dist/esm/core/jwt.js +50 -0
  42. package/dist/esm/core/service-token-provider.js +2 -2
  43. package/dist/esm/nest/bastion-api-client.provider.d.ts +3 -2
  44. package/dist/esm/nest/bastion-api-client.provider.js +3 -2
  45. package/dist/esm/nest/bastion-http-exception.d.ts +16 -6
  46. package/dist/esm/nest/bastion-http-exception.js +40 -9
  47. package/dist/esm/nest/bastion.module.d.ts +5 -3
  48. package/dist/esm/nest/bastion.module.js +9 -2
  49. package/dist/esm/nest/bastion.service.d.ts +1 -1
  50. package/dist/esm/nest/bastion.service.js +7 -2
  51. package/dist/esm/nest/decorators/current-user.decorator.d.ts +2 -0
  52. package/dist/esm/nest/decorators/current-user.decorator.js +2 -0
  53. package/dist/esm/nest/guards/app-user.guard.d.ts +16 -0
  54. package/dist/esm/nest/guards/app-user.guard.js +44 -0
  55. package/dist/esm/nest/index.d.ts +3 -1
  56. package/dist/esm/nest/index.js +3 -1
  57. package/dist/esm/nest/options.d.ts +7 -0
  58. package/dist/esm/nest/request-context.d.ts +10 -0
  59. package/dist/esm/nest/request-context.js +15 -0
  60. package/dist/esm/webhooks/index.d.ts +2 -1
  61. 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
- Consumer SDK for [Bastion](https://github.com/heyatomdev/bastion), the HeyAtom identity provider.
4
- Replaces the `src/modules/bastion/` boilerplate every service used to copy by hand.
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
- - `@heyatom/bastion-client` — framework-free core: `JwksVerifier` (kid-indexed cache, unknown-kid refetch with cooldown, stale-on-error), `BastionHttp` (`POST /auth/client`, `POST /events`, any other route), `ServiceTokenProvider` (single-flight refresh ahead of `exp`), payload types.
7
- - `@heyatom/bastion-client` also carries Bastion's user-API contract: `BastionApiClient` (login, register, OAuth exchange, 2FA, single-flight refresh, logout, sessions, `/me`, profile, social accounts, email/password flows, export, delete), its types (`BastionMe`, `BastionTokenPair`, …) and `BastionErrorCode` (a stable code on every `BastionHttpError`: credentials, app context, refresh token, OAuth code, age gate, OAuth flow, unavailable, upstream). `refreshMemoryMs` tunes how long a refresh answer is remembered per token (default 60 s). Since 0.7.2 `completeAgeVerification(token, birthDate, { currentPassword?, twoFactorCode? })` sends the step-up Bastion requires (as `deleteSelf`), and `UserJwtPayload.ageVerificationRequired?: true` is set only while the account owes the app's age screen: a consumer API can refuse it from the token alone.
8
- - `@heyatom/bastion-client/webhooks` — `verifyWebhookSignature` (`X-Bastion-Signature-V2` with timestamp + delivery id, legacy fallback only with `allowLegacySignature: true` (off by default since 0.7.0), 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()`.
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
- 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`).
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', { metadata: (_result, req) => ({ keys: Object.keys(req.body as object) }) })
88
+ @Audit('config.updated')
61
89
  update(@CurrentAdminUser() admin: UserJwtPayload, @Body() dto: UpdateDto) { /* ... */ }
62
90
  }
63
91
  ```
64
92
 
65
- Bastion stores app events as `<serviceSlug>.<event>` — send the bare `resource.action`.
93
+ Audit events are stored by Bastion as `<serviceSlug>.<event>`: send `resource.action`.
66
94
 
67
- ## App backends: verifying your users' tokens
95
+ ### Backend for your users (BFF)
68
96
 
69
- ```ts
70
- // BastionJwksService.verifyUserToken(token): a user session token minted for this app
71
- // (rejects machine tokens, checks aud + appSlug === serviceSlug, requires sub). Build your
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
- 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
- }
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
- // 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);
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); // throws BastionTokenError
111
-
112
- const http = new BastionHttp({ baseUrl: process.env.BASTION_URL! });
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
- ## Release
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
- Bump `version`, tag `vX.Y.Z`, push the tag. The workflow runs lint/test/build/DI smoke and publishes via npm trusted publishing (OIDC, no token; provenance implied).
218
+ MIT
@@ -51,12 +51,23 @@ export declare class BastionApiClient {
51
51
  message: string;
52
52
  }>;
53
53
  /**
54
- * Trades the one-time OAuth code for tokens. `state` is the value the app put
55
- * on the initiate URL, read back from its own cookie/session (never from the
56
- * callback query): Bastion refuses a code minted under a different one, which
57
- * is what stops an attacker's code from being redeemed in a victim's browser.
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
- exchange(code: string, ctx?: BastionClientContext, state?: string): Promise<BastionLoginResult>;
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
- * Trades the one-time OAuth code for tokens. `state` is the value the app put
66
- * on the initiate URL, read back from its own cookie/session (never from the
67
- * callback query): Bastion refuses a code minted under a different one, which
68
- * is what stops an attacker's code from being redeemed in a victim's browser.
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
- return this.call('POST', '/auth/exchange', { code, appSlug: this.appSlug, ...(state !== undefined ? { state } : {}) }, { headers: BastionApiClient.clientHeaders(ctx) });
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
- const parsed = (await res.json().catch(() => ({})));
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
- : (parsed.message ?? `Bastion error ${res.status}`);
45
- throw new errors_js_1.BastionHttpError(res.status, message);
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
- /** A non-2xx answer from Bastion's HTTP API, or Bastion unreachable (`status` 503, code `UNAVAILABLE`). */
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
  }
@@ -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
- /** A non-2xx answer from Bastion's HTTP API, or Bastion unreachable (`status` 503, code `UNAVAILABLE`). */
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/BASTION_INTEGRATION.md):
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 jose_1 = require("jose");
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/BASTION_INTEGRATION.md):
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
- ({ kid } = (0, jose_1.decodeProtectedHeader)(token));
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
- const { payload } = await (0, jose_1.jwtVerify)(token, key, {
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
- next.set(jwk.kid, await (0, jose_1.importJWK)(jwk, 'RS256'));
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>;