Skip to content

Authentication Implementation

This document describes the OAuth-based authentication system implementation for the Musingly microlearning platform.

Overview

The authentication system provides OAuth-based authentication using Google and GitHub as identity providers. It implements:

  • OAuth 2.0 flows for Google and GitHub
  • Session management with secure HttpOnly cookies
  • Token refresh with rotation for security
  • User profile creation on first signup
  • Integration with Supabase Auth for identity management

Architecture

┌─────────────┐       ┌──────────────┐       ┌─────────────┐
│   Client    │──────▶│  Auth Edge   │──────▶│  Supabase   │
│ (Web/Mobile)│◀──────│   Function   │◀──────│    Auth     │
└─────────────┘       └──────────────┘       └─────────────┘
                      ┌──────────────┐
                      │  PostgreSQL  │
                      │  - sessions  │
                      │  - profiles  │
                      └──────────────┘

API Endpoints

Base URL

  • Local: http://127.0.0.1:54321/functions/v1/auth
  • Production: https://<project-ref>.supabase.co/functions/v1/auth

Endpoints

1. GET /auth/login

Initiates OAuth flow with specified provider.

Query Parameters:

  • provider (required): google | github
  • redirect (optional): URL to redirect after successful auth

Response: 302 Redirect to OAuth provider

Example:

GET /auth/login?provider=google&redirect=http://localhost:3000/dashboard

2. GET /auth/callback

Handles OAuth provider callback after user authorizes.

Query Parameters:

  • code (required): Authorization code from OAuth provider
  • state (optional): CSRF state token for validation

Response: 200 OK

{
  "user": {
    "id": "uuid",
    "email": "user@example.com",
    "display_name": "John Doe",
    "provider": "google"
  },
  "session": {
    "access_token": "jwt-token",
    "refresh_token": "refresh-token",
    "expires_at": "2026-02-15T12:00:00Z",
    "expires_in": 3600
  },
  "is_new_user": true,
  "redirect_url": "http://localhost:3000/dashboard"
}

Cookies Set:

  • sb-access-token (HttpOnly, Secure, SameSite=Lax)
  • sb-refresh-token (HttpOnly, Secure, SameSite=Lax)

3. GET /auth/session

Returns current authentication state.

Response: 200 OK

Authenticated:

{
  "authenticated": true,
  "user": {
    "id": "uuid",
    "email": "user@example.com",
    "display_name": "John Doe",
    "avatar_url": "https://...",
    "provider": "google"
  },
  "session": {
    "id": "session-uuid",
    "provider": "google",
    "expires_at": "2026-02-15T12:00:00Z",
    "created_at": "2026-02-15T10:00:00Z"
  }
}

Anonymous:

{
  "authenticated": false,
  "user": null,
  "session": null
}

4. POST /auth/logout

Ends user session and invalidates tokens.

Headers:

  • Authorization: Bearer <access-token> (required)

Response: 200 OK

{
  "success": true,
  "message": "Logged out successfully"
}

Cookies Cleared:

  • sb-access-token
  • sb-refresh-token

5. POST /auth/refresh

Refreshes access token using refresh token.

Request Body (optional):

{
  "refresh_token": "refresh-token"
}

Or provide refresh token via sb-refresh-token cookie.

Response: 200 OK

{
  "access_token": "new-jwt-token",
  "refresh_token": "new-refresh-token",
  "expires_at": "2026-02-15T13:00:00Z",
  "expires_in": 3600
}

Cookies Set:

  • sb-access-token (new token)
  • sb-refresh-token (new token, rotated)

Database Schema

sessions Table

Stores active user authentication sessions.

CREATE TABLE sessions (
    id UUID PRIMARY KEY,
    user_id UUID NOT NULL,
    provider oauth_provider NOT NULL,
    access_token_hash VARCHAR(255) NOT NULL,
    refresh_token_hash VARCHAR(255),
    access_token_expires_at TIMESTAMPTZ NOT NULL,
    is_active BOOLEAN NOT NULL DEFAULT true,
    last_activity_at TIMESTAMPTZ NOT NULL,
    user_agent TEXT,
    ip_address INET,
    created_at TIMESTAMPTZ NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL
);

Key Features:

  • Stores SHA-256 hashes of tokens (never plain text)
  • Tracks session expiration and activity
  • Supports session invalidation
  • RLS policies ensure users only see their own sessions

user_profiles Enhancements

Added OAuth provider tracking:

ALTER TABLE user_profiles
    ADD COLUMN provider oauth_provider,
    ADD COLUMN last_login_at TIMESTAMPTZ;

Security Features

1. CSRF Protection

  • OAuth state parameter used for CSRF protection
  • State token validated in callback

2. Token Storage

  • HttpOnly cookies: Prevents XSS attacks
  • Secure flag: HTTPS-only transmission
  • SameSite=Lax: CSRF protection
  • Token hashing: SHA-256 hashes stored in database

3. Token Rotation

  • New refresh token generated on each refresh
  • Old refresh token invalidated
  • Detects token reuse (potential attack)

4. Session Management

  • Automatic expiration based on token lifetime
  • Manual invalidation via logout
  • Cleanup function for expired sessions

5. Input Validation

  • Zod schema validation for all inputs
  • Sanitization prevents SQL injection
  • Redirect URL validation prevents open redirects

OAuth Provider Setup

Google OAuth

  1. Create OAuth credentials in Google Cloud Console
  2. Add authorized redirect URIs:
  3. Local: http://localhost:54321/auth/v1/callback
  4. Production: https://<project-ref>.supabase.co/auth/v1/callback

  5. Set environment variables:

    GOOGLE_CLIENT_ID=your-client-id
    GOOGLE_CLIENT_SECRET=your-client-secret
    

GitHub OAuth

  1. Create OAuth App in GitHub Settings
  2. Add authorization callback URL:
  3. Local: http://localhost:54321/auth/v1/callback
  4. Production: https://<project-ref>.supabase.co/auth/v1/callback

  5. Set environment variables:

    GITHUB_CLIENT_ID=your-client-id
    GITHUB_CLIENT_SECRET=your-client-secret
    


Usage Examples

Client-Side OAuth Flow

// 1. Initiate OAuth
window.location.href = "/auth/login?provider=google&redirect=/dashboard";

// 2. After redirect, callback is handled automatically
// User lands on /dashboard with session cookies set

// 3. Check session state
const response = await fetch("/auth/session", {
  credentials: "include", // Include cookies
});
const { authenticated, user, session } = await response.json();

if (authenticated) {
  console.log("User:", user);
  console.log("Session expires:", session.expires_at);
}

// 4. Refresh token when needed
const refreshResponse = await fetch("/auth/refresh", {
  method: "POST",
  credentials: "include",
});
const { access_token, expires_at } = await refreshResponse.json();

// 5. Logout
await fetch("/auth/logout", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${access_token}`,
  },
  credentials: "include",
});

Server-Side (Supabase Admin)

import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY,
);

// Get all active sessions for a user
const { data: sessions } = await supabase
  .from("sessions")
  .select("*")
  .eq("user_id", userId)
  .eq("is_active", true);

// Invalidate specific session
await supabase
  .from("sessions")
  .update({ is_active: false })
  .eq("id", sessionId);

// Clean up expired sessions (run periodically)
await supabase.rpc("invalidate_expired_sessions");

Performance Considerations

Token Validation

  • JWKS caching: Supabase public keys cached for 24 hours
  • Session lookup: Indexed on user_id and is_active
  • Single query: User + session fetched in one call

Performance Targets

  • GET /auth/login: p95 < 100ms
  • GET /auth/callback: p95 < 500ms
  • POST /auth/logout: p95 < 200ms
  • GET /auth/session: p95 < 50ms
  • POST /auth/refresh: p95 < 300ms

Optimization Tips

  1. Use connection pooling (built into Supabase)
  2. Cache JWKS public keys
  3. Minimize database queries per request
  4. Use RPC functions for complex queries

Error Handling

Error Response Format

{
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable error message",
    "details": {}
  }
}

Common Error Codes

Code Status Description
UNAUTHORIZED 401 Authentication required or invalid token
VALIDATION_ERROR 400 Invalid input parameters
NOT_FOUND 404 Resource not found
INTERNAL_ERROR 500 Server error
MISSING_REFRESH_TOKEN 401 Refresh token not provided

Testing

Local Testing

  1. Start Supabase:

    supabase start
    

  2. Serve auth function:

    supabase functions serve auth
    

  3. Test OAuth flow:

    # Initiate OAuth
    curl "http://127.0.0.1:54321/functions/v1/auth/login?provider=google"
    
    # Check session (anonymous)
    curl "http://127.0.0.1:54321/functions/v1/auth/session"
    

  4. Run E2E tests:

    deno task test:e2e tests/e2e/suites/auth/
    

Manual Testing with HTTP Files

See http/auth.http for comprehensive manual test cases.


Deployment

Deploy Auth Function

# Deploy to production
supabase functions deploy auth

# Verify deployment
supabase functions list

Environment Variables

Ensure these are set in Supabase dashboard:

  • GOOGLE_CLIENT_ID
  • GOOGLE_CLIENT_SECRET
  • GITHUB_CLIENT_ID
  • GITHUB_CLIENT_SECRET

Troubleshooting

Issue: "OAuth failed"

  • Cause: Invalid client ID/secret or redirect URI mismatch
  • Solution: Verify OAuth credentials in provider dashboard

Issue: "Refresh token expired"

  • Cause: Token rotation failed or token was revoked
  • Solution: User must re-authenticate via /auth/login

Issue: "Session not found"

  • Cause: Session expired or was invalidated
  • Solution: Check access_token_expires_at and invalidate_expired_sessions

Issue: "CSRF state mismatch"

  • Cause: State parameter doesn't match original request
  • Solution: Ensure state is preserved during OAuth flow

Service-to-Service Authentication

Overview

The platform supports service-to-service (S2S) authentication for backend services like Cloudflare Workers performing ISR/SSR. Services authenticate via the X-Service-Auth header with a shared secret, without needing a user JWT.

See the dedicated Service-to-Service Guide for integration examples and full documentation.

Middleware Resolution Order

The protection middleware (src/middleware/protection.ts) resolves user context in this priority order:

  1. Dev bypass (local dev only): If DEV_BYPASS_AUTH=true and on localhost, create an admin context using DEV_USER_ID
  2. JWT resolution: If Authorization: Bearer <token> is present, decode JWT and resolve role from custom claims (user_role, subscription_plan)
  3. Service auth fallback: If JWT resolution yields ANONYMOUS AND the request has a valid X-Service-Auth header, create a SERVICE context
// In protectionMiddleware():
userContext = resolveUserContextFromHeader(authHeader);

// Service-to-service auth fallback
if (userContext.role === UserRole.ANONYMOUS && hasValidServiceAuth(c)) {
  userContext = createUserContext(UserRole.SERVICE, "service");
}

SERVICE Role Permissions

The SERVICE role (shared/src/protection/roles.ts) has carefully scoped permissions:

Granted:

  • READ_PUBLIC_CONTENT — Browse published content
  • READ_PREVIEW_CONTENT — Read preview content
  • READ_FULL_CONTENT — Read full content
  • READ_PREMIUM_CONTENT — Read premium content
  • UNLIMITED_SEARCH — Search without limits
  • BYPASS_RATE_LIMITS — No rate limiting

Not granted:

  • TRACK_PROGRESS — Cannot access /me/* routes
  • CREATE_JOURNEY — Cannot create journeys
  • MANAGE_CONTENT — Cannot modify content
  • MANAGE_USERS — Cannot manage users

Database Access

When a service authenticates via X-Service-Auth (no JWT):

  • No Authorization header is forwarded to Supabase
  • getSupabaseClient() creates a client with the anon key
  • RLS policies for the anon role apply — published content is readable
  • User-specific tables (journeys, learning_events) are inaccessible
  • No RLS bypass — this approach respects all Row Level Security policies

Security Considerations

  • SERVICE_AUTH_SECRET must be a strong random string (32+ characters)
  • Only share with trusted backend services (Cloudflare Workers, build systems)
  • User JWT always takes precedence over service auth
  • Service auth only grants read access, never write access
  • All service requests are logged with userId: "service" in audit trails

Future Enhancements

Planned Features

  • Email/password authentication
  • Multi-factor authentication (MFA)
  • Social login with more providers
  • Device tracking and management
  • Session history and audit log
  • Domain events (user_registered, user_authenticated, user_logged_out)
  • Event consumption (subscription tier updates)

Event Publishing (TODO)

Once event infrastructure is ready, publish these domain events:

  1. identity.user_registered - New user completes OAuth signup
  2. identity.user_authenticated - Existing user logs in
  3. identity.user_logged_out - User logs out

References