{"_id":"@bulwarkauth/nestjs","_rev":"4-084f00b261a4c9ade5b5ffe71318fb77","name":"@bulwarkauth/nestjs","dist-tags":{"latest":"0.3.0"},"versions":{"0.1.0":{"name":"@bulwarkauth/nestjs","version":"0.1.0","keywords":["bulwark","auth","authentication","identity","security","nestjs","nest","guard","module","decorator"],"license":"Apache-2.0","_id":"@bulwarkauth/nestjs@0.1.0","maintainers":[{"name":"ronai","email":"ron@techtapsolutions.com"}],"homepage":"https://bulwarkauth.com","bugs":{"url":"https://github.com/bulwarkauth/bulwark/issues"},"dist":{"shasum":"82ba425935f6a5071a1207377f85418c7ddbb61e","tarball":"https://registry.npmjs.org/@bulwarkauth/nestjs/-/nestjs-0.1.0.tgz","fileCount":6,"integrity":"sha512-ltEghT10OHQPUfhpzEVx9vPWNWsmP5viramtHnxk0GYgxmSSCBccoldPo0GoD2097OR52QS1PAGlm2l8QQxG/g==","signatures":[{"sig":"MEQCIBCDMC3bRI8gi8741iLfBzjXB13yRM1dnH6FKa3RlGgdAiAEF674k8JLsSHR4vfE4LDVLFSDO5lMmKLPPmJZaK+xEw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":29194},"main":"./dist/index.cjs","type":"module","_from":"file:bulwarkauth-nestjs-0.1.0.tgz","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}}},"scripts":{"dev":"tsup --watch","lint":"tsc --noEmit","build":"tsup","typecheck":"tsc --noEmit"},"_npmUser":{"name":"ronai","email":"ron@techtapsolutions.com"},"_resolved":"/private/var/folders/bx/h_6976wn2vd66ptqpj1fb_n40000gn/T/7eb16afc621a8100c76831f9a4edf429/bulwarkauth-nestjs-0.1.0.tgz","_integrity":"sha512-ltEghT10OHQPUfhpzEVx9vPWNWsmP5viramtHnxk0GYgxmSSCBccoldPo0GoD2097OR52QS1PAGlm2l8QQxG/g==","repository":{"url":"git+https://github.com/bulwarkauth/bulwark.git","type":"git","directory":"sdk/nestjs"},"_npmVersion":"10.9.3","description":"Bulwark NestJS SDK — module, guards, and decorators","directories":{},"_nodeVersion":"22.18.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"rxjs":"^7.0.0","tsup":"^8.0.0","typescript":"^5.7.0","@nestjs/core":"^10.0.0","@nestjs/common":"^10.0.0","reflect-metadata":"^0.2.0","@bulwarkauth/core":"0.1.0"},"peerDependencies":{"@nestjs/core":">=10","@nestjs/common":">=10","@bulwarkauth/core":"0.1.0"},"_npmOperationalInternal":{"tmp":"tmp/nestjs_0.1.0_1774166195881_0.6357191371449749","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@bulwarkauth/nestjs","version":"0.2.0","keywords":["bulwark","auth","authentication","identity","security","nestjs","nest","guard","module","decorator"],"license":"Apache-2.0","_id":"@bulwarkauth/nestjs@0.2.0","maintainers":[{"name":"ronai","email":"ron@techtapsolutions.com"}],"homepage":"https://bulwarkauth.com","bugs":{"url":"https://github.com/bulwarkauth/bulwark/issues"},"dist":{"shasum":"157e0a57d11ce726b44500cc97e70f317ad6f547","tarball":"https://registry.npmjs.org/@bulwarkauth/nestjs/-/nestjs-0.2.0.tgz","fileCount":6,"integrity":"sha512-suWKg1mixLSgttorTZ39wHzxEaKJogNoUzgaxKktzWUp0iu0eVWHy3gsngdM8SglGjEwZSDRNN/eFIr36RwTdg==","signatures":[{"sig":"MEUCIE8EkJMFD5s9AHU8ICdJSpKRftxshmvjMPm4GtiOK/m2AiEAjNgfynNovgL+som2UpfDQL+Vs3N8wOsSrM8+buf0TIY=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":34137},"main":"./dist/index.cjs","type":"module","_from":"file:bulwarkauth-nestjs-0.2.0.tgz","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}}},"scripts":{"dev":"tsup --watch","lint":"tsc --noEmit","build":"tsup","typecheck":"tsc --noEmit"},"_npmUser":{"name":"ronai","email":"ron@techtapsolutions.com"},"_resolved":"/private/var/folders/bx/h_6976wn2vd66ptqpj1fb_n40000gn/T/b6f8876f8ecd026144d4559a27571c64/bulwarkauth-nestjs-0.2.0.tgz","_integrity":"sha512-suWKg1mixLSgttorTZ39wHzxEaKJogNoUzgaxKktzWUp0iu0eVWHy3gsngdM8SglGjEwZSDRNN/eFIr36RwTdg==","repository":{"url":"git+https://github.com/bulwarkauth/bulwark.git","type":"git","directory":"sdk/nestjs"},"_npmVersion":"10.9.3","description":"Bulwark NestJS SDK — module, guards, and decorators","directories":{},"_nodeVersion":"22.18.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"rxjs":"^7.0.0","tsup":"^8.0.0","typescript":"^5.7.0","@nestjs/core":"^10.0.0","@nestjs/common":"^10.0.0","reflect-metadata":"^0.2.0","@bulwarkauth/core":"0.1.0"},"peerDependencies":{"@nestjs/core":">=10","@nestjs/common":">=10","@bulwarkauth/core":"0.1.0"},"_npmOperationalInternal":{"tmp":"tmp/nestjs_0.2.0_1774288086014_0.4947166377230807","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"name":"@bulwarkauth/nestjs","version":"0.2.1","keywords":["bulwark","auth","authentication","identity","security","nestjs","nest","guard","module","decorator"],"license":"Apache-2.0","_id":"@bulwarkauth/nestjs@0.2.1","maintainers":[{"name":"ronai","email":"ron@techtapsolutions.com"}],"homepage":"https://bulwarkauth.com","bugs":{"url":"https://github.com/bulwarkauth/bulwark/issues"},"dist":{"shasum":"4c14030af7f8392430afe4fc6049ef03d0d53a50","tarball":"https://registry.npmjs.org/@bulwarkauth/nestjs/-/nestjs-0.2.1.tgz","fileCount":6,"integrity":"sha512-aBgkGm3MpvhXbg2u1yzdVieZpWyxL4L3WIK4S7DJhH4HsM6vzbOfi55iAjJN+7GAzffGQUBu7FyDbXIfELcJnQ==","signatures":[{"sig":"MEQCIBqZh/eatuGO/g60TkNM7F2gqSydDWHqDyuDVBYZLD1rAiB9SuaGR7EfRghzjFjg9XT3My1xwvf7eA5S4+XF0iYqtw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":35312},"main":"./dist/index.cjs","type":"module","_from":"file:bulwarkauth-nestjs-0.2.1.tgz","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}}},"scripts":{"dev":"tsup --watch","lint":"tsc --noEmit","build":"tsup","typecheck":"tsc --noEmit"},"_npmUser":{"name":"ronai","email":"ron@techtapsolutions.com"},"_resolved":"/private/var/folders/bx/h_6976wn2vd66ptqpj1fb_n40000gn/T/140448362f0945d495df94f400414a76/bulwarkauth-nestjs-0.2.1.tgz","_integrity":"sha512-aBgkGm3MpvhXbg2u1yzdVieZpWyxL4L3WIK4S7DJhH4HsM6vzbOfi55iAjJN+7GAzffGQUBu7FyDbXIfELcJnQ==","repository":{"url":"git+https://github.com/bulwarkauth/bulwark.git","type":"git","directory":"sdk/nestjs"},"_npmVersion":"10.9.3","description":"Bulwark NestJS SDK — module, guards, and decorators","directories":{},"_nodeVersion":"22.18.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"rxjs":"^7.0.0","tsup":"^8.0.0","typescript":"^5.7.0","@nestjs/core":"^10.0.0","@nestjs/common":"^10.0.0","reflect-metadata":"^0.2.0","@bulwarkauth/core":"0.3.0"},"peerDependencies":{"@nestjs/core":">=10","@nestjs/common":">=10","@bulwarkauth/core":"^0.3.0"},"_npmOperationalInternal":{"tmp":"tmp/nestjs_0.2.1_1783457472848_0.46904871216095745","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"_id":"@bulwarkauth/nestjs@0.3.0","bugs":{"url":"https://github.com/bulwarkauth/bulwark/issues"},"dist":{"shasum":"078865a78ae101832132fc1454b987b991e05b8f","tarball":"https://registry.npmjs.org/@bulwarkauth/nestjs/-/nestjs-0.3.0.tgz","fileCount":7,"integrity":"sha512-blkuUEenbLNIeLFQjyMwmxIGFPxVOp3mDGasXD8ueTXvhbp9Q/EUFOUbUO4maAFFbLxNivt5upGJ3Vr+Rmc90w==","signatures":[{"sig":"MEUCIQDylhLDqY/PVjkm1zqdXvSRorLjaLo2mb7Jl3PAFKrtIgIgcmp2VqkJan3HOECMAQXNqRXDqHAHkwFnuGSvl9PNBO0=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQDn+CqohN7l8doJKQmhtOxpLutrkK4jq2DTXW23Ramr0QIgcPjSYvjiM6esyPtPsooZn9bgdccUoozevVr4a1nTafA="}],"unpackedSize":151470},"main":"./dist/index.cjs","name":"@bulwarkauth/nestjs","type":"module","_from":"file:bulwarkauth-nestjs-0.3.0.tgz","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}}},"license":"Apache-2.0","scripts":{"dev":"tsup --watch","lint":"tsc --noEmit","test":"vitest run","build":"tsup","typecheck":"tsc --noEmit"},"version":"0.3.0","_npmUser":{"name":"ronai","email":"ron@techtapsolutions.com"},"homepage":"https://bulwarkauth.com","keywords":["bulwark","auth","authentication","identity","security","nestjs","nest","guard","module","decorator"],"_resolved":"/private/tmp/claude-501/-Volumes-PRO-G40-Dropbox-Tech-Tap-Solutions-AI-Claude-Bulwark/be636d19-a0ae-4a05-924e-5bf75dd1f845/scratchpad/publish/out/bulwarkauth-nestjs-0.3.0.tgz","_integrity":"sha512-blkuUEenbLNIeLFQjyMwmxIGFPxVOp3mDGasXD8ueTXvhbp9Q/EUFOUbUO4maAFFbLxNivt5upGJ3Vr+Rmc90w==","repository":{"url":"git+https://github.com/bulwarkauth/bulwark.git","type":"git","directory":"sdk/nestjs"},"_npmVersion":"11.6.0","description":"Bulwark NestJS SDK — module, guards, and decorators","directories":{},"maintainers":[{"name":"ronai","email":"ron@techtapsolutions.com"}],"_nodeVersion":"22.18.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"rxjs":"^7.0.0","tsup":"^8.0.0","vitest":"^3.0.0","typescript":"^5.7.0","@types/node":"^22.15.21","@nestjs/core":"^10.0.0","@nestjs/common":"^10.0.0","reflect-metadata":"^0.2.0","@bulwarkauth/core":"0.3.1"},"peerDependencies":{"@nestjs/core":">=10","@nestjs/common":">=10","@bulwarkauth/core":"^0.3.1"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/nestjs_0.3.0_1790630845477_0.6108301845259241"}}},"time":{"created":"2026-03-22T07:56:35.681Z","modified":"2026-09-28T21:27:25.769Z","0.1.0":"2026-03-22T07:56:36.032Z","0.2.0":"2026-03-23T17:48:06.178Z","0.2.1":"2026-07-07T20:51:12.994Z","0.3.0":"2026-09-28T21:27:25.560Z"},"bugs":{"url":"https://github.com/bulwarkauth/bulwark/issues"},"license":"Apache-2.0","homepage":"https://bulwarkauth.com","keywords":["bulwark","auth","authentication","identity","security","nestjs","nest","guard","module","decorator"],"repository":{"url":"git+https://github.com/bulwarkauth/bulwark.git","type":"git","directory":"sdk/nestjs"},"description":"Bulwark NestJS SDK — module, guards, and decorators","maintainers":[{"name":"ronai","email":"ron@techtapsolutions.com"}],"readme":"# @bulwarkauth/nestjs\n\nNestJS module, guard, and decorators for validating Bulwark-issued tokens.\n\n## Install\n\n```bash\npnpm add @bulwarkauth/nestjs @bulwarkauth/core\n```\n\n## Setup\n\n```ts\nimport { BulwarkModule } from \"@bulwarkauth/nestjs\";\n\n@Module({\n  imports: [\n    BulwarkModule.forRoot({\n      baseURL: \"https://auth.example.com\",\n      tenant: \"your-tenant-id\",\n    }),\n  ],\n})\nexport class AppModule {}\n```\n\nGuard a route:\n\n```ts\nimport {\n  BulwarkAuthGuard,\n  BulwarkAuth,\n  CurrentUser,\n} from \"@bulwarkauth/nestjs\";\n\n@UseGuards(BulwarkAuthGuard)\n@Controller(\"me\")\nexport class MeController {\n  @Get()\n  me(@CurrentUser() user: BulwarkClaims) {\n    return user;\n  }\n}\n```\n\n## Token validation modes\n\nBy default, `validateToken` **verifies the token's EdDSA signature offline**\nagainst Bulwark's published JWKS (`/.well-known/jwks.json`) — no unverified\ndecode, no per-request round-trip. This is secure by default: no\nconfiguration is required for the common case of validating Bulwark's own\nlogin/access tokens.\n\n```ts\nBulwarkModule.forRoot({\n  baseURL: \"https://auth.example.com\",\n  tenant: \"your-tenant-id\",\n  // Optional — shown with their defaults:\n  // expectedIssuer: \"bulwark\",       // Bulwark's own login/access token issuer\n  // expectedAudience: undefined,     // only enforced if set\n  // clockToleranceSec: 60,\n  // jwksCacheTtlMs: 5 * 60 * 1000,\n  // jwksMinRefreshIntervalMs: 5_000,   // once at least one fetch has succeeded — see rotation note below\n  // jwksFailureBackoffMs: 2_000,       // much shorter — applies right after a FAILED fetch\n  // jwksFetchTimeoutMs: 5_000,         // covers the full fetch AND response-body read\n  // jwksMaxStaleMs: 60 * 60 * 1000,    // stale-if-error window during an outage — see below\n});\n```\n\nWhat's checked: the EdDSA (Ed25519) signature against the key published\nunder the token's `kid`; `alg` is allowlisted to exactly `\"EdDSA\"` (`none`,\nHS*/RS*/ES* are all rejected before any key lookup, so alg-confusion and\n`alg: none` attacks never reach a signature check at all); `exp`/`nbf`/`iat`\n(each also required to be a finite number — an overflowing value can't\nsilently bypass expiry) with `clockToleranceSec` leeway; `iss` against\n`expectedIssuer`; **the token's `tid` claim (falling back to `tenant_id`\nonly when `tid` is absent) against an explicit allowlist (see below — this\nis not optional the way `aud` is)**; `aud` against `expectedAudience`, but\nonly when you set it (Bulwark's own server-side audience stamping is itself\nopt-in per deployment, so nothing is enforced by default here) — **pass an\narray to match ANY of several acceptable audiences** (`expectedAudience:\n[\"aud-one\", \"aud-two\"]` accepts a token whose `aud` contains either). The\nJWKS is cached in memory and only refetched (at most once per\n`jwksMinRefreshIntervalMs`, default 5s — deliberately short: Bulwark only\npublishes a new signing key once it starts using it, no advance-notice\ngrace period, so a longer window means a token signed with a just-rotated\nkey can spuriously 401 for that long) on a `kid` it doesn't recognize, so a\nburst of tokens with forged/random `kid`s can't be used to hammer the JWKS\nendpoint.\n\n### Construction-time validation\n\n`JwtVerifier` (and `BulwarkService`, which constructs one internally)\nvalidates its options and throws a clear `Error` at construction — never\nsilently miscomputes on every `verify()` call afterward. In addition to the\nnumeric/shape checks from earlier rounds (every duration option must be a\nfinite number `>= 0`, `expectedTenantIds` a real array of non-empty\nstrings, etc. — see the CHANGELOG), construction also enforces:\n\n- **`dangerouslySkipSignatureVerification` and `dangerouslySkipTenantCheck`\n  must be real booleans (or omitted) — never merely truthy.** A\n  `ConfigService`/env value of the string `\"false\"`, the string `\"0\"`, the\n  number `1`, or an object are all truthy in JavaScript; checking\n  `if (options.dangerouslySkipSignatureVerification)` would have silently\n  disabled signature verification for any of them. Both flags are now\n  rejected outright unless they're exactly `true`, `false`, or `undefined`.\n- **`clockToleranceSec` may not exceed 300 (5 minutes).** This leeway\n  exists to absorb clock skew between this process and Bulwark, not to\n  make `exp`/`nbf` effectively optional — an unbounded value defeats\n  expiry almost as completely as an unvalidated `NaN` would.\n- **`jwksMinRefreshIntervalMs` may not exceed `jwksCacheTtlMs`.** If it\n  did, a known `kid`'s cached key could go stale (past the TTL) for the\n  whole min-refresh window without a refresh even being attempted —\n  bypassing the `jwksMaxStaleMs` stale-if-error bound entirely, since\n  nothing had actually FAILED to trigger it.\n- `expectedIssuer` must be a non-empty string when given.\n- `allowedTenantIds`/`expectedTenantIds` provided EXPLICITLY as `[]` or\n  `null` throws — omit the option entirely to fall back to the default\n  (`[tenant]`); providing it with nothing usable in it is treated as a\n  mistake, not \"not set\".\n- **`jwksFetchTimeoutMs` may not exceed `60_000` (60s).** Node's\n  `setTimeout` clamps any delay above `2^31-1`ms (~24.8 days) to `1`ms\n  instead of erroring — a value like `2_592_000_000` (30 days, plausible\n  if a caller assumed a different unit) would silently become a ~1ms\n  timeout instead, 503ing every request with a misleadingly large \"timed\n  out after ...ms\" message.\n- **`jwksCacheTtlMs` and `jwksMaxStaleMs` may each not exceed\n  `86_400_000` (24 hours).** The same \"unbounded defeats the bound\" class\n  of issue as the `clockToleranceSec` cap above: an oversized\n  `jwksCacheTtlMs` keeps a key Bulwark has since REMOVED from its JWKS\n  trusted for just as long, and an oversized `jwksMaxStaleMs` serves a\n  stale (possibly rotated-out) key for the entire length of a JWKS\n  outage, however long that outage lasts.\n\n**During a JWKS outage** (fetch failure or timeout), a cached key for a\n`kid` this deployment has already seen is still served — \"stale-if-error\"\n— for up to `jwksMaxStaleMs` (default 1 hour) PAST the normal\n`jwksCacheTtlMs`, instead of every request failing the instant the TTL\nlapses mid-outage. ⚠️ **Security trade-off:** this means a key that was\nrotated OUT stays trusted for up to `jwksMaxStaleMs` if the JWKS happens to\nbe unreachable right when that rotation would otherwise have been picked\nup. Set `jwksMaxStaleMs: 0` to disable stale-serving entirely (every\nrequest fails fast once the outage outlasts the TTL) if that trade-off\nisn't acceptable for your deployment. A `kid` this deployment has NEVER\nseen still throws `BulwarkVerificationUnavailableError` during an outage,\nnever a silent `null` — stale-serving only ever extends trust in a key\nalready known to be genuine, never invents trust in an unknown one.\n\n### Tenant binding\n\n**Bulwark's JWKS is a single instance-wide key set — it is NOT scoped per\ntenant.** A token validly signed and issued for a _completely different_\nBulwark customer's app verifies just as cleanly as one issued for yours; only\nthe tenant claim tells them apart. `validateToken` therefore also checks the\nverified token's `tid` claim — the CANONICAL tenant claim server-side\n(`internal/crypto/token.go` `BulwarkClaims.TenantID`) — falling back to\n`tenant_id` (a compat mirror the server sets equal to `tid` at sign time)\nonly when `tid` is absent, against an allowlist. A token carrying both that\ndisagree is rejected outright (the real server never mints one like that).\nRejects (`null` → `401`) a mismatch, a disagreement between the two claims,\nor a token with no tenant claim at all.\n\nBy default the allowlist is `[tenant]` — the same value you already pass as\n`BulwarkModuleOptions.tenant`. This is correct out of the box for an app\nrunning in Bulwark's **isolated** isolation mode, where a token's\n`tenant_id` IS the app's own tenant id.\n\n**If your app runs in Bulwark's _shared_ isolation mode**, Bulwark promotes\nthe token's `tenant_id` to the parent **workspace** tenant id at login\ninstead — which will NOT equal the app id you send as `X-Bulwark-Tenant` on\nAPI calls (`tenant`). Set `allowedTenantIds` explicitly to your workspace\ntenant id:\n\n```ts\nBulwarkModule.forRoot({\n  baseURL: \"https://auth.example.com\",\n  tenant: \"your-app-tenant-id\", // used for X-Bulwark-Tenant on API calls\n  allowedTenantIds: [\"your-workspace-tenant-id\"], // what shared-mode tokens actually carry\n});\n```\n\nA single NestJS service that legitimately accepts tokens from several\nsibling apps or tenants (e.g. a shared backend fronting multiple isolated\napps) can list all of them in `allowedTenantIds`.\n\n> ⚠️ **`dangerouslySkipTenantCheck: true`** disables this check entirely —\n> ANY tenant's validly-signed token is accepted. Logs a warning once at\n> construction. Only appropriate if tenant scoping is enforced some other\n> way (e.g. a `customTokenValidator` upstream, or a reverse proxy that\n> already filters by tenant).\n\n**Know what `allowedTenantIds=[workspace-id]` actually admits.** In shared\nisolation mode, the workspace tenant id is shared by every app in that\nworkspace, and by that workspace's own dashboard/admin logins — not just\nyour app. Setting `allowedTenantIds` to a workspace id accepts a validly-\nsigned token from _any_ of them, not only from your app's own users. If you\nneed per-app isolation within a shared workspace, that requires an\napp-scoped claim Bulwark tokens don't currently carry — the actual fix is\nmigrating the app to isolated mode, not tightening this allowlist further.\n\n**How do I know which isolation mode my app is in?**\n\n- New apps created via the dashboard/settings API are **isolated** by\n  default (Bulwark's `CreateApplication` service sets\n  `isolation_mode: \"isolated\"` explicitly for both the dev and prod tenants\n  it creates).\n- Any tenant created before this feature shipped, or created through a\n  path that doesn't explicitly set `isolation_mode`, defaults to **shared**\n  (the column's SQL default, and `CreateTenantTx`'s own default when the\n  field is left blank).\n- When in doubt, ask a Bulwark admin to check the app's tenant row, or look\n  at a decoded access token from your app: if `tid`/`tenant_id` equals your\n  app's own tenant id, you're isolated; if it equals a different (parent\n  workspace) tenant id shared with sibling apps, you're shared.\n\n### What this can't see: use `useIntrospection` for revocation-sensitive routes\n\nOffline signature verification is stateless — it never contacts Bulwark\nafter the JWKS is cached, so it cannot see anything that happens to the\ntoken or its owner AFTER the token was minted, until the token's own `exp`\n(plus `clockToleranceSec` leeway) passes:\n\n- the user logging out (a revoked session/refresh token);\n- the user being suspended or disabled;\n- a password reset or other event that bumps `users.token_version` (the\n  server's `tv` claim mass-revocation mechanism);\n- an IdP-initiated single-logout (SLO).\n\n`useIntrospection: true` calls `/oauth2/introspect` on every request instead\nand sees all of these immediately, at the cost of a network round-trip per\nvalidation (see **Rate limits and caching** below). For routes where \"still\nlogged in right now\" genuinely matters — not just \"held a token that hadn't\nexpired yet\" — prefer introspection, or keep offline-verified access tokens\nshort-lived.\n\n`validateToken` returns `null` for anything that means the token is\ngenuinely invalid (bad signature, disallowed alg, expired, wrong iss/aud,\nunknown kid, malformed). It throws `BulwarkVerificationUnavailableError`\ninstead if the JWKS endpoint itself couldn't be reached, returned a non-2xx\nstatus, or returned malformed data — that's a Bulwark/network availability\nproblem, never silently treated the same as \"every token happens to be\ninvalid\". `BulwarkAuthGuard` maps this to `503`.\n\n> ⚠️ **Do NOT use `expectedIssuer`/`expectedAudience` to validate OAuth2\n> access tokens or as a relying-party/client scoping mechanism.** OAuth2\n> access tokens carry a single, PLATFORM-WIDE `aud` (`internal/oauth2/\njwt_strategy.go`, stamped from one server-wide `cfg.TokenAudience` value\n> onto every access token this Bulwark instance issues, for every OAuth2\n> client — `client_credentials` grants included) and an `iss` that's the\n> same for the whole instance too. Setting `expectedIssuer`/\n> `expectedAudience` to those values does NOT scope validation to any\n> particular client or relying party — it accepts a token from ANY OAuth2\n> client on the instance, which is very likely not what you want. For\n> OAuth2 access tokens, or anywhere you need genuine per-client/per-RP\n> scoping, use `useIntrospection: true` instead — introspection is\n> client-scoped server-side (RFC 7662 §2.1, Mimir M1/ba1fedfa).\n\n> ⚠️ **`dangerouslySkipSignatureVerification: true`** restores the old\n> unverified-decode behavior (only checks `exp` — anyone can forge a token\n> with an arbitrary `sub`/`roles`/`tenant_id`) — **and disables the tenant\n> binding check above too**, since it only runs as part of signature\n> verification. Logs a warning once at construction. Only appropriate\n> behind a trusted network boundary that already guarantees token\n> authenticity some other way (e.g. a JWKS-verifying proxy/middleware in\n> front of this service) — prefer `useIntrospection` or a\n> `customTokenValidator` otherwise.\n\nTo verify against Bulwark's `/oauth2/introspect` endpoint instead, set\n`useIntrospection: true` and **create a confidential OAuth2 client with\nintrospection enabled**:\n\n1. In Bulwark's admin API (or dashboard, once available), register a\n   confidential OAuth2 client (`client_secret_basic`) for this service. A\n   client dedicated solely to introspection is fine — it never needs any\n   OAuth2 grant type of its own.\n2. Enable that client's `can_introspect` capability. This is **admin-API-only**\n   — it cannot be set via Dynamic Client Registration (RFC 7591) or RFC 7592\n   self-service client configuration, by design (Mimir M1): a self-registered\n   client must never be able to grant itself the ability to probe token\n   liveness.\n3. Pass the client's id/secret to the module:\n\n```ts\nBulwarkModule.forRoot({\n  baseURL: \"https://auth.example.com\",\n  tenant: \"your-tenant-id\",\n  useIntrospection: true,\n  clientId: process.env.BULWARK_INTROSPECT_CLIENT_ID,\n  clientSecret: process.env.BULWARK_INTROSPECT_CLIENT_SECRET,\n});\n```\n\n`useIntrospection: true` without both `clientId` and `clientSecret` throws\n`BulwarkIntrospectionConfigError` immediately at module construction —\n`/oauth2/introspect` is a confidential-client-only endpoint (RFC 7662 §2.1),\nso there is no valid configuration without them.\n\n### Error handling\n\n`validateToken` (introspection mode) returns `null` **only** for a genuine\n\"token not active\" result. Anything that prevents the introspection call\nfrom even answering that question throws instead:\n\n| Condition                                                               | Throws                                 | `BulwarkAuthGuard` maps to |\n| ----------------------------------------------------------------------- | -------------------------------------- | -------------------------- |\n| Wrong `clientId`/`clientSecret`, or `can_introspect` not enabled        | `BulwarkIntrospectionConfigError`      | `401 Unauthorized`         |\n| `429` (rate limited), `5xx`, an unexpected status, or a network failure | `BulwarkIntrospectionUnavailableError` | `503 Service Unavailable`  |\n\nIf you call `bulwarkService.validateToken()` directly (outside the guard),\nwrap it in `try/catch` and handle both error types — do not assume `null` is\nthe only failure mode.\n\n### Access vs. refresh tokens\n\n`/oauth2/introspect` can resolve either an access token or a refresh token\nby signature alone — both come back as `{\"active\":true,...}` with the same\n`sub`/`email`/`roles`/`exp`. `validateToken` only ever authenticates a\n**Bearer access token**: it checks the response's `token_type` field and\nreturns `null` (the same as an inactive token — this is a legitimate\nvalidity result, not an error) unless it is `\"Bearer\"` (checked\ncase-insensitively, since RFC 6749 §7.1 allows either case). A refresh token\npresented to a guard as if it were an access token is therefore rejected,\nnot silently accepted.\n\nThis requires a Bulwark server that emits `token_type` on active\nintrospection responses (Mimir R2-M4) — see the CHANGELOG's server\ncompatibility note before upgrading this SDK independently of the server.\n\n### Rate limits and caching\n\n`/oauth2/introspect` is rate-limited per authenticated `client_id` (default\n600 requests/minute, server-configurable via `BULWARK_RATE_LIMIT_INTROSPECT_CLIENT`),\non top of a coarser per-IP limit that applies before your client even\nauthenticates. If this service introspects on every incoming request under\nmeaningful load, **cache introspection results briefly** (a few seconds is\nusually enough) rather than calling `/oauth2/introspect` once per request —\nboth to stay well under the budget and to reduce latency on your own request\npath.\n\n## Testing\n\n```bash\npnpm test\n```\n","readmeFilename":"README.md"}