Authentication on Tawa
Without this, `BIO_CLIENT_ID` and `BIO_CLIENT_SECRET` are never injected. Your service cannot log users in.
Ruleprovision
About
Without this, `BIO_CLIENT_ID` and `BIO_CLIENT_SECRET` are never injected. Your service cannot log users in.
Skill Content
This is the raw markdown that gets installed as a Claude Code rule.
# Authentication on Tawa
## The Short Version
- **To enable user login**, declare `spec.auth: mode: sso` in catalog-info.yaml — that's what injects `BIO_CLIENT_ID` and `BIO_CLIENT_SECRET`.
- **Routes registered in catalog-info.yaml** with `auth: required` → Janus verifies tokens for you. No SDK needed.
- **Services with custom auth middleware** (e.g. Next.js) → use `verifyTokenJWKS(token)`. No secret needed.
- **Service-to-service** → use `BioAuth.fromEnv().getClientCredentialsToken(scopes)`.
- **NEVER use `JWT_SECRET`** — that was HS256. Bio-ID 0.3.0+ issues RS256 tokens.
## Step 1: Enable OAuth in catalog-info.yaml
Without this, `BIO_CLIENT_ID` and `BIO_CLIENT_SECRET` are never injected. Your service cannot log users in.
```yaml
spec:
auth:
mode: sso # REQUIRED for user login
```
If you deploy without `spec.auth: mode: sso`, the build log will say:
```
OAuth skipped (spec.auth not configured)
```
and all calls to `BioAuth.fromEnv()` will throw `BIO_CLIENT_ID is required`.
> **Common mistake:** Adding `bio-id` to `internalDependencies` does NOT enable OAuth.
> `BIO_ID_URL` is always auto-injected as a core platform variable — no declaration needed.
## How It Works
Bio-ID issues RS256-signed JWTs. The private key lives only in Bio-ID. Consuming services verify using Bio-ID's public JWKS endpoint — no shared secret.
```
Developer → OAuth flow → Bio-ID → RS256 JWT
JWT → request header → Janus (verifies via JWKS) → your service
```
## When You DON'T Need the SDK
If your service registers routes in `catalog-info.yaml` with `auth: required`, Janus verifies the token before proxying. Your handler receives a verified request. Most Express/Hono/Fastify services never call `verifyTokenJWKS()` directly.
```yaml
spec:
routes:
- path: /api/my-service/data
methods: [GET]
auth: required # Janus handles verification
```
## When You DO Need the SDK
Services with their own auth middleware — primarily **Next.js** — need to verify tokens themselves because requests arrive before Janus can proxy them.
```typescript
import { verifyTokenJWKS } from '@insureco/bio'
// In middleware or route handler:
const token = req.headers.authorization?.slice(7)
if (!token) return res.status(401).json({ error: 'Unauthorized' })
const payload = await verifyTokenJWKS(token)
// payload.bioId, payload.orgSlug, payload.roles, payload.email
```
`BIO_ID_URL` is auto-injected by the builder on every deploy. `verifyTokenJWKS()` reads it automatically.
## OAuth Flow (user login)
```typescript
import { BioAuth } from '@insureco/bio'
// All env vars auto-injected: BIO_CLIENT_ID, BIO_CLIENT_SECRET, BIO_ID_URL
const bio = BioAuth.fromEnv()
// 1. Build authorization URL
const { url, state, codeVerifier } = bio.getAuthorizationUrl({
redirectUri: `${process.env.APP_URL}/api/auth/callback`,
})
// Store state + codeVerifier in httpOnly cookies, redirect user to url
// 2. Handle callback — MUST be at /api/auth/callback (builder registers this exact path)
const tokens = await bio.exchangeCode(code, codeVerifier, redirectUri)
// Store tokens.access_token and tokens.refresh_token
// 3. Refresh when expired
const newTokens = await bio.refreshToken(refreshToken)
```
**Critical:** Your callback MUST be at `/api/auth/callback`. The builder registers this exact path. Any other path fails with "Invalid Redirect URI".
### Redirect URIs are merged on deploy (not overwritten)
On every deploy the builder syncs your OAuth client's redirect URIs. It auto-derives:
- the platform hostname: `https://{service}.{env-prefix}{PLATFORM_DOMAIN}/api/auth/callback`
- any **verified custom domains** for that environment
These derived URIs are **merged** with whatever is already registered on the client — the deploy only ever **adds** URIs, it never removes them. This means redirect URIs you add manually (e.g. a `http://localhost:PORT/api/auth/callback` dev callback added with `tawa oauth add-uri`) survive across deploys instead of being clobbered.
```bash
# Add a local dev callback once — it now persists across deploys
tawa oauth add-uri {service}-{env} http://localhost:9010/api/auth/callback
```
Tradeoff: because deploys never strip URIs, removing a verified custom domain does **not** auto-remove its callback URI from the client. Remove a stale URI explicitly with `tawa oauth remove-uri {client-id} {uri}`.
### Allowed scopes are merged on deploy too (not overwritten)
The same applies to the client's **allowed scopes** and **grant types**. The builder derives
`openid, profile, email` plus any scopes your `spec.dependencies` declare, and **merges** that with
whatever the client already holds in Bio-ID. A deploy only ever **adds** — so a scope an admin
granted out of band survives every subsequent deploy:
```bash
tawa oauth update {service}-{env} --scopes "openid,profile,email,personas:mint"
tawa deploy --prod # personas:mint is still there afterwards
```
When a deploy preserves something the catalog does not derive, it says so in the build log (names
only):
```
OAuth scopes preserved from Bio-ID (not derived from the catalog): personas:mint
```
Same tradeoff as URIs: removal is an **explicit admin action**. Dropping a dependency from
`catalog-info.yaml` does not revoke its scope — remove it with `tawa oauth update {client-id}
--scopes "…"` naming the list you want.
If Bio-ID cannot be read during the sync, the deploy sends **neither** list rather than risk
narrowing the client on a transient error, and warns in the build log. Fail-open, like the rest of
the pipeline.
> **Fixed 2026-09-20 (iec-builder#159).** Before this, the deploy sent its derived scope list
> verbatim, and Bio-ID's PATCH replaces the whole list — so **every deploy of every service silently
> revoked any scope an admin had granted its client**. `iec-test-prod` lost `personas:mint` minutes
> after it was granted, and its `client_credentials` request started failing with `invalid_scope`.
> If you granted a scope before that date and it vanished, grant it again; it will stick now.
Catalog-declared fields are **not** merged — `spec.auth.passport` (`includeBranding`,
`includeIecHash`, `headless`, `allowedEmbedOrigins`) is written from the catalog on every deploy,
because the catalog is their source of truth.
### Who may manage an OAuth client (`tawa oauth …`, `/oauth/clients`)
The builder's `/oauth/*` routes proxy to Bio-ID's admin API with the builder's own key, so
the builder decides who may touch which client (iec-builder#224). Before that fix any signed-in
user of any org could list, read, patch, regenerate or delete **every** client.
| Caller | May do |
|--------|--------|
| `super_admin` (and the internal key tawa-web uses) | everything |
| Anyone else | only clients **their org owns** |
A client is owned by your org when its Bio-ID record names your org (`orgSlug`), **or** it
belongs to a builder service your org owns or is an [authorized org](authorized-orgs.md) of
(matched by the stored credential's client id, else `{service}-{env}`). So
`tawa oauth add-uri / get / update / regenerate-secret` on your own service keeps working.
- **Not yours = `404`**, not `403`, so a client's existence is not disclosed.
- **List** (`GET /oauth/clients`) returns only the clients you own. A client you created by hand
is listed once Bio-ID's record names your org; the builder stamps your org on every create.
- **Create** (`POST /oauth/clients`) is for your own org only, with scopes limited to
`openid profile email` and grants to `authorization_code refresh_token`. Anything else, or
another org, needs `super_admin` (`403`).
- **Update** (`PATCH`): `allowedScopes`, `allowedGrantTypes`, `trustedClient` and
`autoApproveScopes` need `super_admin` (even to narrow). `headlessEnabled`,
`selfServiceEmail` and `passportConfig` flags need `super_admin` to turn **on**; an owner may
turn them off. Redirect URIs, name, description, TTLs, branding, embed origins and custom
domains are the owner's to edit. A deploy still provisions the scopes your catalog derives
(that path does not go through these routes).
### The builder owns the client secret — don't set it manually
On first deploy the builder creates the OAuth client in Bio-ID, receives the secret, and stores it (encrypted) on the service record. Subsequent deploys re-inject that stored secret. **Do not** set `BIO_CLIENT_SECRET` with `tawa config set` — a manual override drifts from Bio-ID's record and breaks login with `invalid_client`. If you have a stale manual override, remove it (`tawa config unset BIO_CLIENT_SECRET`) and redeploy so the builder's value flows through.
### Self-healing a deleted / out-of-band-rotated client
If the OAuth client is deleted from Bio-ID (or its secret is regenerated out-of-band, e.g. via the Bio-ID admin UI or `tawa oauth regenerate-secret`), the builder's stored secret becomes orphaned. The builder detects this on the next deploy: when the redirect-URI sync finds the client missing in Bio-ID, it **recreates the client and overwrites the stored credential** with the freshly issued secret — login recovers automatically with no manual intervention.
`tawa oauth delete {client-id}` also **clears the builder's stored credential** for that client, so the next deploy provisions a clean client rather than re-injecting the deleted one.
> The one case the builder cannot auto-detect is an out-of-band secret rotation where the client still **exists** (the redirect-URI sync succeeds, so there's no missing-client signal). If login fails with `invalid_client` but the client is present, force a clean reprovision with `tawa oauth delete {client-id}` followed by `tawa deploy` — the delete clears the stored credential, and the deploy recreates the client and stores the matching secret.
## Next.js App Router Patterns
### Reading the token in API routes
Always pass `request` to your auth helper. Without it, Next.js falls back to `cookies()` which is unreliable in some App Router contexts:
```typescript
// ✅ CORRECT — pass request explicitly
export async function GET(request: Request) {
const user = await getAuthUser(request) // reads Authorization header first, then cookies
if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 })
// ...
}
// ❌ WRONG — no request passed, relies on cookies() fallback
export async function GET() {
const user = await getAuthUser() // may fail in certain App Router contexts
}
```
### Preventing redirect loops
Only auto-redirect to login from your main dashboard or landing page. Sub-pages should show an error or "Log in" button instead:
```typescript
// ✅ dashboard/page.tsx — auto-redirect is fine here
'use client'
export default function Dashboard() {
const { user, loading } = useAuth()
// Loop guard: if we just came back from OAuth and still have no user, something is broken
const searchParams = useSearchParams()
const authFresh = searchParams.get('auth') === 'fresh'
if (!loading && !user) {
if (authFresh) {
// Already tried OAuth — show error instead of redirecting again
return <div>Login failed. Please try again.</div>
}
window.location.href = '/api/auth/login'
return null
}
// ...
}
// ✅ leads/[id]/page.tsx — show inline error on auth failure, don't redirect
export default function LeadDetail() {
const { user, loading } = useAuth()
if (!loading && !user) {
return (
<div>
<p>You need to log in to view this page.</p>
<a href="/api/auth/login">Log in</a>
</div>
)
}
// ...
}
```
After OAuth callback, redirect to `/?auth=fresh` (or `/dashboard?auth=fresh`) so the loop guard can detect a broken auth cycle.
### Authorization header fallback (custom domain cookie issues)
On custom domains, httpOnly cookies may not persist reliably across requests in some Cloudflare + Next.js standalone configurations. A robust pattern is to capture the token in `sessionStorage` on first load and forward it via `Authorization` header:
```typescript
// In your auth helper — check Authorization header first, then fall back to cookies
export async function getAuthUser(request: Request): Promise<User | null> {
let token: string | undefined
// 1. Check Authorization header (from client sessionStorage fallback)
const authHeader = request.headers.get('authorization')
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.slice(7)
}
// 2. Fall back to cookies
if (!token) {
token = request.cookies.get('bd-token')?.value
}
if (!token) return null
// IMPORTANT: always read refresh token from cookies regardless of above
const refreshToken = request.cookies.get('bd-refresh')?.value
try {
const payload = await verifyTokenJWKS(token)
return { ...payload, refreshToken }
} catch {
return null
}
}
```
Client-side — capture token on first load while cookies still work, then use `Authorization` header for all subsequent requests:
```typescript
// On app init / layout — capture token while cookies work
useEffect(() => {
if (sessionStorage.getItem('auth-token')) return // already have it
fetch('/api/auth/me', { credentials: 'include' })
.then(r => r.json())
.then(r => {
if (r.token) sessionStorage.setItem('auth-token', r.token)
})
.catch(() => {})
}, [])
// All API calls
async function apiFetch(path: string, options?: RequestInit) {
const token = sessionStorage.getItem('auth-token')
return fetch(path, {
...options,
credentials: 'include',
headers: {
...options?.headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
}
```
## Service-to-Service Auth
```typescript
const bio = BioAuth.fromEnv()
const { access_token } = await bio.getClientCredentialsToken(['target-service:scope'])
await fetch(`${process.env.TARGET_URL}/api/endpoint`, {
headers: { Authorization: `Bearer ${access_token}` },
})
```
## Headless auth emails (white-label clients)
A headless client (`headlessEnabled`) calling `/api/embed/*` gets Bio-ID's transactional
emails — password reset, magic link, verification — under **its own** branding. Set
`whiteLabel.emailFromName`, `logoUrl` and `primaryColor` on the client in the Tawa
console (**Console → OAuth → your client**) and every email sent for that client uses
them; no code change.
Templates can also be overridden per client or per org in Bio-ID's admin
(**Email Templates → Add override**). Resolution is most-specific-first:
```
client override → org override → platform default
```
### Sending the email yourself
To send your own mail instead, ask for the token rather than the send:
```jsonc
// POST /api/embed/forgot-password
{
"email": "[email protected]",
"sendEmail": false, // skip Bio-ID's email
"resetBaseUrl": "https://app.example.com/reset" // optional: your own reset page
}
// → { "sent": false, "resetToken": "…", "resetUrl": "…", "expiresAt": "…" }
```
Two requirements, both enforced server-side:
| Requirement | Why |
|---|---|
| `selfServiceEmail` enabled on the client | Opt-in — this hands out a credential |
| `X-Client-Secret` (server-to-server) auth | Origin auth only proves the call came from an allowed page, which any visitor can do. Returning a reset token there would be account takeover. |
A browser-origin call with `sendEmail: false` is refused with `403 SELF_SEND_REQUIRES_SECRET`.
#### Turning `selfServiceEmail` on
It is a per-client toggle in the console, in the same **headless** block as
`headlessEnabled` and `allowedEmbedOrigins` (**Console → OAuth → your client**), because it
only means anything for a headless client. Enabling it lets that client **receive
password-reset tokens over the API** — a credential-issuing capability, not a formatting
preference — so grant it to a client you would trust with a reset link, and leave it off
otherwise. Turning it on requires `super_admin` (see [Who may manage an OAuth client](#who-may-manage-an-oauth-client-tawa-oauth--oauthclients)).
The console's write goes through the builder: `tawa-web → PATCH /oauth/clients/:clientId →
UpdateClientSchema → Bio-ID`. `UpdateClientSchema` is a **strict allowlist and zod strips
anything it does not declare, silently** — so a client setting that is missing from it
returns `200` and changes nothing. If a toggle in the console appears to do nothing, check
that schema before looking anywhere else.
The **read** path has no such field gate: the builder returns Bio-ID's client object verbatim on
`GET /oauth/clients[/:clientId]` (ownership-filtered, see above — it maps and projects nothing) so whatever Bio-ID stored
for a client is what the console renders. That asymmetry is the diagnostic. A setting that
reads back as you left it, after a `200` on the write, was dropped on the way **in**.
This mode deliberately reveals whether the account exists — you cannot send your own
email otherwise. Every other path keeps enumeration protection.
### Trusted invite signup (one email, no Bio-ID emails)
When your app already proved the person owns their email, because they clicked an invite
link **you** emailed, a headless client can create their account with the password they
choose on **your** page. Bio-ID sends no email, and the account is signed in immediately.
iec-split-pay's partner onboarding uses this: one branded welcome email, then a
choose-your-password page.
Three things must all hold, and each is enforced server-side:
| Requirement | Why |
|---|---|
| `trustedInviteSignup: true` on the client | Opt-in. It lets the client assert "this email is verified" |
| `X-Client-Id` + `X-Client-Secret` (server-to-server) | Never browser-origin |
| An **approved `org:manage` org-grant** for this client on the target org | Per org, not a scope. The target org can never be `insureco` |
```jsonc
// POST {BIO_ID_URL}/api/embed/signup
{ "email": "…", "password": "…", "firstName": "…", "lastName": "…",
"orgSlug": "acme", "emailVerified": true }
// 201 → { access_token, refresh_token, token_type, expires_in, scope, user: {…} }
// 409 USER_EXISTS → see set-initial-password below
```
- **No roles.** Roles and permissions live on the org, not the user. A `roles` field,
even `[]`, is refused with `400 ROLES_NOT_ACCEPTED`, and the user gets a plain membership.
- **Refresh through the standard endpoint**, `POST /api/oauth/token` with
`grant_type=refresh_token`, not `/api/embed/refresh`.
**Accounts provisioned before the person arrived.** An account created headlessly by
`provision-org`, with a temporary password nobody knows, returns `USER_EXISTS` from
signup. Call `POST /api/embed/set-initial-password` with `{ email, orgSlug, password }`,
under the same three requirements. It works **once**, and only on an account that:
- carries Bio-ID's `onboardingPending` marker. Only `provision-org` sets it, and any human
login, password change or identity link clears it;
- has never logged in and is a member of that org through this client.
Anything else returns a uniform `403 NOT_ELIGIBLE`. Send the person to normal sign-in.
#### Turning `trustedInviteSignup` on
**Only an insureco super_admin, in their own session, can set it.** The builder, the
console (`UpdateClientSchema` does not carry it), service keys and the internal key are
all refused. With a Bio-ID session open at `bio.tawa.pro`, run this in the browser console:
```js
await fetch('/api/admin/oauth-clients/<clientId>', { method: 'PATCH', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trustedInviteSignup: true }) }).then(r => r.status) // 200
```
Grant it only to a client whose secret you would trust to create verified users in the
orgs it manages. That is exactly what the secret can do once the flag is on.
## Environment Variables (auto-injected)
| Variable | When injected | Purpose |
|----------|--------------|---------|
| `BIO_CLIENT_ID` | When `spec.auth: mode: sso` or `service-only` | Your service's OAuth client ID |
| `BIO_CLIENT_SECRET` | When `spec.auth: mode: sso` or `service-only` | Your service's OAuth client secret |
| `BIO_ID_URL` | **Always** — core platform variable | Bio-ID base URL. No declaration needed. |
## What NOT to Do
```typescript
// ❌ WRONG: HS256 with shared secret
import { verifyToken } from '@insureco/bio'
const payload = verifyToken(token, process.env.JWT_SECRET) // throws on RS256 tokens
// ❌ WRONG: JWT_SECRET env var — not used with RS256
JWT_SECRET=some-secret-value
// ❌ WRONG: BIO_ID_BASE_URL — renamed to BIO_ID_URL
BIO_ID_BASE_URL=https://bio.tawa.insureco.io
// ❌ WRONG: internalDependencies: bio-id to enable OAuth — only injects BIO_ID_URL (already injected)
internalDependencies:
- service: bio-id # does NOT give you BIO_CLIENT_ID or BIO_CLIENT_SECRET
// ✅ CORRECT: spec.auth to enable OAuth
spec:
auth:
mode: sso
// ✅ CORRECT: verifyTokenJWKS with no secret
import { verifyTokenJWKS } from '@insureco/bio'
const payload = await verifyTokenJWKS(token)
```
Install
Copy the skill content and save it to:
~/.claude/rules/tawa-oauth.mdComing soon via CLI:
tawa chaac install tawa-oauthDetails
- Format
- Rule
- Category
- provision
- Version
- 1.0.70411
- Tokens
- ~5,260
- Updated
- 2026-10-13
platformauthjwtoauthbio-idrs256nextjs