Skip to content

πŸ“š Auth Architecture Reference: Better Auth OAuth Server & Client IntegrationΒ #1501

@damonshen17

Description

@damonshen17

Better Auth OAuth Architecture Reference

This document provides a comprehensive reference for the centralized authentication system using Better Auth with OAuth 2.1.

Architecture Overview

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Auth Server (apps/auth, port 3001)             β”‚
β”‚                   PostgreSQL + Better Auth                   β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚ OAuth 2.1 Provider (issues tokens to clients)       β”‚   β”‚
β”‚  β”‚ Google/LinkedIn OAuth (upstream verification)        β”‚   β”‚
β”‚  β”‚ Credentials + Token plugins (DLAI API validation)   β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            ↑↓
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        ↓                   ↓                   ↓
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ Next.js β”‚      β”‚TanStack Startβ”‚      β”‚  Expo   β”‚
   β”‚ (3000)  β”‚      β”‚   (3002)     β”‚      β”‚ Mobile  β”‚
   β”‚Statelessβ”‚      β”‚  Stateless   β”‚      β”‚         β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

1. Auth Server Setup (apps/auth/)

Required Environment Variables

# Core
AUTH_URL=http://localhost:3001
AUTH_SECRET=<min 32 chars>          # Encrypts tokens + signs cookies
POSTGRES_URL=postgresql://...

# Google OAuth
GOOGLE_CLIENT_ID=<from Google Console>
GOOGLE_CLIENT_SECRET=<from Google Console>

# LinkedIn OAuth (optional)
LINKEDIN_CLIENT_ID=<from LinkedIn>
LINKEDIN_CLIENT_SECRET=<from LinkedIn>

# Upstream API
DLAI_PYTHON_LEARN_API_URL=https://api.deeplearning.ai

Database Tables

Run pnpm auth:generate to generate schema in packages/db/src/auth-schema.ts:

Table Purpose
user User accounts + dlaiUserId, dlaiJwtToken (staging)
session Sessions + dlaiJwtToken (authoritative, encrypted)
account OAuth provider links (Google, LinkedIn tokens)
verification Email verification tokens
jwks JWT signing keys (auto-rotated)
oauth_client Registered OAuth clients
oauth_access_token Issued access tokens
oauth_refresh_token Issued refresh tokens
oauth_consent User consent records

Better Auth Configuration

File: apps/auth/src/lib/auth.ts

betterAuth({
  database: drizzleAdapter(db, { provider: "pg" }),
  baseURL: env.AUTH_URL,
  secret: env.AUTH_SECRET,
  
  plugins: [
    expo(),                    // React Native support
    credentialsPlugin({...}),  // Email/password β†’ DLAI API
    tokenPlugin({...}),        // Existing token β†’ DLAI API
    linkedInProvider({...}),   // LinkedIn legacy OAuth
    customSession({...}),      // Decrypt token in responses
    jwt(),                     // JWT signing (required for OAuth Provider)
    oauthProvider({...}),      // OAuth 2.1 server
  ],
  
  socialProviders: {
    google: { clientId, clientSecret, prompt: "select_account" }
  },
  
  session: {
    storeSessionInDatabase: true,  // Required for OAuth Provider
    additionalFields: { dlaiJwtToken: { type: "string" } }
  },
  
  databaseHooks: {
    account: accountHooks,     // OAuth token β†’ DLAI API validation
    session: sessionHooks,     // Single session + token staging
  }
})

Plugin Reference

Plugin Endpoint Purpose
credentialsPlugin POST /api/auth/sign-in/credentials Email/password auth
tokenPlugin POST /api/auth/sign-in/token Existing DLAI token auth
oauthProvider Various OAuth 2.1 endpoints Issues tokens to clients
customSession Transforms GET /api/auth/get-session Decrypts dlaiJwtToken
jwt Internal Generates/rotates signing keys

Token Storage Strategy

OAuth Flow (Google/LinkedIn):
  1. account.create.after β†’ Call DLAI API, get token
  2. Store encrypted token on user.dlaiJwtToken (staging)
  3. session.create.after β†’ Copy to session.dlaiJwtToken
  4. customSession β†’ Decrypt for API responses

Credentials/Token Flow:
  1. Plugin validates with DLAI API
  2. Store encrypted token directly on session.dlaiJwtToken

Why two dlaiJwtToken fields?

  • Better Auth creates sessions AFTER account hooks complete
  • user.dlaiJwtToken = temporary staging during OAuth
  • session.dlaiJwtToken = authoritative source (encrypted)

2. Client Integration (apps/nextjs/)

Required Environment Variables

# Server-side
AUTH_SECRET=<must match auth server>
DLAI_OAUTH_CLIENT_ID=<registered client ID>
DLAI_OAUTH_CLIENT_SECRET=<registered client secret>

# Client-side
NEXT_PUBLIC_AUTH_URL=http://localhost:3001
NEXT_PUBLIC_APP_URL=http://localhost:3000

Register Client with Auth Server

Insert into oauth_client table:

INSERT INTO oauth_client (
  id, client_id, client_secret, name,
  redirect_uris, scopes, skip_consent, public
) VALUES (
  gen_random_uuid(),
  'nextjs-app',
  '<generated-secret>',
  'Next.js App',
  ARRAY['http://localhost:3000/api/auth/callback/dlai'],
  ARRAY['openid', 'profile', 'email', 'dlai'],
  true,   -- First-party app, no consent screen
  false   -- Confidential client (has secret)
);

Better Auth Config (Stateless Mode)

File: apps/nextjs/src/lib/auth.ts

betterAuth({
  baseURL: env.NEXT_PUBLIC_APP_URL,
  secret: env.AUTH_SECRET,
  trustedOrigins: [env.NEXT_PUBLIC_AUTH_URL],
  
  plugins: [
    genericOAuth({
      config: [{
        providerId: "dlai",
        clientId: env.DLAI_OAUTH_CLIENT_ID,
        clientSecret: env.DLAI_OAUTH_CLIENT_SECRET,
        discoveryUrl: `${AUTH_URL}/.well-known/openid-configuration`,
        scopes: ["openid", "profile", "email", "dlai"],
        pkce: true,
        authorizationUrlParams: { prompt: "login" },
        
        async getUserInfo(tokens) {
          const claims = decodeJwt(tokens.idToken);
          pendingClaims.set(claims.sub, {
            dlaiJwtToken: claims.dlaiJwtToken,
            dlaiUserId: claims.dlaiUserId
          });
          return { id: claims.sub, email: claims.email, ... };
        }
      }]
    })
  ],
  
  hooks: {
    after: createAuthMiddleware(async (ctx) => {
      // On callback: store DLAI claims in cookies
      const claims = pendingClaims.get(userId);
      ctx.setCookie("dlai_jwt_token", claims.dlaiJwtToken);
      ctx.setCookie("dlai_user_id", String(claims.dlaiUserId));
    })
  }
})

Session Retrieval

File: apps/nextjs/src/auth/server.ts

export const getSession = cache(async () => {
  const session = await betterAuth.api.getSession({ headers });
  
  // Enrich with DLAI cookies (stateless mode)
  const cookieStore = await cookies();
  return {
    user: {
      ...session.user,
      dlaiJwtToken: cookieStore.get("dlai_jwt_token")?.value,
      dlaiUserId: parseInt(cookieStore.get("dlai_user_id")?.value)
    }
  };
});

3. Shared Packages

@dlai/auth (packages/auth/)

Export Purpose
createUpstreamClient() DLAI API client wrapper
credentialsPlugin, tokenPlugin Auth plugins for Better Auth
createAccountHooks() OAuth token β†’ DLAI validation hooks
encrypt(), decrypt() AES-256-GCM token encryption
findOrCreateUser() User management utility

@dlai/db (packages/db/)

Export Purpose
db Drizzle ORM client
user, session, account, ... Auth schema tables

4. Setup Checklist

New Auth Server

  • Set POSTGRES_URL and run pnpm db:push
  • Run pnpm auth:generate to create schema
  • Configure AUTH_SECRET (min 32 chars)
  • Configure AUTH_URL
  • Set up Google OAuth in Google Console
    • Redirect URI: {AUTH_URL}/api/auth/callback/google
  • (Optional) Set up LinkedIn OAuth
    • Redirect URI: {AUTH_URL}/api/auth/oauth2/callback/linkedin

New Client App

  • Register OAuth client in oauth_client table:
    • client_id, client_secret
    • redirect_uris: ["{APP_URL}/api/auth/callback/dlai"]
    • scopes: ["openid", "profile", "email", "dlai"]
    • skip_consent: true (first-party apps)
  • Configure same AUTH_SECRET as auth server
  • Set DLAI_OAUTH_CLIENT_ID and DLAI_OAUTH_CLIENT_SECRET
  • Configure discoveryUrl pointing to auth server
  • Implement DLAI claims extraction from id_token
  • Store claims in cookies for server component access

5. Auth Flow Diagrams

OAuth Flow (Google/LinkedIn)

User clicks "Sign in"
       ↓
Auth Server redirects to Google/LinkedIn
       ↓
User authenticates with provider
       ↓
Provider redirects back with code
       ↓
Auth Server exchanges code for tokens
       ↓
Auth Server calls DLAI API to validate
       ↓
Auth Server creates user + session
       ↓
Auth Server issues JWT with dlaiJwtToken claim
       ↓
Client receives JWT, decodes claims
       ↓
Client stores DLAI claims in cookies
       ↓
βœ“ User authenticated

Credentials Flow

User submits email/password
       ↓
POST /api/auth/sign-in/credentials
       ↓
Auth Server calls DLAI API (loginByPassword)
       ↓
Auth Server creates user + session
       ↓
Auth Server returns { user, session, dlaiJwtToken }
       ↓
βœ“ User authenticated

Token Flow

User has existing DLAI JWT
       ↓
POST /api/auth/sign-in/token
       ↓
Auth Server calls DLAI API (getProfile)
       ↓
Auth Server creates user + session
       ↓
Auth Server returns { user, session, dlaiJwtToken }
       ↓
βœ“ User authenticated

6. Key Files Reference

File Purpose
apps/auth/src/lib/auth.ts Auth server Better Auth config
apps/auth/src/lib/linkedin-provider.ts LinkedIn legacy OAuth
apps/auth/src/lib/trusted-origins.ts CORS allowed origins
apps/nextjs/src/lib/auth.ts Client Better Auth config
apps/nextjs/src/auth/server.ts Session retrieval helpers
apps/nextjs/src/lib/cookies.ts Cookie configuration
packages/auth/src/plugins/dlai-auth.ts Credentials + Token plugins
packages/auth/src/auth-service.ts User management + OAuth hooks
packages/auth/src/crypto.ts AES-256-GCM encryption
packages/db/src/auth-schema.ts Generated database schema

7. Troubleshooting

Problem Cause Solution
"Invalid credentials" DLAI API call fails Check DLAI_PYTHON_LEARN_API_URL
CORS error Origin not trusted Add to trustedOrigins or TRUSTED_ORIGINS env
Missing dlaiJwtToken Not in id_token claims Verify customIdTokenClaims in oauthProvider
Multiple sessions Hook not working Check session.create.before deletes old sessions
"No id_token" OIDC misconfiguration Verify discoveryUrl returns valid metadata
Cookie not set SameSite/Secure mismatch Check cookieOptions match environment

Related Documentation

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions