← Back to Skills

Passport — Enriched Identity, Branding & Permissions

A Passport is a self-contained identity object that travels with a user across every Tawa service. It carries identity, org branding, and permissions — all…

Ruleprovision

About

A Passport is a self-contained identity object that travels with a user across every Tawa service. It carries identity, org branding, and permissions — all bundled at token mint time by Bio-ID. Services never make a second call to get this data.

Skill Content

This is the raw markdown that gets installed as a Claude Code rule.

# Passport — Enriched Identity, Branding & Permissions

## What It Is

A Passport is a self-contained identity object that travels with a user across every Tawa service. It carries identity, org branding, and permissions — all bundled at token mint time by Bio-ID. Services never make a second call to get this data.

One login, everywhere. One object, everything you need.

> Canonical spec: `tawa-platform/architecture/passport-branding-auth` in the Bible.

---

## The Passport Object

```typescript
interface Passport {
  // Identity
  bioId: string                  // unique user ID (immutable)
  email: string
  firstName: string
  lastName: string
  displayName: string
  avatar?: string
  userType: string               // 'user' | 'service' | 'admin'
  roles: string[]                // roles in primary org

  // Primary org
  orgSlug: string
  orgId: string                  // ORG-xxx format
  orgName: string

  // All org memberships (multi-org users)
  orgs: {
    orgSlug: string
    orgId: string
    orgName: string
    roles: string[]
    modules: string[]
  }[]

  // Aggregated modules across all orgs
  modules: string[]

  // Branding — loaded from vault at mint time (when passport.includeBranding: true)
  branding: {
    displayName: string          // org display name for UI chrome
    tagline?: string
    logoUrl?: string             // primary logo (SVG or PNG)
    logoMarkUrl?: string         // icon/mark for small surfaces
    primaryColor: string         // hex — defaults to '#0F172A'
    secondaryColor?: string      // hex
    websiteUrl?: string
    verified: boolean            // platform verified this entity
    whiteLabelApproved: boolean  // approved for custom domain + hideEcoBranding
  }

  // Permissions — resolved from Koko at mint time
  permissions: {
    scopes: string[]             // e.g. ['raterspot:rate', 'docman:read']
  }

  // Vault link
  iecHash?: string               // entity's chain address (if vault entity exists)

  // Session metadata
  issuedAt: string
  connectedServices: string[]
}
```

---

## Using the Passport in Your Service

### Reading Identity + Branding (Most Common)

```typescript
// req.user IS the Passport — already populated by Janus or your middleware
const { orgSlug, branding, permissions } = req.user

// Brand the page
document.title = branding.displayName
headerLogo.src = branding.logoUrl
root.style.setProperty('--primary', branding.primaryColor)

// Check permission
if (permissions.scopes.includes('raterspot:rate')) {
  showRatingPanel()
}

// No vault lookup. No Koko call. It's already in the token.
```

### React — Via Passport Socket

```typescript
import { useEffect, useState } from 'react'

const BIO_ID_URL = process.env.NEXT_PUBLIC_BIO_ID_URL || 'https://bio.insureco.io'

export function usePassport(token: string | null) {
  const [passport, setPassport] = useState<Passport | null>(null)

  useEffect(() => {
    if (!token) return

    const ws = new WebSocket(`${BIO_ID_URL.replace(/^http/, 'ws')}/passport?token=${token}`)

    ws.onmessage = (e) => {
      const { type, passport } = JSON.parse(e.data)
      if (type === 'identity' || type === 'passport_updated') setPassport(passport)
      if (type === 'revoked') setPassport(null)
    }

    ws.onclose = () => setPassport(null)
    return () => ws.close()
  }, [token])

  return passport
}
```

### Node.js / CLI

```typescript
import WebSocket from 'ws'

const BIO_ID_URL = process.env.BIO_ID_URL || 'https://bio.insureco.io'

export function connectPassport(token: string, onIdentity: (passport: Passport) => void) {
  const ws = new WebSocket(`${BIO_ID_URL.replace(/^http/, 'ws')}/passport?token=${token}`)

  ws.on('message', (raw) => {
    const { type, passport } = JSON.parse(raw.toString())
    if (type === 'identity' || type === 'passport_updated') onIdentity(passport)
    if (type === 'revoked') process.exit(0)
  })

  return () => ws.close()
}
```

---

## The Socket

```
wss://bio.insureco.io/passport?token=<access_token>
```

Advertised in Bio-ID's OIDC configuration as `passport_endpoint`.

### Events

| Event type | When | Payload |
|------------|------|---------|
| `identity` | On connect (if session valid) | `{ type, passport }` |
| `passport_updated` | Role/org/branding change | `{ type, passport }` |
| `revoked` | Logout or token invalidated | `{ type }` |

### Error Codes

| Code | Reason |
|------|--------|
| `4001` | Missing token — no `?token=` in URL |
| `4003` | Invalid token — expired or tampered |
| `4004` | User not found or suspended |

---

## Three Auth Modes

All three modes produce the same Passport object. The mode determines the user experience.

| Mode | Bio-ID visible? | User redirected? | Dev builds UI? |
|------|----------------|-----------------|----------------|
| **Branded** | Yes — "Sign in with Bio-ID" | Yes (bio.tawa.pro) | No |
| **White-Label** | No — org's domain + branding | Yes (custom domain) | No (CSS only) |
| **Headless** | No — completely invisible | No — server-to-server | Yes — fully custom |

### Branded (default)

```yaml
spec:
  auth:
    mode: sso
```

### White-Label

```yaml
spec:
  auth:
    mode: sso
    passport:
      includeBranding: true
```

Org's vault branding auto-applies to the consent screen. Custom domain + hideEcoBranding requires `whiteLabelApproved: true` on the vault entity.

### Headless

```yaml
spec:
  auth:
    mode: sso
    passport:
      includeBranding: true
      headless: true
      allowedOrigins:
        - https://yourapp.com
```

```typescript
const bio = BioAuth.fromEnv()

// Signup — server-to-server, no redirect
const tokens = await bio.embed.signup({
  email, password, firstName,
  orgSlug: 'acme-agency',  // optional — omit to auto-create org
})

// Login
const tokens = await bio.embed.login({ email, password })

// Magic link (you send the email yourself)
const { magicToken } = await bio.embed.createMagicLink({ email })

// Verify magic token
const tokens = await bio.embed.verify({ token: magicToken })

// Refresh
const tokens = await bio.embed.refresh({ refreshToken })
```

---

## Onboarding Flows

### Self-Signup (no invite)

```
Any mode → user signs up → Bio-ID auto-creates personal org (slug from name)
→ Passport minted with new org → user lands in service
```

### Invite

```
Admin: POST /api/v2/orgs/:orgSlug/invites { email, role, modules }
→ Bio-ID stores invite, sends email (or returns inviteUrl for headless)
→ User clicks link → signup (if new) or login (if existing)
→ Attached to inviting org with specified role
→ Passport minted with inviting org as primary
```

Headless invite acceptance:

```typescript
const tokens = await bio.embed.signup({
  email, password, firstName,
  inviteToken: 'TOKEN',  // resolves to org + role
})
```

### Service-Initiated (with orgSlug)

```typescript
// Create user directly in an org (requires org:manage scope)
const tokens = await bio.embed.signup({
  email: '[email protected]',
  password: tempPassword,
  firstName: 'New',
  orgSlug: 'acme-agency',
  role: 'agent',
})
```

---

## Vault Branding

Branding lives on vault entities at `profile.branding`. The Passport reads it at mint time.

### Two Tiers

| Tier | Who | What they can set |
|------|-----|-------------------|
| **Claimed** | Any entity after claim flow | displayName, tagline, logoUrl, logoMarkUrl, colors, website, phone, email |
| **White-Label Approved** | Platform approval | customDomain, hideEcoBranding, emailFromName, customCss |

### Vault API

```
GET  /v1/branding/:orgSlug                    → 0 gas, public
PATCH /v1/entities/:iecHash/branding          → 2 gas, auth required
```

### SDK

```typescript
import { VaultClient } from '@insureco/bio-vault'
const vault = VaultClient.fromEnv()

const branding = await vault.getBranding('acme-agency')
```

---

## How It Fits with OAuth

| Flow | Use when |
|------|----------|
| Full-page redirect | First login, consent screens |
| Popup | Staying on page during auth |
| Headless embed API | Full control, Bio-ID invisible |
| **Passport socket** | Already authenticated — propagate identity silently |

After any OAuth flow completes, Bio-ID publishes the Passport on the socket. Any connected service receives the identity immediately.

---

## Key Use Cases

- **Branding** — `req.user.branding.logoUrl` renders the org's logo instantly
- **Onboarding** — passport arrives with name, org, modules → no "what's your company?" form
- **Badge widget** — floating identity indicator on any page
- **CLI** — connect on startup, get identity silently
- **Village traversal** — user moves between services, identity follows
- **Permission gating** — `req.user.permissions.scopes` controls feature access

---

## Gotchas

- Token must be a valid Bio-ID access token (RS256 in prod)
- Socket closes on invalid token — don't retry without a fresh token
- Branding in token is cached for up to 1 hour — logo changes propagate on next refresh
- If `passport.includeBranding` is not declared (or `false`) in catalog-info.yaml, Passport carries identity only (no branding)
- Headless mode: always call Bio-ID server-to-server, never from the browser
- `bio.embed.signup({ orgSlug })` requires `org:manage` scope — auto-granted when deployed by that org

Install

Copy the skill content and save it to:

~/.claude/rules/tawa-passport.md
Download .md

Coming soon via CLI:

tawa chaac install tawa-passport

Details

Format
Rule
Category
provision
Version
1.0.41477
Tokens
~2,313
Updated
2026-06-24
platformpassportidentityauthbrandingpermissions