|
| 1 | +### Title: AuthPort seam for backend authn/authz |
| 2 | + |
| 3 | +### Proposed by: Kacper Cierzniewski |
| 4 | + |
| 5 | +### Date: 15.05.2026 |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +The reference backend (`apps/backend`) ships **without** authentication. CORS is wide open and any caller can create, read, publish, execute, and cancel workflows. This is deliberate: real authn/authz is consumer-specific (SSO, JWT, OAuth, custom) and the OSS package does not pick one. |
| 10 | + |
| 11 | +This decision log records the structural piece (scope L from [`local-dev-binding.decision-log.md`](./local-dev-binding.decision-log.md)) that lets consumers plug in their own provider without forking the HTTP routes: introduce `AuthPort`, a Hono auth middleware, and a permissive default. |
| 12 | + |
| 13 | +## Decision |
| 14 | + |
| 15 | +1. Introduce `AuthPort` in `apps/backend/src/auth/auth-port.ts` with two methods: |
| 16 | + - `identify(request)` resolves an incoming `Request` to a `CallerIdentity` or `null` (anonymous). |
| 17 | + - `authorize(caller, action, resource)` returns `true` if the caller may perform the action on the resource; `false` otherwise. Throwing is reserved for unexpected failures (e.g. the IdP is down). |
| 18 | +2. Wire HTTP routes through a Hono middleware (`createAuthMiddleware`) that calls `identify` once per request and stashes the caller on `c.var.caller`. Boot code binds the port to an `AssertAuthorized` function via `makeAssertAuthorized(port)` and passes it to each route factory; handlers `await assertAuthorized(...)` before sensitive operations. The function throws `AuthDeniedError` on deny, and `app.onError` in `server.ts` maps the error to 401 (anonymous) or 403 (authenticated but forbidden). |
| 19 | +3. Ship `AllowAllAuthPort` as the permissive default for local development. It returns `null` from `identify` (consistent with the design note "Return null when anonymous") and `true` from `authorize`, together short-circuiting the 401/403 paths so the middleware never denies anything until a real port replaces it. |
| 20 | +4. Refuse to construct `AllowAllAuthPort` unless the operator sets `WB_AUTH_PORT=allow-all` explicitly. This is a default-secure posture: a forgotten env var in CI, a deploy template, or a copy-paste fails loudly instead of silently starting wide-open. There is no "wide everywhere except production" heuristic that a misconfigured `NODE_ENV` can quietly break. A loud startup warning fires whenever the port boots. |
| 21 | + |
| 22 | +## Actions covered today |
| 23 | + |
| 24 | +| Action | Resource | |
| 25 | +| ------------------- | ------------------------------------ | |
| 26 | +| `workflows:create` | `{ kind: 'workflows' }` | |
| 27 | +| `workflows:list` | `{ kind: 'workflows' }` | |
| 28 | +| `workflows:read` | `{ kind: 'workflow', workflowId }` | |
| 29 | +| `workflows:update` | `{ kind: 'workflow', workflowId }` | |
| 30 | +| `workflows:publish` | `{ kind: 'workflow', workflowId }` | |
| 31 | +| `workflows:execute` | `{ kind: 'workflow', workflowId }` | |
| 32 | +| `executions:read` | `{ kind: 'execution', executionId }` | |
| 33 | +| `executions:stream` | `{ kind: 'execution', executionId }` | |
| 34 | +| `executions:cancel` | `{ kind: 'execution', executionId }` | |
| 35 | + |
| 36 | +Per-row resource kinds (`workflow`, `execution`) also accept an optional `attributes: Record<string, unknown>`. Routes that already loaded the row can pass it through so ABAC ports do not need to refetch. Pure RBAC ports ignore the field. Routes that load before authorize is wired (see follow-ups on data scoping) will start using it without a breaking change. |
| 37 | + |
| 38 | +## Alternative Options Considered |
| 39 | + |
| 40 | +- **Skip the port; require consumers to fork the routes.** Rejected. Forces every consumer to maintain a divergent copy of `apps/backend/src/routes/`, which is the surface most likely to evolve. The seam is small (one interface + one middleware) and isolates the consumer's auth code from upstream changes. |
| 41 | +- **Return-based deny (`Response | null`).** Considered and rejected. A missed `if (denied) return denied` silently lets the request through and TypeScript cannot warn about the missing return. The throw-based shape removes the footgun entirely: calling `await assertAuthorized(...)` either proceeds (caller is authorized) or aborts the handler. Cost is one `app.onError` mapping `AuthDeniedError` to 401/403, which is registered once in `server.ts`. |
| 42 | +- **Pass the port through Hono `c.var`.** Rejected. The port is a boot-time singleton, not per-request state. Currying `makeAssertAuthorized(port)` once at boot and handing each route factory the bound `assertAuthorized` removes the temptation to swap the port mid-request. |
| 43 | + |
| 44 | +## Consequences |
| 45 | + |
| 46 | +- **Pros** |
| 47 | + - **Adapter authoring is a one-file change.** Implementing `AuthPort` and swapping the constructor in `server.ts` is all a consumer needs to wire up SSO/JWT/OAuth. |
| 48 | + - **Anonymous semantics are explicit.** `identify` returning `null` signals "unauthenticated" with no ambiguity. Operators replacing `AllowAllAuthPort` with a JWT port will see the 401 branch fire for anonymous requests immediately. |
| 49 | + - **No silent allow.** Handlers cannot accidentally forward a deny `Response` or forget to return early. The throw + `onError` shape removes the entire class of bugs the return-based seam invited. |
| 50 | + - **Default-secure boot.** `AllowAllAuthPort` requires `WB_AUTH_PORT=allow-all`. There is no environment heuristic to misconfigure; the only way to run permissively is to set the variable on purpose. |
| 51 | + |
| 52 | +- **Cons** |
| 53 | + - **No data-plane scoping yet.** The port answers "is this caller allowed to do X to resource Y" but does not yet filter list/read by ownership. The `attributes` slot on per-row resources reserves room for ABAC without forcing a breaking change later; see follow-ups. |
| 54 | + - **Action set is a closed union.** Adding a new route action means editing `AuthAction` in `auth-port.ts`. For a reference implementation with a small surface this is the right trade (every adapter sees every action at compile time); a long-running consumer with many bespoke actions would prefer an open string + runtime guard. |
| 55 | + |
| 56 | +## Default: AllowAllAuthPort |
| 57 | + |
| 58 | +``` |
| 59 | +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! |
| 60 | +!! AuthPort: AllowAllAuthPort (permissive default) is active. |
| 61 | +!! Every request - read, write, publish, execute, cancel - is allowed. |
| 62 | +!! Replace with a real AuthPort before exposing this backend to anyone. |
| 63 | +!! See: apps/backend/auth-port.decision-log.md |
| 64 | +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! |
| 65 | +``` |
| 66 | + |
| 67 | +The constructor throws unless `WB_AUTH_PORT=allow-all` is set explicitly. `apps/backend/.env.example` ships the variable for local development; production environments must remove it (and swap the port) before the process will start. |
| 68 | + |
| 69 | +## Wiring a real provider |
| 70 | + |
| 71 | +Swap the default in `apps/backend/src/server.ts`: |
| 72 | + |
| 73 | +```ts |
| 74 | +import { JwtAuthPort } from './auth/jwt-auth-port'; |
| 75 | + |
| 76 | +const authPort: AuthPort = new JwtAuthPort({ |
| 77 | + issuer: process.env.AUTH_ISSUER!, |
| 78 | + audience: process.env.AUTH_AUDIENCE!, |
| 79 | + jwksUri: process.env.AUTH_JWKS_URI!, |
| 80 | +}); |
| 81 | +``` |
| 82 | + |
| 83 | +The startup warning and the opt-in env check are local to `AllowAllAuthPort`, so this swap automatically disables both. Remove `WB_AUTH_PORT=allow-all` from the deploy environment at the same time. |
| 84 | + |
| 85 | +## SSE / EventSource auth caveats |
| 86 | + |
| 87 | +`GET /api/executions/:id/stream` is the long-lived SSE endpoint that drives the live execution view in `apps/ai-studio` and `apps/demo`. Browser `EventSource` cannot attach custom request headers, so a JWT-bearer adapter that reads `Authorization` will not work for this endpoint out of the box. Two supported fallbacks: |
| 88 | + |
| 89 | +1. **Cookie session.** If your IdP issues a session cookie (SameSite=Lax for same-origin, set via the login flow), the cookie rides every browser request including SSE. The port reads from `request.headers.get('cookie')` in `identify`. |
| 90 | +2. **Short-lived token in the URL.** Mint a single-use token from your authenticated frontend (e.g. `POST /api/exec-stream-token` returns `{ token, expiresAt }`), then open `new EventSource('/api/executions/' + id + '/stream?access_token=' + token)`. The port parses the token from `new URL(request.url).searchParams.get('access_token')`. Treat the token as bearer-equivalent: short TTL, single resource, log usage. Be aware that URLs end up in access logs, browser history, and Referer headers; the token must be short-lived enough that exposure is acceptable. |
| 91 | + |
| 92 | +Token expiry mid-stream is not handled by the seam. `identify` runs once per request, so a 1-hour SSE connection authorized at minute zero stays open through token expiry. Adapters that care should also wire periodic re-auth on the stream callback (`subscribe` in `events/execution-event-bus.ts`), or accept that long-lived streams outlive their tokens. The reference backend does not implement this today. |
| 93 | + |
| 94 | +## CORS for a real port |
| 95 | + |
| 96 | +`server.ts` mounts `cors()` with its defaults: `origin: '*'`, `credentials: false`, no explicit `allowHeaders`. That works for the permissive default but breaks both common real-port shapes: |
| 97 | + |
| 98 | +- **Bearer JWT adapters.** Browser preflight strips `Authorization` unless the server returns it in `Access-Control-Allow-Headers`. Configure `cors({ allowHeaders: ['Authorization', 'Content-Type'], origin: <your frontend origin> })`. |
| 99 | +- **Cookie session adapters.** Cookies require `credentials: true` on the server (`Access-Control-Allow-Credentials: true`) and matching `withCredentials: true` on `fetch` / `EventSource`. The wildcard origin is also illegal once credentials are on; you must list the frontend origin(s) explicitly. |
| 100 | + |
| 101 | +Both are operator-side concerns, but plan the CORS change at the same time you swap the port - shipping a real `AuthPort` without updating CORS produces a baffling "preflight rejected" error. |
| 102 | + |
| 103 | +## Example: simple JWT verification |
| 104 | + |
| 105 | +Sketch that validates a bearer token against a JWKS endpoint and authorizes by role, using [`jose`](https://github.com/panva/jose) (`pnpm add jose`). The interesting bit is the failure handling in `identify`, see the note below the code. |
| 106 | + |
| 107 | +```ts |
| 108 | +// apps/backend/src/auth/jwt-auth-port.ts |
| 109 | +import { type JWTPayload, createRemoteJWKSet, errors as joseErrors, jwtVerify } from 'jose'; |
| 110 | + |
| 111 | +import type { AuthAction, AuthPort, AuthResource, CallerIdentity } from './auth-port'; |
| 112 | + |
| 113 | +type Options = { |
| 114 | + issuer: string; |
| 115 | + audience: string; |
| 116 | + jwksUri: string; |
| 117 | +}; |
| 118 | + |
| 119 | +export class JwtAuthPort implements AuthPort { |
| 120 | + private readonly jwks: ReturnType<typeof createRemoteJWKSet>; |
| 121 | + |
| 122 | + constructor(private readonly options: Options) { |
| 123 | + this.jwks = createRemoteJWKSet(new URL(options.jwksUri)); |
| 124 | + } |
| 125 | + |
| 126 | + async identify(request: Request): Promise<CallerIdentity | null> { |
| 127 | + const header = request.headers.get('authorization'); |
| 128 | + if (!header?.startsWith('Bearer ')) return null; |
| 129 | + const token = header.slice('Bearer '.length); |
| 130 | + |
| 131 | + try { |
| 132 | + const { payload } = await jwtVerify(token, this.jwks, { |
| 133 | + issuer: this.options.issuer, |
| 134 | + audience: this.options.audience, |
| 135 | + }); |
| 136 | + return toIdentity(payload); |
| 137 | + } catch (error) { |
| 138 | + // Token-shaped failures (invalid signature, expired, wrong audience) are |
| 139 | + // anonymous requests, the caller may retry with a fresh token. |
| 140 | + if (error instanceof joseErrors.JOSEError) return null; |
| 141 | + // Anything else (network blip fetching JWKS, JSON parse, programmer bug) |
| 142 | + // is operational, let it surface as a 500 so monitoring catches it. |
| 143 | + // Swallowing it here would mask outages as "stream of anonymous users". |
| 144 | + throw error; |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + async authorize(caller: CallerIdentity | null, action: AuthAction, _resource: AuthResource): Promise<boolean> { |
| 149 | + if (!caller) return false; |
| 150 | + |
| 151 | + // `roles` is a provider-specific custom claim, not part of RFC 7519. |
| 152 | + // Auth0 surfaces it via a namespaced claim (e.g. |
| 153 | + // `https://your-app/roles`), Cognito as `cognito:groups`, Keycloak as |
| 154 | + // `realm_access.roles`. Replace `roles` below with whatever shape your |
| 155 | + // IdP issues and adjust `toIdentity` to flatten it onto `attributes`. |
| 156 | + const roles = (caller.attributes?.roles as string[] | undefined) ?? []; |
| 157 | + |
| 158 | + // Reads are open to any authenticated user. |
| 159 | + if (action.endsWith(':read') || action === 'workflows:list' || action === 'executions:stream') { |
| 160 | + return true; |
| 161 | + } |
| 162 | + |
| 163 | + // Writes require the `editor` role. |
| 164 | + if (action.startsWith('workflows:')) { |
| 165 | + return roles.includes('editor'); |
| 166 | + } |
| 167 | + |
| 168 | + // Execution control requires the `operator` role. |
| 169 | + if (action === 'executions:cancel') { |
| 170 | + return roles.includes('operator'); |
| 171 | + } |
| 172 | + |
| 173 | + return false; |
| 174 | + } |
| 175 | +} |
| 176 | + |
| 177 | +function toIdentity(payload: JWTPayload): CallerIdentity | null { |
| 178 | + if (!payload.sub) return null; |
| 179 | + return { |
| 180 | + subject: payload.sub, |
| 181 | + attributes: { |
| 182 | + roles: payload.roles, |
| 183 | + email: payload.email, |
| 184 | + }, |
| 185 | + }; |
| 186 | +} |
| 187 | +``` |
| 188 | + |
| 189 | +## Design notes |
| 190 | + |
| 191 | +- **The port does not throw for denied access.** Returning `false` keeps the failure path concentrated in one place (the middleware throws `AuthDeniedError`, `onError` translates) and lets adapters defer the decision without inventing exception types. |
| 192 | +- **`identify` runs once per request**, in middleware. `assertAuthorized` runs at each sensitive operation. This lets you key on the request itself (headers, cookies, mTLS peer) once, then make per-resource decisions cheaply. |
| 193 | +- **`identify` failure handling distinguishes shape vs operational errors.** Invalid tokens become `null` (anonymous). Infrastructure failures throw, so monitoring sees them. Catching everything as `null` would mask an IdP outage as a flood of anonymous requests. |
| 194 | +- **Resources are structured, not strings.** A `{ kind: 'workflow', workflowId, attributes? }` tuple is easier to grow than a `'workflow:<id>'` string convention, and the `attributes` slot reserves space for ABAC without breaking the interface later. |
| 195 | +- **`AllowAllAuthPort` requires explicit opt-in.** A misconfigured `NODE_ENV` can no longer accidentally enable the permissive path; only an explicit `WB_AUTH_PORT=allow-all` does. |
| 196 | + |
| 197 | +## Follow-ups |
| 198 | + |
| 199 | +WB-184 delivers the port + middleware halves of scope L from [`local-dev-binding.decision-log.md`](./local-dev-binding.decision-log.md). The remaining pieces are tracked separately: |
| 200 | + |
| 201 | +- **Schema migration** adding `users`, `tenants`, `ownerId` columns to `workflows` and `executions`. |
| 202 | +- **Route-level data scoping** (filter `workflows:list` by ownership, restrict `workflows:read` / `execute` to owned rows or shared via ACL). The route order will flip to "load row, then assertAuthorized with row in `attributes`" so ABAC ports do not refetch. |
| 203 | +- **SSE token refresh** for long-lived `executions:stream` connections that outlive their auth token. Today the port runs once at connect time; a periodic re-check inside the `subscribe` callback would close the gap. |
| 204 | + |
| 205 | +## Status |
| 206 | + |
| 207 | +Accepted |
0 commit comments