← Back to Skills

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.md
Download .md

Coming soon via CLI:

tawa chaac install tawa-oauth

Details

Format
Rule
Category
provision
Version
1.0.70411
Tokens
~5,260
Updated
2026-10-13
platformauthjwtoauthbio-idrs256nextjs