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}`.
## 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}` },
})
```
## 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.21383
- Tokens
- ~2,433
- Updated
- 2026-06-24
platformauthjwtoauthbio-idrs256nextjs