Implementing OAuth SSO with Bio-ID

Bio-ID is the Tawa platform's identity provider. It handles user authentication, organization membership, and role assignments. Adding SSO to your service means declaring it in catalog-info.yaml — the builder provisions an OAuth client automatically on every deploy.

Overview

Two auth modes are available:

ModeUse caseWhat gets injected
ssoUser-facing login (browser redirect flow)BIO_CLIENT_ID, BIO_CLIENT_SECRET
service-onlyMachine-to-machine only (no user login)BIO_CLIENT_ID, BIO_CLIENT_SECRET

BIO_ID_URL is always injected as a core platform variable — you never need to declare it.

Auth Modes and the Passport

Beyond basic SSO, you can configure the Passport to bundle branding and permissions into the JWT at mint time. This is configured via spec.auth.passport in catalog-info.yaml:

ConfigEffect
passport.branding: trueBio-ID fetches org's vault branding, bundles into token
passport.scopes: [...]Bio-ID resolves scopes from Koko, bundles into permissions
passport.headless: trueEnables /api/embed/* endpoints — no redirect, Bio-ID invisible

Three auth modes, all producing the same Passport:

  • Branded — standard auth: mode: sso — "Sign in with Bio-ID"
  • White-Label — add passport.branding: true — org's domain and branding, Bio-ID invisible
  • Headless — add passport.headless: true — fully custom UI, server-to-server only

NOTE: See tawa-platform/architecture/passport-branding-auth for the full Passport, branding, and auth modes architecture.

Step 1: Enable SSO in catalog-info.yaml

spec:
  auth:
    mode: sso

That's all. On your next deploy, the builder will:

  1. Create a Bio-ID OAuth client if one does not exist (or update the existing one)
  2. Register /api/auth/callback as the redirect URI for your environment
  3. Inject BIO_CLIENT_ID, BIO_CLIENT_SECRET, and BIO_ID_URL into your pod

IMPORTANT: Do not use internalDependencies: bio-id to enable OAuth — that is incorrect. internalDependencies is for resolving internal service URLs, not for OAuth provisioning. Always use auth: mode: sso in spec.

Step 2: Install the SDK

npm install @insureco/bio

Step 3: Login Route

The login route redirects the user to Bio-ID to authenticate. Store state and codeVerifier in httpOnly cookies so the callback can verify them.

import { BioAuth } from '@insureco/bio'

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

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

  // Store in httpOnly cookies — never expose in the URL or response body
  res.cookie('oauth_state', state, { httpOnly: true, secure: true, sameSite: 'lax' })
  res.cookie('oauth_verifier', codeVerifier, { httpOnly: true, secure: true, sameSite: 'lax' })
  res.redirect(url)
})

Step 4: Callback Route

IMPORTANT: The callback route MUST be at exactly /api/auth/callback. The builder registers this path as the redirect URI. Any other path — including /api/auth/bio-id/callback or /auth/callback — will be rejected by Bio-ID with an "Invalid Redirect URI" error.

Register the handler directly on the app at the full path — do not rely solely on Express sub-router composition (e.g. app.use('/api/auth', router) + router.get('/callback', ...)). The tawa preflight scanner uses static analysis and checks for the literal string /api/auth/callback in your route registrations. If the path is split across a mount point and a sub-router, preflight will report a FAIL even though the route works correctly at runtime.

If you use sub-routers, export the callback handler and register it explicitly at the full path in your main app file:

// routes/auth.routes.ts
export async function handleOAuthCallback(req: Request, res: Response): Promise<void> {
  // ... callback logic
}
router.get('/callback', handleOAuthCallback) // keeps sub-router working

// app.ts
import authRoutes, { handleOAuthCallback } from './routes/auth.routes'

app.get('/api/auth/callback', handleOAuthCallback) // explicit — preflight finds this
app.use('/api/auth', authRoutes)                   // sub-router — other auth routes
app.get('/api/auth/callback', async (req, res) => {
  const { code, state } = req.query
  const storedState = req.cookies.oauth_state
  const codeVerifier = req.cookies.oauth_verifier

  // Always verify state to prevent CSRF
  if (state !== storedState) {
    return res.status(400).send('Invalid state parameter')
  }

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

    // Store access token in a secure httpOnly cookie
    res.cookie('access_token', tokens.access_token, {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      maxAge: 60 * 60 * 1000, // 1 hour
    })

    res.redirect('/dashboard')
  } catch (err) {
    console.error('OAuth callback failed:', err)
    res.redirect('/api/auth/login')
  }
})

Step 5: Verifying Tokens

Most services do not need to verify tokens directly. If your routes are registered in Koko with auth: required, Janus verifies the token before forwarding the request to your service.

Only implement token verification if your service runs its own auth middleware (for example, a Next.js edge middleware or a standalone API that handles requests before they reach Janus).

import { verifyTokenJWKS } from '@insureco/bio'

// In middleware or a route guard:
async function requireAuth(req, res, next) {
  const token = req.headers.authorization?.slice(7) // strip "Bearer "

  try {
    const payload = await verifyTokenJWKS(token)
    // payload.bioId    — unique user identifier
    // payload.orgSlug  — user's organization slug
    // payload.roles    — array of role names
    // payload.email    — user's email address
    req.user = payload
    next()
  } catch {
    res.status(401).json({ error: 'Unauthorized' })
  }
}

NOTE: Never use the old verifyToken(token, secret) HS256 pattern. Bio-ID uses RS256. The verifyTokenJWKS function fetches the public key from Bio-ID's JWKS endpoint and verifies locally. No shared secret is needed in your service.

Step 6: Service-to-Service Authentication

When your service needs to call another service's API on behalf of itself (not a user), use client credentials:

const bio = BioAuth.fromEnv()

const tokens = await bio.getClientCredentialsToken(['service:read'])
// tokens.access_token carries your service identity — use it as a Bearer token

const response = await fetch(`${process.env.OTHER_SERVICE_URL}/api/data`, {
  headers: {
    Authorization: `Bearer ${tokens.access_token}`,
  },
})

Token Lifetimes

TokenLifetimeNotes
Access token1 hourRS256 JWT, carries user claims
Refresh token30 daysRotated on each use
Client credentials1 hourNo refresh token issued
Authorization code10 minutesSingle-use

Environment Variables Injected Automatically

VariableDescription
BIO_CLIENT_IDOAuth client ID for your service
BIO_CLIENT_SECRETOAuth client secret
BIO_ID_URLBio-ID base URL — always injected, no declaration needed

These are injected by the builder on every deploy. Do not hardcode these values and do not set them manually via tawa config set — the builder manages them.

Local Development

Use tawa local-setup to configure your service for local development in one step. It registers the localhost redirect URI, regenerates the client secret, pulls all sandbox config vars, and writes .env.local with APP_URL overridden to localhost.

cd my-service
tawa local-setup --port 3000    # or whatever port your dev server uses

This replaces the manual steps of tawa oauth add-uri, tawa oauth regenerate-secret, and tawa config pull. Run it once after cloning, and again any time you add new config vars to sandbox.

The APP_URL override is critical — your service uses it to construct redirect URIs. Without it pointing to localhost, the OAuth callback would redirect back to the sandbox domain instead of your local server.

WARNING: Never commit .env.local to git. It contains decrypted secrets and is written with restricted (0600) permissions.

Managing OAuth Clients via CLI

tawa oauth list                                    # List all OAuth clients for your org
tawa oauth get <client-id>                         # Inspect redirect URIs and scopes
tawa oauth add-uri <client-id> <uri>               # Add a redirect URI manually
tawa oauth remove-uri <client-id> <uri>            # Remove a redirect URI
tawa oauth regenerate-secret <client-id>           # Rotate the client secret
tawa oauth delete <client-id>                      # Delete an OAuth client

Common Mistakes

  • Wrong callback path — Using /api/auth/bio-id/callback or any path other than /api/auth/callback. The builder only registers /api/auth/callback.
  • Sub-router split causing preflight failure — Registering the callback as app.use('/api/auth', router) + router.get('/callback', ...) makes the route work at runtime but fail tawa preflight. The scanner uses static analysis and requires the literal path /api/auth/callback to appear in a route registration. Fix: export the handler and also register it directly with app.get('/api/auth/callback', handler) in your main app file.
  • Using internalDependencies: bio-id — This is for resolving service URLs, not for enabling OAuth. Use auth: mode: sso in spec.
  • Forgetting auth: mode: sso — Without it, BIO_CLIENT_ID and BIO_CLIENT_SECRET are never injected and your service cannot authenticate users.
  • Using the old HS256 patternverifyToken(token, secret) is deprecated. Use verifyTokenJWKS(token).
  • Setting JWT_SECRET in your service — Not needed with RS256. Bio-ID's private key signs tokens; your service verifies using the public JWKS endpoint.
  • Not verifying state in the callback — Always compare state from the query string against the value stored in the cookie to prevent CSRF attacks.
  • Awaiting bio.getAuthorizationUrl — This method is synchronous and returns immediately. It does not make network calls.

Last updated: March 6, 2026