OAuth Integration

Every service deployed on Tawa gets an OAuth client automatically. Use the @insureco/bio SDK for the simplest integration — it handles PKCE, token exchange, refresh, and userinfo with zero config.

Quick Start with SDK

Install the SDK and initialize it from auto-injected environment variables:

npm install @insureco/bio
import { BioAuth } from '@insureco/bio'

// Reads BIO_CLIENT_ID, BIO_CLIENT_SECRET, BIO_ID_URL from env
const bio = BioAuth.fromEnv()

That's it. The builder injects BIO_CLIENT_ID and BIO_CLIENT_SECRET automatically on every deploy. The SDK defaults to https://bio.tawa.insureco.io as the issuer.

Login Route

Build an authorization URL with PKCE and redirect the user to Bio-ID. The SDK generates the PKCE challenge and state automatically.

Next.js (App Router)

// app/api/auth/login/route.ts
import { cookies } from 'next/headers'
import { BioAuth } from '@insureco/bio'

export async function GET() {
  const bio = BioAuth.fromEnv()
  const { url, state, codeVerifier } = bio.getAuthorizationUrl({
    redirectUri: `${process.env.APP_URL}/api/auth/callback`,
  })

  const cookieStore = await cookies()
  cookieStore.set('oauth_state', state, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600,
  })
  cookieStore.set('oauth_code_verifier', codeVerifier, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600,
  })

  return Response.redirect(url)
}

Express

import { BioAuth } from '@insureco/bio'

const bio = BioAuth.fromEnv()

app.get('/api/auth/login', (req, res) => {
  const { url, state, codeVerifier } = bio.getAuthorizationUrl({
    redirectUri: `${process.env.APP_URL}/api/auth/callback`,
  })

  res.cookie('oauth_state', state, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600000,
  })
  res.cookie('oauth_code_verifier', codeVerifier, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600000,
  })
  res.redirect(url)
})

Callback Route

Your callback must be at /api/auth/callback — the builder registers this path automatically.

Do not use custom callback paths. The builder only registers /api/auth/callback. Using /api/auth/bio-id/callback or any other path will fail with “Invalid Redirect URI”.
// app/api/auth/callback/route.ts
import { cookies } from 'next/headers'
import { BioAuth } from '@insureco/bio'

export async function GET(request: Request) {
  const bio = BioAuth.fromEnv()
  const cookieStore = await cookies()
  const url = new URL(request.url)

  const code = url.searchParams.get('code')
  const state = url.searchParams.get('state')
  const error = url.searchParams.get('error')

  if (error) return Response.redirect(`${process.env.APP_URL}/?error=${error}`)
  if (!code || !state) return Response.redirect(`${process.env.APP_URL}/?error=missing_params`)

  // Validate CSRF state
  const storedState = cookieStore.get('oauth_state')?.value
  if (state !== storedState) {
    return Response.redirect(`${process.env.APP_URL}/?error=invalid_state`)
  }

  const codeVerifier = cookieStore.get('oauth_code_verifier')?.value
  if (!codeVerifier) {
    return Response.redirect(`${process.env.APP_URL}/?error=missing_verifier`)
  }

  // Exchange code for tokens
  const tokens = await bio.exchangeCode(
    code, codeVerifier, `${process.env.APP_URL}/api/auth/callback`,
  )

  // Get user profile
  const user = await bio.getUserInfo(tokens.access_token)

  // Store tokens in secure cookies
  cookieStore.set('access_token', tokens.access_token, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: tokens.expires_in,
  })
  cookieStore.set('refresh_token', tokens.refresh_token!, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 30,
  })

  // Clean up OAuth cookies
  cookieStore.delete('oauth_state')
  cookieStore.delete('oauth_code_verifier')

  return Response.redirect(`${process.env.APP_URL}/dashboard`)
}

Get Current User

Check if the user is logged in and auto-refresh expired tokens:

import { BioAuth, isTokenExpired } from '@insureco/bio'
import { cookies } from 'next/headers'

export async function getCurrentUser() {
  const bio = BioAuth.fromEnv()
  const cookieStore = await cookies()

  let accessToken = cookieStore.get('access_token')?.value
  const refreshToken = cookieStore.get('refresh_token')?.value

  if (!accessToken && !refreshToken) return null

  // Auto-refresh if expired
  if ((!accessToken || isTokenExpired(accessToken)) && refreshToken) {
    try {
      const tokens = await bio.refreshToken(refreshToken)
      accessToken = tokens.access_token
      cookieStore.set('access_token', tokens.access_token, {
        httpOnly: true, secure: true, sameSite: 'lax', maxAge: tokens.expires_in,
      })
      if (tokens.refresh_token) {
        cookieStore.set('refresh_token', tokens.refresh_token, {
          httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 30,
        })
      }
    } catch {
      cookieStore.delete('access_token')
      cookieStore.delete('refresh_token')
      return null
    }
  }

  if (!accessToken) return null
  return bio.getUserInfo(accessToken)
}

Logout

Revoke the refresh token and clear cookies:

// app/api/auth/logout/route.ts
import { cookies } from 'next/headers'
import { BioAuth } from '@insureco/bio'

export async function GET() {
  const bio = BioAuth.fromEnv()
  const cookieStore = await cookies()
  const refreshToken = cookieStore.get('refresh_token')?.value

  if (refreshToken) {
    await bio.revokeToken(refreshToken, 'refresh_token').catch(() => {})
  }

  cookieStore.delete('access_token')
  cookieStore.delete('refresh_token')

  return Response.redirect(`${process.env.APP_URL}/`)
}

Verify Tokens (RS256)

Bio-ID tokens are RS256-signed JWTs. Use verifyTokenJWKS() to verify them locally using Bio-ID's public JWKS endpoint — no shared secret required.

import { verifyTokenJWKS } from '@insureco/bio'

// In API middleware or a route handler:
const token = req.headers.authorization?.slice(7) // strip "Bearer "
const payload = await verifyTokenJWKS(token)

console.log(payload.bioId)    // "BIO-XXXXXXXX"
console.log(payload.orgSlug)  // "my-org"
console.log(payload.roles)    // ["admin", ...]

The JWKS is cached in-process for 24 hours and auto-refreshed on key rotation. No configuration needed — it defaults to https://bio.tawa.insureco.io/.well-known/jwks.json.

Most services don't need this. If your routes are registered in Koko with auth: required, Janus verifies the token before proxying the request. Only services with their own auth middleware (Next.js edge middleware, standalone API servers) need to call verifyTokenJWKS() directly.
Don't set JWT_SECRET in consuming services. That was the old HS256 pattern. With RS256, the private key lives only in Bio-ID — consuming services verify using the public JWKS endpoint.

Client Credentials (Service-to-Service)

For backend services that need to call APIs without a user context:

const bio = BioAuth.fromEnv()
const tokens = await bio.getClientCredentialsToken(['service:read'])
// tokens.access_token identifies your service, not a user

Client credentials tokens have a 1-hour lifetime and do not include a refresh token.

Auto-Provisioning

When you run tawa deploy, the builder automatically:

  1. Creates an OAuth client in Bio-ID (if it doesn't already exist)
  2. Registers the redirect URI based on naming conventions
  3. Injects BIO_CLIENT_ID and BIO_CLIENT_SECRET as environment variables
ComponentFormatExample
OAuth Client ID{service}-{environment}ppay-board-sandbox
Redirect URI (sandbox)https://{service}.sandbox.tawa.insureco.io/api/auth/callback 
Redirect URI (prod)https://{service}.tawa.insureco.io/api/auth/callback 

Token Lifetimes

TokenLifetimeNotes
Access token1 hourJWT (RS256), carries user claims
Refresh token30 daysRotated on each use
Client credentials1 hourNo refresh token issued
Authorization code10 minutesSingle use

Local Development

For local development, pull your deployed credentials:

tawa config pull

This writes all config and secrets to .env.local. Or set them manually:

# .env.local
BIO_CLIENT_ID=your-dev-client-id
BIO_CLIENT_SECRET=your-dev-client-secret
BIO_ID_URL=https://bio.tawa.insureco.io
APP_URL=http://localhost:3000

Troubleshooting

ErrorCauseFix
Invalid Redirect URICallback not at /api/auth/callbackUse the exact path /api/auth/callback
Invalid StateState mismatch between request and callbackVerify cookies are set and read correctly
client_id=undefinedOAuth provisioning was skippedEnsure catalog-info.yaml exists and redeploy
Token ExpiredAccess token expired (1 hour lifetime)Implement auto-refresh with isTokenExpired()
Invalid Code VerifierPKCE verifier doesn't match challengeEnsure codeVerifier is stored in cookie during login

Related