Docs /api-reference/authentication-api

Authentication API Reference

Complete guide to authentication flows, session management, and security in Izri.

📋 Overview

Izri uses Better Auth for authentication with GitHub OAuth as the primary authentication method.

Authentication Methods:

  • ✅ GitHub OAuth (implemented)
  • 📋 API Tokens (planned)
  • 📋 Email/Password (planned)

Session Management:

  • Cookie-based sessions
  • 7-day expiration (default)
  • Automatic renewal on activity
  • Multi-device support

🔐 GitHub OAuth Flow

1. Initiate Sign-In

Frontend:

import { authClient } from '@izri/auth/client'

// Redirect to GitHub
await authClient.signIn.social({
  provider: 'github',
  callbackURL: '/dashboard'
})

What Happens:

  1. Client calls Better Auth sign-in endpoint
  2. Better Auth generates OAuth state
  3. User redirected to GitHub authorization page
  4. User approves access

2. GitHub Callback

URL: http://localhost:4000/api/auth/callback/github?code=...&state=...

What Happens:

  1. GitHub redirects back with authorization code
  2. Better Auth exchanges code for access token
  3. Better Auth fetches user profile from GitHub
  4. User created or updated in database
  5. Session created
  6. Session cookie set
  7. User redirected to callbackURL

Database Changes:

-- Insert or update user
INSERT INTO users (id, email, name, avatar, email_verified)
VALUES (...) ON CONFLICT (email) DO UPDATE ...

-- Create account link (GitHub)
INSERT INTO accounts (user_id, provider, provider_account_id, access_token, ...)
VALUES (...)

-- Create session
INSERT INTO sessions (id, user_id, expires_at)
VALUES (...)

3. Session Cookie

Cookie Name: better-auth.session_token

Attributes:

  • HttpOnly: Prevents JavaScript access
  • Secure: HTTPS only (production)
  • SameSite=Lax: CSRF protection
  • Max-Age: 7 days (604800 seconds)

Example:

Set-Cookie: better-auth.session_token=session_abc123xyz; 
  HttpOnly; Secure; SameSite=Lax; Max-Age=604800; Path=/

🔑 Session Management

Get Current Session

tRPC:

const { data } = await trpc.auth.getSession.useQuery()
console.log(data?.user) // User object or null
console.log(data?.isAuthenticated) // boolean

REST:

curl http://localhost:4000/api/auth/get-session \
  --cookie "better-auth.session_token=..."

Response:

{
  "session": {
    "id": "session_abc",
    "userId": "user_xyz",
    "expiresAt": "2024-12-31T23:59:59.999Z"
  },
  "user": {
    "id": "user_xyz",
    "email": "user@example.com",
    "name": "John Doe",
    "avatar": "https://avatars.githubusercontent.com/...",
    "emailVerified": true
  }
}

List All Sessions

View all active sessions for current user.

tRPC:

const { data } = await trpc.auth.listSessions.useQuery()
console.log(data) // Array of sessions

Response:

[
  {
    id: 'session_current',
    userId: 'user_xyz',
    expiresAt: Date,
    createdAt: Date,
    ipAddress: '192.168.1.1',
    userAgent: 'Chrome/120.0...'
  },
  {
    id: 'session_mobile',
    // ... other session
  }
]

Revoke Session (Logout)

Current Session:

const { mutate: logout } = trpc.auth.invalidateSession.useMutation({
  onSuccess: () => {
    window.location.href = '/login'
  }
})

logout()

Specific Session:

const { mutate } = trpc.auth.revokeSession.useMutation()
mutate({ sessionToken: 'session_to_revoke' })

All Other Sessions (keep current):

const { mutate } = trpc.auth.revokeOtherSessions.useMutation()
mutate()

👤 User Profile

Get Profile

tRPC:

const { data: profile } = await trpc.auth.getProfile.useQuery()

Response:

{
  id: string
  email: string
  name: string
  avatar?: string
  emailVerified: boolean
  createdAt: Date
  updatedAt: Date
}

Update Profile

tRPC:

const { mutate } = trpc.auth.updateProfile.useMutation()

mutate({
  name: 'New Name',
  avatar: 'https://example.com/avatar.jpg'
})

Updatable Fields:

  • name - Display name
  • avatar - Avatar URL

Non-updatable:

  • id, email, emailVerified, createdAt

🔒 Protected Routes

Frontend Route Protection

React Router Loader:

// app/routes/_authenticated.tsx
export async function loader() {
  const session = await trpc.auth.getSession.query()
  
  if (!session.isAuthenticated) {
    throw redirect('/login')
  }
  
  return { user: session.user }
}

Component:

import { useAuth } from '@/hooks/useAuth'

export function ProtectedPage() {
  const { user, isLoading } = useAuth()
  
  if (isLoading) return <LoadingSpinner />
  if (!user) return <Navigate to="/login" />
  
  return <div>Welcome {user.name}</div>
}

Backend Procedure Protection

tRPC Context:

// packages/trpc/src/context.ts
export async function createContext({ req, res }: Context) {
  const session = await auth.api.getSession({ headers: req.headers })
  
  return {
    req,
    res,
    db,
    session: session?.session,
    user: session?.user,
    isAuthenticated: !!session?.session
  }
}

Protected Procedure:

export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
  if (!ctx.isAuthenticated || !ctx.user) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: 'Not authenticated'
    })
  }
  
  return next({
    ctx: {
      ...ctx,
      user: ctx.user,
      session: ctx.session
    }
  })
})

Usage:

export const projectsRouter = router({
  getProjects: protectedProcedure
    .input(z.object({ ... }))
    .query(async ({ ctx, input }) => {
      // ctx.user is guaranteed to exist
      const userId = ctx.user.id
      // ...
    })
})

🔑 API Token Authentication (Planned)

Token Structure

Format:

izri_v1_k7n9p2q5r8t1u4v7w0x3y6z9_c4a8

Components:

  • izri_ - Prefix
  • v1 - Version
  • k7n9... - Random 32 chars (secret)
  • c4a8 - Checksum (verification)

Token Storage

Database (hashed):

INSERT INTO api_tokens (
  id,
  user_id,
  name,
  token_hash,  -- SHA-256 hash
  scopes,
  expires_at
) VALUES (...)

Never store plain tokens - only hashes.

Token Verification

import crypto from 'node:crypto'

function hashToken(token: string): string {
  return crypto.createHash('sha256').update(token).digest('hex')
}

async function verifyToken(token: string): Promise<User | null> {
  const hash = hashToken(token)
  
  const tokenRecord = await db
    .select()
    .from(apiTokens)
    .where(eq(apiTokens.tokenHash, hash))
    .limit(1)
  
  if (!tokenRecord.length) return null
  if (tokenRecord[0].expiresAt < new Date()) return null
  
  const user = await db
    .select()
    .from(users)
    .where(eq(users.id, tokenRecord[0].userId))
    .limit(1)
  
  return user[0] || null
}

Usage in Requests

Authorization Header:

curl http://localhost:4000/api/v1/projects \
  -H "Authorization: Bearer izri_v1_..."

Middleware:

app.use('/api/v1/*', async (c, next) => {
  const auth = c.req.header('Authorization')
  if (!auth?.startsWith('Bearer ')) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
  
  const token = auth.slice(7)
  const user = await verifyToken(token)
  
  if (!user) {
    return c.json({ error: 'Invalid token' }, 401)
  }
  
  c.set('user', user)
  await next()
})

🛡️ Security Best Practices

Session Security

Cookie Attributes:

{
  httpOnly: true,      // No JavaScript access
  secure: true,        // HTTPS only
  sameSite: 'lax',     // CSRF protection
  maxAge: 7 * 24 * 60 * 60  // 7 days
}

Session Rotation:

  • Sessions refreshed on every request
  • Old session tokens invalidated
  • Prevents session fixation attacks

Session Timeout:

  • 7-day absolute timeout
  • 30-minute idle timeout (planned)
  • Automatic cleanup of expired sessions

Token Security

Generation:

import crypto from 'node:crypto'

function generateToken(): string {
  const random = crypto.randomBytes(24).toString('base64url')
  const checksum = crypto.createHash('sha256')
    .update(random)
    .digest('base64url')
    .slice(0, 4)
  
  return `izri_v1_${random}_${checksum}`
}

Storage:

  • ✅ Store hashed tokens in database
  • ❌ Never log tokens
  • ❌ Never return tokens after creation
  • ✅ Show tokens once on creation

Rotation:

  • Rotate tokens periodically (90 days recommended)
  • Revoke unused tokens
  • Alert on suspicious activity

CORS Configuration

Development:

cors({
  origin: ['http://localhost:5173'],
  credentials: true
})

Production:

cors({
  origin: [
    'https://yourdomain.com',
    'https://www.yourdomain.com'
  ],
  credentials: true,
  maxAge: 86400  // 24 hours
})

📊 Audit Logging

Auth Audit Logs

Track authentication events for security monitoring.

Events Logged:

  • login - Successful login
  • logout - User logout
  • session_revoked - Session revoked
  • token_created - API token created
  • token_revoked - API token revoked
  • failed_auth - Failed authentication attempt

Schema:

{
  id: string
  userId: string
  action: string
  ipAddress?: string
  userAgent?: string
  metadata?: Record<string, any>
  createdAt: Date
}

Query Logs:

const { data: logs } = await trpc.auth.getAuditLogs.useQuery({
  limit: 50,
  offset: 0
})

Example Log Entry:

{
  "id": "log_abc123",
  "userId": "user_xyz",
  "action": "login",
  "ipAddress": "192.168.1.1",
  "userAgent": "Mozilla/5.0...",
  "metadata": {
    "provider": "github",
    "success": true
  },
  "createdAt": "2024-01-01T00:00:00.000Z"
}

🚨 Error Handling

Common Errors

UNAUTHORIZED (401):

{
  "code": "UNAUTHORIZED",
  "message": "Not authenticated"
}

Causes:

  • No session cookie
  • Expired session
  • Invalid session token

Solution: Redirect to login


FORBIDDEN (403):

{
  "code": "FORBIDDEN",
  "message": "Insufficient permissions"
}

Causes:

  • Valid session but insufficient role
  • API token with limited scopes

Solution: Request higher permissions or different token


SESSION_EXPIRED:

{
  "code": "SESSION_EXPIRED",
  "message": "Your session has expired. Please log in again."
}

Frontend Handling:

trpc.useContext().client.setDefaultOptions({
  onError: (error) => {
    if (error.data?.code === 'UNAUTHORIZED') {
      // Clear local state
      queryClient.clear()
      
      // Redirect to login
      window.location.href = '/login'
    }
  }
})

🔗 Related Documentation

Reading this with an agent? /docs/api-reference/authentication-api.md serves the raw markdown.

All docs