OAuth 2.0 Authentication Template

A practical guide to implementing OAuth 2.0 with PKCE using SvelteKit and FastAPI, supporting both web and mobile (Android/iOS via Capacitor).

Table of Contents


What is OAuth 2.0?

OAuth 2.0 is an authorization framework that allows third-party applications to access user resources without exposing credentials. Instead of sharing passwords, users authorize apps to act on their behalf.

Example: When you click "Login with Google", you're redirected to Google's login page. After authenticating, Google asks if you want to grant the app access to your profile. If you agree, Google sends back a token the app can use.


Key Concepts

Authorization Server

The service that authenticates users and issues tokens (e.g., Google, Authentik).

Resource Server

The API that holds protected resources (e.g., Google's user profile API).

Client

Your application requesting access to user resources.

Tokens

Token Purpose Lifetime
Access Token Authorizes API requests Short (minutes to hours)
Refresh Token Obtains new access tokens Long (days to months)
ID Token Contains user identity info (OpenID Connect) Short

Scopes

Permissions requested by the client. Examples:

  • openid - Required for OpenID Connect
  • profile - User's name, picture
  • email - User's email address

PKCE Flow Explained

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. It's required for public clients and recommended for all OAuth flows.

Step-by-Step Flow

┌──────────┐                              ┌─────────────────┐
│  Browser │                              │ Auth Server     │
│ (Client) │                              │ (Google/etc)    │
└────┬─────┘                              └────────┬────────┘
     │                                             │
     │ 1. Generate code_verifier (random string)   │
     │ 2. Create code_challenge = SHA256(verifier) │
     │                                             │
     │ 3. Redirect to /authorize ─────────────────>│
     │    ?response_type=code                      │
     │    &client_id=xxx                           │
     │    &redirect_uri=http://localhost/callback  │
     │    &scope=openid profile email              │
     │    &state=random123                         │
     │    &code_challenge=abc123                   │
     │    &code_challenge_method=S256              │
     │                                             │
     │                         User logs in & consents
     │                                             │
     │ 4. Redirect to callback <───────────────────│
     │    ?code=AUTH_CODE&state=random123          │
     │                                             │
     │ 5. POST /token ────────────────────────────>│
     │    grant_type=authorization_code            │
     │    &code=AUTH_CODE                          │
     │    &redirect_uri=http://localhost/callback  │
     │    &client_id=xxx                           │
     │    &code_verifier=original_verifier  ←──────── Proves we initiated the request
     │                                             │
     │ 6. Receive tokens <─────────────────────────│
     │    { access_token, refresh_token, id_token }│
     │                                             │

Why PKCE Works

  1. code_verifier: Random string generated by client (kept secret)
  2. code_challenge: Hash of verifier sent in authorization request
  3. Verification: Token endpoint requires original verifier that hashes to the challenge

An attacker who intercepts the authorization code cannot exchange it without the original verifier.


Security Architecture

All Token Exchanges via Backend

For security, ALL OAuth token exchanges go through the backend - even for public clients like Authentik. This keeps OAuth URLs and any secrets server-side only.

Frontend                    Backend                     OAuth Provider
   │                           │                              │
   │  1. Redirect to provider ─────────────────────────────> │
   │                           │                              │
   │  2. Callback with code  <─────────────────────────────── │
   │                           │                              │
   │  3. POST /api/login/{provider}/token                     │
   │     {code, code_verifier} │                              │
   │  ─────────────────────>   │                              │
   │                           │  4. Exchange code + secret   │
   │                           │  ─────────────────────────>  │
   │                           │                              │
   │                           │  5. Tokens                   │
   │                           │  <─────────────────────────  │
   │  6. Tokens + userinfo     │                              │
   │  <─────────────────────   │                              │

Why Backend-First?

  1. Secrets stay secret: Client secrets never touch the browser
  2. Consistent pattern: Same flow for all providers
  3. Future-proof: Easy to add user registration, rate limiting, etc.
  4. Token validation: Backend can validate tokens before returning

Implementation Guide

1. PKCE Utilities (frontend/src/lib/auth/pkce.ts)

// Generate random string for code_verifier
export function generateRandomString(length: number): string {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  return Array.from(crypto.getRandomValues(new Uint8Array(length)))
    .map((x) => chars[x % chars.length])
    .join('');
}

// Create code_challenge from verifier using SHA-256
export async function generateCodeChallenge(verifier: string): Promise<string> {
  const data = new TextEncoder().encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return btoa(String.fromCharCode(...new Uint8Array(hash)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

2. Start Login (frontend/src/lib/auth/auth.service.ts)

export async function startLogin(providerName: AuthProvider) {
  const provider = providers[providerName];

  const codeVerifier = generateRandomString(128);
  const codeChallenge = await generateCodeChallenge(codeVerifier);
  const nonce = generateRandomString(32);

  if (isWeb()) {
    // Web: store PKCE in sessionStorage, state is just the nonce
    sessionStorage.setItem(`pkce_${providerName}`, codeVerifier);
    sessionStorage.setItem(`state_${providerName}`, nonce);
  }

  // Mobile: encode verifier in state so the trampoline page can use it
  const state = isWeb() ? nonce : `${nonce}.${codeVerifier}`;

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: provider.clientId,
    redirect_uri: provider.redirectUri,
    scope: provider.scope,
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256'
  });

  window.location.href = `${provider.authorizeUrl}?${params}`;
}

3. Handle Callback (Web)

export async function handleOAuthCallback(
  providerName: AuthProvider,
  code: string,
  state: string
): Promise<LoginInfo> {
  const savedState = sessionStorage.getItem(`state_${providerName}`);
  const verifier = sessionStorage.getItem(`pkce_${providerName}`);

  if (!verifier || state !== savedState) {
    throw new Error('Invalid OAuth state');
  }

  // Exchange code via backend, get tokens + userinfo
  // Clean up sessionStorage after
}

4. Provider Configuration

Frontend only needs public info (no secrets, no token URLs):

// Authentik
export const authentikProvider: OAuthProviderConfig = {
  name: 'authentik',
  authorizeUrl: `${AUTHENTIK_URL}/application/o/authorize/`,
  clientId: 'your-client-id',
  redirectUri: 'http://localhost:3000/callback/authentik',
  scope: 'openid profile email'
};

// Google
export const googleProvider: OAuthProviderConfig = {
  name: 'google',
  authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
  clientId: 'your-client-id',
  redirectUri: 'http://localhost:3000/callback/google',
  scope: 'openid profile email'
};

5. Backend Token Exchange (handles all providers)

@router.post("/{provider}/token")
async def token_exchange(request: TokenRequest, provider: Literal["google", "authentik"]):
    if provider == "google":
        return await _exchange_google(request)
    else:
        return await _exchange_authentik(request)

async def _exchange_google(request: TokenRequest):
    async with httpx.AsyncClient() as client:
        resp = await client.post(
            "https://oauth2.googleapis.com/token",
            data={
                "grant_type": "authorization_code",
                "code": request.code,
                "redirect_uri": request.redirect_uri,
                "client_id": GOOGLE_CLIENT_ID,
                "client_secret": GOOGLE_CLIENT_SECRET,  # Secret stays on server
                "code_verifier": request.code_verifier,
            },
        )
        # ... fetch userinfo and return

Mobile Support (Capacitor)

This template supports Android/iOS via Capacitor. The key challenge is that OAuth providers don't allow custom URL schemes (e.g., authtemplate://) as redirect URIs for web client types. The solution is the trampoline pattern.

Mobile Detection

Platform detection uses build-time VITE_PLATFORM env var with runtime Capacitor checks:

// frontend/src/lib/platform/detect.ts
const isMobile = import.meta.env.VITE_PLATFORM === 'mobile';

export function isWeb(): boolean {
  return !isMobile;
}

export async function getPlatform(): Promise<Platform> {
  if (!isMobile) return 'web';
  const { Capacitor } = await import('@capacitor/core');
  const platform = Capacitor.getPlatform();
  if (platform === 'android') return 'android';
  if (platform === 'ios') return 'ios';
  return 'web';
}

Build distinction (vite.config.ts):

  • Web build: Capacitor plugins are externalized (not bundled)
  • Mobile build (VITE_PLATFORM=mobile): Capacitor plugins are bundled

Trampoline Pattern (Mobile OAuth)

Since Google doesn't allow custom URL schemes as redirect URIs:

┌─────────────┐     ┌──────────────┐     ┌─────────────────┐
│  Mobile App │     │ Trampoline   │     │  OAuth Provider  │
│ (Capacitor) │     │ (Prod Server)│     │  (Google/etc)    │
└──────┬──────┘     └──────┬───────┘     └────────┬────────┘
       │                   │                       │
       │ 1. Open Chrome browser                    │
       │    with authorize URL ───────────────────>│
       │    redirect_uri = prodUrl/callback/android│
       │    state = nonce.verifier (PKCE in state) │
       │                                           │
       │                   │  2. Redirect with code│
       │                   │<──────────────────────│
       │                   │                       │
       │                   │ 3. Extract verifier   │
       │                   │    from state         │
       │                   │ 4. Exchange code      │
       │                   │    for tokens ───────>│
       │                   │                       │
       │                   │ 5. Receive tokens <───│
       │                   │                       │
       │ 6. Deep link:     │                       │
       │    authtemplate:// │                       │
       │    auth?data=JSON │                       │
       │<──────────────────│                       │
       │                   │                       │
       │ 7. Parse LoginInfo│                       │
       │    Store in auth  │                       │
       │    Navigate to /  │                       │

Why encode PKCE verifier in state? Android may kill the app while the user is in Chrome. PKCE verifier stored in sessionStorage would be lost. Encoding it in the OAuth state parameter ensures the trampoline page has everything needed. The backend's client_secret maintains security.

The app listens for deep links in +layout.svelte:

onMount(() => {
  if (!isWeb()) {
    import('@capacitor/app').then(({ App }) => {
      // Warm start: app in background
      App.addListener('appUrlOpen', ({ url }) => {
        processDeepLink(url);
      });

      // Cold start: app killed, relaunched via deep link
      App.getLaunchUrl().then((result) => {
        if (result?.url) processDeepLink(result.url);
      });
    });
  }
});

Trampoline Callback Pages

Located at frontend/src/routes/callback/android/{provider}/+page.svelte:

  1. Receives auth code + state from OAuth provider redirect
  2. Extracts PKCE verifier from state (nonce.verifier format)
  3. Exchanges code for tokens via backend API
  4. Fetches user role via /api/login/me
  5. Builds LoginInfo JSON and redirects to app via deep link

Project Structure

auth-template/
├── backend/
│   ├── src/
│   │   ├── main.py              # FastAPI app, CORS
│   │   └── router/auth.py       # Token exchange for all providers
│   ├── Dockerfile
│   └── requirements.txt
├── frontend/
│   ├── src/
│   │   ├── lib/
│   │   │   ├── auth/
│   │   │   │   ├── auth.service.ts   # Login, callback, deep link handler
│   │   │   │   ├── pkce.ts           # PKCE utilities
│   │   │   │   └── providers/        # Provider configs (public info only)
│   │   │   ├── platform/
│   │   │   │   ├── detect.ts         # isWeb(), getPlatform()
│   │   │   │   ├── types.ts          # Platform type
│   │   │   │   └── index.ts          # Re-exports
│   │   │   ├── stores/auth.store.ts  # Auth state management
│   │   │   └── types/auth.ts         # TypeScript types
│   │   └── routes/
│   │       ├── +layout.svelte        # Deep link listener (mobile)
│   │       ├── login/+page.svelte    # Login page
│   │       └── callback/
│   │           ├── google/           # Web callback
│   │           ├── authentik/        # Web callback
│   │           └── android/          # Mobile trampoline callbacks
│   │               ├── google/
│   │               └── authentik/
│   ├── capacitor.config.ts           # Capacitor configuration
│   ├── Dockerfile
│   └── package.json
├── docker-compose.yml
└── .env.example

Environment Variables

Backend (.env)

Variable Description
AUTHENTIK_URL Authentik instance URL
AUTHENTIK_CLIENT_ID Authentik OAuth client ID
GOOGLE_CLIENT_ID Google OAuth client ID
GOOGLE_CLIENT_SECRET Google OAuth client secret (backend only!)

Frontend (injected at build time via vite.config.ts)

Variable Description
VITE_BACKEND_URL Backend API URL (auto-set by build mode)
VITE_AUTHENTIK_URL Authentik URL (from AUTHENTIK_URL)
VITE_AUTHENTIK_CLIENT_ID Authentik client ID (from AUTHENTIK_CLIENT_ID)
VITE_AUTHENTIK_REDIRECT_URI Callback URL (web or mobile trampoline)
VITE_GOOGLE_CLIENT_ID Google client ID (from GOOGLE_CLIENT_ID)
VITE_GOOGLE_REDIRECT_URI Callback URL (web or mobile trampoline)
VITE_PLATFORM "browser" (default) or "mobile" for Capacitor builds

Mobile-specific

Variable Description
VITE_PLATFORM Set to "mobile" for Capacitor builds
PROD_URL Production server URL for trampoline redirects

Key insight: Redirect URIs differ between web and mobile:

  • Web: http://localhost:3000/callback/{provider}
  • Mobile: https://your-app.example.com/callback/android/{provider} (trampoline)

Setup

  1. Clone and configure:

    cp .env.example .env
    # Edit .env with your OAuth credentials
    
  2. Configure providers:

    Google:

    Authentik:

    • Create OAuth2 Provider in Authentik admin
    • Add redirect URI: http://localhost:3000/callback/authentik
    • For mobile, also add: https://your-app.example.com/callback/android/authentik
    • Use public client (PKCE enabled)
  3. Run (web):

    docker compose up
    

    Or locally:

    # Backend
    cd backend && pip install -r requirements.txt
    cd src && uvicorn main:app --reload --port 5000
    
    # Frontend
    cd frontend && npm install && npm run dev
    
  4. Build for mobile (Capacitor):

    cd frontend
    
    # Install dependencies
    npm install
    
    # Build for mobile (bundles Capacitor plugins)
    VITE_PLATFORM=mobile npm run build
    
    # Add Android platform (first time only)
    npx cap add android
    
    # Sync web assets to native project
    npx cap sync
    
    # Or use the shortcut: npm run cap
    
    # Open in Android Studio
    npx cap open android
    
  5. Open: http://localhost:3000


Security Checklist

  • Use PKCE for all OAuth flows
  • Validate state parameter to prevent CSRF
  • Store tokens securely (httpOnly cookies or secure storage)
  • Keep client secrets on backend only
  • Use HTTPS in production
  • Validate token signatures (for JWTs)
  • Implement token refresh before expiry
  • Mobile: PKCE verifier encoded in state (survives app kill)
  • Mobile: Trampoline pattern for OAuth redirects

Further Reading

Top categories

Loading Svelte Themes