Tawa Auth — Next.js Bio-ID Integration
The complete Bio-ID OAuth implementation for Next.js services on Tawa. Use this when building or debugging login flows.
Commandprovision
About
The complete Bio-ID OAuth implementation for Next.js services on Tawa. Use this when building or debugging login flows.
Skill Content
This is the raw markdown that gets installed as a Claude Code command.
# Tawa Auth — Next.js Bio-ID Integration
## What this skill covers
The complete Bio-ID OAuth implementation for Next.js services on Tawa. Use this when building or debugging login flows.
## Step 0: Enable it in catalog-info.yaml
Nothing works without this. No OAuth credentials = no login.
```yaml
spec:
auth:
mode: sso # injects BIO_CLIENT_ID, BIO_CLIENT_SECRET on every deploy
```
If you don't have this, `tawa logs --build <id>` will say `OAuth skipped (spec.auth not configured)`.
## The Auth Helper
Put this in `src/lib/auth.ts`. It checks Authorization header first (for custom domain reliability), then cookies:
```typescript
import { verifyTokenJWKS } from '@insureco/bio'
import { cookies } from 'next/headers'
export interface AuthUser {
bioId: string
email: string
orgSlug: string
roles: string[]
}
export async function getAuthUser(request?: Request): Promise<AuthUser | null> {
let token: string | undefined
// 1. Authorization header (reliable on custom domains where cookies may not persist)
if (request) {
const authHeader = request.headers.get('authorization')
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.slice(7)
}
}
// 2. Cookie fallback
if (!token) {
const cookieStore = cookies()
token = cookieStore.get('auth-token')?.value
}
if (!token) return null
try {
const payload = await verifyTokenJWKS(token)
return {
bioId: payload.bioId as string,
email: payload.email as string,
orgSlug: payload.orgSlug as string,
roles: (payload.roles as string[]) ?? [],
}
} catch {
return null
}
}
```
## API Routes — Always Pass request
```typescript
// ✅ CORRECT
export async function GET(request: Request) {
const user = await getAuthUser(request)
if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 })
// ...
}
// ❌ WRONG — falls back to cookies() which is unreliable in App Router
export async function GET() {
const user = await getAuthUser()
}
```
## Login + Callback Routes
```typescript
// app/api/auth/login/route.ts
import { BioAuth } from '@insureco/bio'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET() {
const bio = BioAuth.fromEnv()
const { url, state, codeVerifier } = bio.getAuthorizationUrl({
redirectUri: `${process.env.APP_URL}/api/auth/callback`,
})
const cookieStore = cookies()
cookieStore.set('oauth-state', state, { httpOnly: true, secure: true, maxAge: 600 })
cookieStore.set('oauth-verifier', codeVerifier, { httpOnly: true, secure: true, maxAge: 600 })
redirect(url)
}
// app/api/auth/callback/route.ts ← MUST be at this exact path
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const cookieStore = cookies()
const codeVerifier = cookieStore.get('oauth-verifier')?.value
if (!code || !codeVerifier) {
return redirect('/?error=login_failed')
}
try {
const bio = BioAuth.fromEnv()
const tokens = await bio.exchangeCode(code, codeVerifier, `${process.env.APP_URL}/api/auth/callback`)
const response = redirect('/dashboard?auth=fresh')
// Set cookies on the response
cookieStore.set('auth-token', tokens.access_token, {
httpOnly: true, secure: true, sameSite: 'lax', maxAge: 3600, path: '/'
})
cookieStore.set('refresh-token', tokens.refresh_token, {
httpOnly: true, secure: true, sameSite: 'lax', maxAge: 2592000, path: '/'
})
return response
} catch {
return redirect('/?error=login_failed')
}
}
```
## Preventing Redirect Loops
Only the main entry page should auto-redirect. Sub-pages show a button:
```typescript
// app/dashboard/page.tsx — auto-redirect OK here
'use client'
export default function Dashboard() {
const searchParams = useSearchParams()
const { user, loading } = useAuth()
if (!loading && !user) {
// Loop guard: if ?auth=fresh is set and we still have no user, show error
if (searchParams.get('auth') === 'fresh') {
return <div>Login failed. <a href="/api/auth/login">Try again</a></div>
}
window.location.href = '/api/auth/login'
return null
}
return <DashboardContent user={user} />
}
// app/leads/[id]/page.tsx — show login button, never auto-redirect
'use client'
export default function LeadDetail() {
const { user, loading } = useAuth()
if (!loading && !user) {
return <div>You need to <a href="/api/auth/login">log in</a> to view this.</div>
}
return <LeadContent />
}
```
## Client-Side Token Forwarding (Custom Domain Reliability)
If you're on a custom domain (e.g., `myapp.binddesk.com`) cookies may vanish intermittently. Capture the token in `sessionStorage` on first load:
```typescript
// In your root layout or auth context
useEffect(() => {
if (sessionStorage.getItem('auth-token')) return
fetch('/api/auth/me', { credentials: 'include' })
.then(r => r.json())
.then(r => { if (r.token) sessionStorage.setItem('auth-token', r.token) })
.catch(() => {})
}, [])
// Wrapper for all API fetches — adds Authorization header when available
export function apiFetch(path: string, init?: RequestInit) {
const token = sessionStorage.getItem('auth-token')
return fetch(path, {
...init,
credentials: 'include',
headers: {
...init?.headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
}
```
Add a `/api/auth/me` route that returns the current user + token:
```typescript
export async function GET(request: Request) {
const user = await getAuthUser(request)
if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 })
const token = request.headers.get('authorization')?.slice(7)
?? request.cookies.get('auth-token')?.value
return Response.json({ user, token })
}
```
## Checklist
- [ ] `spec.auth: mode: sso` in catalog-info.yaml
- [ ] Callback at `/api/auth/callback` (exact path)
- [ ] `getAuthUser(request)` — always pass request in API routes
- [ ] Dashboard has loop guard (`?auth=fresh` detection)
- [ ] Sub-pages show login button, never auto-redirect
- [ ] Client uses `apiFetch()` with Authorization header fallback
- [ ] `APP_URL` env var set via `tawa config set APP_URL=https://myapp.tawa.insureco.io`
Install
Copy the skill content and save it to:
~/.claude/commands/tawa-auth.mdComing soon via CLI:
tawa chaac install tawa-authDetails
- Format
- Command
- Category
- provision
- Version
- 1.0.80861
- Tokens
- ~1,584
- Updated
- 2026-06-24
platform