← Back to Skills

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

Coming soon via CLI:

tawa chaac install tawa-auth

Details

Format
Command
Category
provision
Version
1.0.80861
Tokens
~1,584
Updated
2026-06-24
platform