# 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)
```
