From Zero to AI

Lesson 6.3: OAuth Basics

Duration: 55 minutes

Learning Objectives

By the end of this lesson, you will be able to:

  1. Understand what OAuth is and the problem it solves
  2. Explain the OAuth 2.0 authorization flow conceptually
  3. Identify the roles and components in OAuth
  4. Understand the difference between authentication and authorization
  5. Recognize common OAuth flows used in different applications

Introduction

Imagine you want to use an app that posts to Twitter on your behalf. The app needs access to your Twitter account, but you definitely do not want to give them your Twitter password. How can the app post for you without knowing your password?

This is the problem OAuth solves. It allows users to grant limited access to their accounts on one service to another application, without sharing passwords.

Traditional (Dangerous):
  You → Give password to App → App logs in as you

OAuth (Safe):
  You → Tell Twitter "Let this app post for me" → Twitter gives app a token

The Problem OAuth Solves

Before OAuth

In the early days of the web, if an app needed access to your data on another service, you had to give it your password:

Problems with sharing passwords:
1. The app has full access to your account
2. You can't limit what the app can do
3. To revoke access, you must change your password
4. If the app is compromised, your password leaks
5. The app might store your password insecurely

With OAuth

OAuth introduces a middleman - the authorization server:

OAuth Flow:
1. App asks Twitter: "Can I access this user's tweets?"
2. Twitter asks you: "Do you want to let this app access your tweets?"
3. You say: "Yes, but only for reading, not posting"
4. Twitter gives the app a token with read-only access
5. App uses the token - never sees your password

OAuth Terminology

Understanding OAuth requires knowing these key terms:

Roles

Role Description Example
Resource Owner The user who owns the data You
Client The application requesting access A photo editing app
Authorization Server Issues tokens after user consent accounts.google.com
Resource Server Hosts the protected data (API) api.google.com

Components

Component Description
Authorization Code Temporary code exchanged for tokens
Access Token Credential used to access resources
Refresh Token Long-lived token to get new access tokens
Scope Permissions being requested
Redirect URI Where user is sent after authorization
Client ID Public identifier for the application
Client Secret Secret key known only to app and auth server

Authentication vs Authorization

These terms are often confused but mean different things:

Authentication

"Who are you?" - Verifying identity

// Authentication: Proving you are John
login(username: "john", password: "secret123");
// Server: "Yes, this is John"

Authorization

"What can you do?" - Granting permissions

// Authorization: What can John access?
const permissions = {
  readPosts: true,
  writePosts: true,
  deleteAccount: false,
  adminPanel: false,
};

OAuth is Primarily About Authorization

OAuth lets you authorize an application to access your resources. The application does not authenticate as you - it gets permission to act on your behalf.

"Login with Google" is actually:
1. Authentication: Google verifies you are you
2. Authorization: You permit the app to see your email/name

The OAuth 2.0 Authorization Code Flow

This is the most common and secure OAuth flow, used by web applications.

Step-by-Step

┌─────────┐                                    ┌─────────────────┐
│  User   │                                    │   Application   │
└────┬────┘                                    └────────┬────────┘
     │                                                  │
     │  1. Click "Login with GitHub"                    │
     │ ─────────────────────────────────────────────────>
     │                                                  │
     │  2. Redirect to GitHub authorization URL         │
     │ <─────────────────────────────────────────────────
     │                                                  │
     │                                    ┌─────────────┴───────────┐
     │                                    │   GitHub (Auth Server)  │
     │                                    └─────────────┬───────────┘
     │                                                  │
     │  3. GitHub shows consent screen                  │
     │ <─────────────────────────────────────────────────
     │                                                  │
     │  4. User approves access                         │
     │ ─────────────────────────────────────────────────>
     │                                                  │
     │  5. Redirect back to app with authorization code │
     │ <─────────────────────────────────────────────────
     │                                                  │
     │                                    ┌─────────────┴───────────┐
     │                                    │      Application        │
     │                                    └─────────────┬───────────┘
     │                                                  │
     │  6. App exchanges code for tokens (server-side)  │
     │                                                  │
     │  7. App receives access token and refresh token  │
     │                                                  │
     │  8. App can now make API calls on user's behalf  │
     │                                                  │

Step 1: Initiate Authorization

The app creates an authorization URL and redirects the user:

function buildAuthorizationUrl(
  authEndpoint: string,
  clientId: string,
  redirectUri: string,
  scopes: string[],
  state: string
): string {
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: scopes.join(' '),
    state: state, // Random string to prevent CSRF
  });

  return `${authEndpoint}?${params.toString()}`;
}

// Example: GitHub OAuth
const authUrl = buildAuthorizationUrl(
  'https://github.com/login/oauth/authorize',
  'your-client-id',
  'https://yourapp.com/callback',
  ['read:user', 'user:email'],
  'random-state-string-12345'
);

// Redirect user to this URL
// window.location.href = authUrl;

Step 2: User Grants Permission

GitHub shows the user a consent screen:

┌────────────────────────────────────────────┐
│                                            │
│   Authorize YourApp                        │
│                                            │
│   YourApp wants to access your GitHub      │
│   account                                  │
│                                            │
│   This will allow YourApp to:              │
│   ✓ Read your profile information          │
│   ✓ Read your email address                │
│                                            │
│   [ Authorize YourApp ]  [ Cancel ]        │
│                                            │
└────────────────────────────────────────────┘

Step 3: Receive Authorization Code

After approval, GitHub redirects back to your app with a code:

https://yourapp.com/callback?code=abc123xyz&state=random-state-string-12345
// Parse the callback URL
function handleOAuthCallback(url: string): { code: string; state: string } {
  const urlObj = new URL(url);
  const code = urlObj.searchParams.get('code');
  const state = urlObj.searchParams.get('state');

  if (!code) {
    throw new Error('Authorization code not found');
  }

  if (!state) {
    throw new Error('State parameter not found');
  }

  return { code, state };
}

// Verify state matches what you sent (prevents CSRF attacks)
function verifyState(received: string, expected: string): void {
  if (received !== expected) {
    throw new Error('State mismatch - possible CSRF attack');
  }
}

Step 4: Exchange Code for Tokens

The authorization code is exchanged for tokens (server-side only):

interface TokenResponse {
  access_token: string;
  token_type: string;
  scope: string;
  refresh_token?: string;
  expires_in?: number;
}

async function exchangeCodeForTokens(
  tokenEndpoint: string,
  code: string,
  clientId: string,
  clientSecret: string,
  redirectUri: string
): Promise<TokenResponse> {
  const response = await fetch(tokenEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      client_id: clientId,
      client_secret: clientSecret,
      redirect_uri: redirectUri,
    }).toString(),
  });

  if (!response.ok) {
    const error = await response.text();
    throw new Error(`Token exchange failed: ${error}`);
  }

  return response.json();
}

// Example: GitHub token exchange
const tokens = await exchangeCodeForTokens(
  'https://github.com/login/oauth/access_token',
  'abc123xyz', // The code from callback
  'your-client-id',
  'your-client-secret',
  'https://yourapp.com/callback'
);

console.log('Access token:', tokens.access_token);

Step 5: Use the Access Token

Now you can make API requests on behalf of the user:

async function getGitHubUser(accessToken: string) {
  const response = await fetch('https://api.github.com/user', {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      Accept: 'application/vnd.github.v3+json',
    },
  });

  return response.json();
}

const user = await getGitHubUser(tokens.access_token);
console.log(`Hello, ${user.login}!`);

Scopes: Limiting Permissions

Scopes define what the application can access. Users can see exactly what they are granting.

Example Scopes

// GitHub scopes
const scopes = [
  'read:user', // Read user profile
  'user:email', // Access email addresses
  'repo', // Full access to repositories
  'repo:status', // Access commit status
  'delete_repo', // Delete repositories (dangerous!)
];

// Google scopes
const googleScopes = [
  'https://www.googleapis.com/auth/userinfo.email',
  'https://www.googleapis.com/auth/userinfo.profile',
  'https://www.googleapis.com/auth/calendar.readonly',
  'https://www.googleapis.com/auth/drive.file',
];

Best Practice: Request Minimum Scopes

// BAD: Requesting everything
const scopes = ["repo", "user", "gist", "delete_repo", "admin:org"];

// GOOD: Only what you need
const scopes = ["read:user", "user:email"];

Other OAuth Flows

Implicit Flow (Legacy)

For browser-only apps, tokens returned directly in URL. Deprecated - do not use for new applications.

// Token directly in URL fragment (insecure)
https://yourapp.com/callback#access_token=xyz&token_type=bearer

Client Credentials Flow

For server-to-server communication, no user involved:

async function getClientCredentialsToken(
  tokenEndpoint: string,
  clientId: string,
  clientSecret: string
): Promise<{ access_token: string }> {
  const response = await fetch(tokenEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: clientId,
      client_secret: clientSecret,
    }).toString(),
  });

  return response.json();
}

Device Authorization Flow

For devices without browsers (smart TVs, CLI tools):

1. Device shows: "Go to github.com/device and enter code: ABCD-1234"
2. User opens URL on their phone/computer
3. User enters code and approves
4. Device polls for token and eventually receives it

PKCE (Proof Key for Code Exchange)

An extension for public clients (mobile apps, SPAs) that cannot keep secrets:

function generateCodeVerifier(): string {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return btoa(String.fromCharCode(...array))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

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

// Usage in authorization request
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);

const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');

// Later, in token exchange, include:
// code_verifier: codeVerifier

Security Considerations

1. Always Use HTTPS

OAuth tokens grant access to user accounts. Never send them over HTTP.

2. Validate the State Parameter

The state parameter prevents CSRF attacks:

// When initiating OAuth:
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);

// In the callback:
const receivedState = new URL(window.location.href).searchParams.get('state');
const savedState = sessionStorage.getItem('oauth_state');

if (receivedState !== savedState) {
  throw new Error('Invalid state - possible attack!');
}

3. Keep Client Secrets Secret

Never expose client secrets in client-side code:

// BAD: Secret in frontend code
const CLIENT_SECRET = 'super-secret-123'; // Anyone can see this!

// GOOD: Token exchange happens on your server
// Frontend sends code to your backend
// Backend exchanges code using secret

4. Use Short-Lived Access Tokens

Access tokens should expire quickly. Use refresh tokens for long-term access.

5. Store Tokens Securely

// Browser: HttpOnly cookies are best (set by server)
// Fallback: sessionStorage > localStorage

// Node.js: Environment variables or secret managers
const token = process.env.ACCESS_TOKEN;

OAuth in Action: Common Providers

GitHub OAuth URLs

const GITHUB_CONFIG = {
  authorizationEndpoint: 'https://github.com/login/oauth/authorize',
  tokenEndpoint: 'https://github.com/login/oauth/access_token',
  userInfoEndpoint: 'https://api.github.com/user',
  scopes: ['read:user', 'user:email'],
};

Google OAuth URLs

const GOOGLE_CONFIG = {
  authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
  tokenEndpoint: 'https://oauth2.googleapis.com/token',
  userInfoEndpoint: 'https://www.googleapis.com/oauth2/v2/userinfo',
  scopes: [
    'https://www.googleapis.com/auth/userinfo.email',
    'https://www.googleapis.com/auth/userinfo.profile',
  ],
};

Twitter/X OAuth 2.0 URLs

const TWITTER_CONFIG = {
  authorizationEndpoint: 'https://twitter.com/i/oauth2/authorize',
  tokenEndpoint: 'https://api.twitter.com/2/oauth2/token',
  userInfoEndpoint: 'https://api.twitter.com/2/users/me',
  scopes: ['tweet.read', 'users.read'],
};

Conceptual Example: Full OAuth Flow

Here is how all the pieces fit together:

// Configuration
const config = {
  clientId: process.env.GITHUB_CLIENT_ID!,
  clientSecret: process.env.GITHUB_CLIENT_SECRET!,
  authEndpoint: 'https://github.com/login/oauth/authorize',
  tokenEndpoint: 'https://github.com/login/oauth/access_token',
  redirectUri: 'http://localhost:3000/auth/callback',
  scopes: ['read:user', 'user:email'],
};

// Step 1: Generate authorization URL
function getAuthorizationUrl(): { url: string; state: string } {
  const state = crypto.randomUUID();

  const params = new URLSearchParams({
    client_id: config.clientId,
    redirect_uri: config.redirectUri,
    scope: config.scopes.join(' '),
    state: state,
  });

  return {
    url: `${config.authEndpoint}?${params.toString()}`,
    state: state,
  };
}

// Step 2: Handle callback and exchange code
async function handleCallback(
  code: string,
  receivedState: string,
  expectedState: string
): Promise<{ accessToken: string; user: unknown }> {
  // Verify state
  if (receivedState !== expectedState) {
    throw new Error('Invalid state parameter');
  }

  // Exchange code for token
  const tokenResponse = await fetch(config.tokenEndpoint, {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      client_id: config.clientId,
      client_secret: config.clientSecret,
      code: code,
      redirect_uri: config.redirectUri,
    }).toString(),
  });

  const tokens = await tokenResponse.json();

  if (tokens.error) {
    throw new Error(`OAuth error: ${tokens.error_description}`);
  }

  // Fetch user info
  const userResponse = await fetch('https://api.github.com/user', {
    headers: {
      Authorization: `Bearer ${tokens.access_token}`,
      Accept: 'application/vnd.github.v3+json',
    },
  });

  const user = await userResponse.json();

  return {
    accessToken: tokens.access_token,
    user: user,
  };
}

// Usage flow:
// 1. User clicks "Login with GitHub"
// 2. Generate auth URL and save state
//    const { url, state } = getAuthorizationUrl();
//    saveState(state); // Store in session
//    redirect(url);    // Send user to GitHub
//
// 3. User approves on GitHub
// 4. GitHub redirects to /auth/callback?code=xxx&state=yyy
// 5. Handle callback
//    const { accessToken, user } = await handleCallback(code, state, savedState);
//    createSession(user, accessToken);

Exercises

Exercise 1: Build Authorization URL

Create a function that builds a complete OAuth authorization URL:

interface OAuthConfig {
  authEndpoint: string;
  clientId: string;
  redirectUri: string;
  scopes: string[];
}

function buildAuthUrl(config: OAuthConfig): { url: string; state: string } {}
Solution
interface OAuthConfig {
  authEndpoint: string;
  clientId: string;
  redirectUri: string;
  scopes: string[];
}

function generateState(): string {
  const array = new Uint8Array(16);
  crypto.getRandomValues(array);
  return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
}

function buildAuthUrl(config: OAuthConfig): { url: string; state: string } {
  const state = generateState();

  const url = new URL(config.authEndpoint);
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('client_id', config.clientId);
  url.searchParams.set('redirect_uri', config.redirectUri);
  url.searchParams.set('scope', config.scopes.join(' '));
  url.searchParams.set('state', state);

  return {
    url: url.toString(),
    state: state,
  };
}

// Test
const { url, state } = buildAuthUrl({
  authEndpoint: 'https://github.com/login/oauth/authorize',
  clientId: 'my-client-id',
  redirectUri: 'http://localhost:3000/callback',
  scopes: ['read:user', 'user:email'],
});

console.log('Auth URL:', url);
console.log('State (save this):', state);

Exercise 2: Parse Callback URL

Create a function that extracts and validates OAuth callback parameters:

interface CallbackParams {
  code: string;
  state: string;
}

function parseOAuthCallback(callbackUrl: string): CallbackParams {
  // Should throw descriptive errors for missing/invalid parameters
}
Solution
interface CallbackParams {
  code: string;
  state: string;
}

interface CallbackError {
  error: string;
  errorDescription?: string;
}

function parseOAuthCallback(callbackUrl: string): CallbackParams {
  const url = new URL(callbackUrl);

  // Check for error response
  const error = url.searchParams.get('error');
  if (error) {
    const errorDescription = url.searchParams.get('error_description');
    throw new Error(`OAuth error: ${error}${errorDescription ? ` - ${errorDescription}` : ''}`);
  }

  // Extract code
  const code = url.searchParams.get('code');
  if (!code) {
    throw new Error(
      'Missing authorization code in callback URL. ' + 'The user may have denied the request.'
    );
  }

  // Extract state
  const state = url.searchParams.get('state');
  if (!state) {
    throw new Error(
      'Missing state parameter in callback URL. ' + 'This could indicate a CSRF attack.'
    );
  }

  return { code, state };
}

// Test with valid callback
try {
  const params = parseOAuthCallback('http://localhost:3000/callback?code=abc123&state=xyz789');
  console.log('Code:', params.code);
  console.log('State:', params.state);
} catch (error) {
  console.error(error);
}

// Test with error callback
try {
  parseOAuthCallback(
    'http://localhost:3000/callback?error=access_denied&error_description=The%20user%20denied%20the%20request'
  );
} catch (error) {
  console.error('Expected error:', error);
}

Exercise 3: OAuth Client Class

Create a reusable OAuth client class:

class OAuthClient {
  constructor(config: OAuthConfig) {}

  // Generate authorization URL
  getAuthorizationUrl(): { url: string; state: string } {}

  // Validate callback and exchange code for token
  async handleCallback(callbackUrl: string, expectedState: string): Promise<TokenResponse> {}
}
Solution
interface OAuthClientConfig {
  clientId: string;
  clientSecret: string;
  authEndpoint: string;
  tokenEndpoint: string;
  redirectUri: string;
  scopes: string[];
}

interface TokenResponse {
  accessToken: string;
  refreshToken?: string;
  expiresIn?: number;
  tokenType: string;
  scope: string;
}

class OAuthClient {
  private config: OAuthClientConfig;

  constructor(config: OAuthClientConfig) {
    this.config = config;
  }

  private generateState(): string {
    const array = new Uint8Array(16);
    crypto.getRandomValues(array);
    return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
  }

  getAuthorizationUrl(): { url: string; state: string } {
    const state = this.generateState();

    const url = new URL(this.config.authEndpoint);
    url.searchParams.set('response_type', 'code');
    url.searchParams.set('client_id', this.config.clientId);
    url.searchParams.set('redirect_uri', this.config.redirectUri);
    url.searchParams.set('scope', this.config.scopes.join(' '));
    url.searchParams.set('state', state);

    return { url: url.toString(), state };
  }

  async handleCallback(callbackUrl: string, expectedState: string): Promise<TokenResponse> {
    const url = new URL(callbackUrl);

    // Check for errors
    const error = url.searchParams.get('error');
    if (error) {
      const description = url.searchParams.get('error_description') || 'Unknown error';
      throw new Error(`OAuth error: ${error} - ${description}`);
    }

    // Validate state
    const state = url.searchParams.get('state');
    if (state !== expectedState) {
      throw new Error('State mismatch - possible CSRF attack');
    }

    // Get code
    const code = url.searchParams.get('code');
    if (!code) {
      throw new Error('Missing authorization code');
    }

    // Exchange code for tokens
    const response = await fetch(this.config.tokenEndpoint, {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: code,
        client_id: this.config.clientId,
        client_secret: this.config.clientSecret,
        redirect_uri: this.config.redirectUri,
      }).toString(),
    });

    if (!response.ok) {
      const errorBody = await response.text();
      throw new Error(`Token exchange failed: ${errorBody}`);
    }

    const tokens = await response.json();

    return {
      accessToken: tokens.access_token,
      refreshToken: tokens.refresh_token,
      expiresIn: tokens.expires_in,
      tokenType: tokens.token_type,
      scope: tokens.scope,
    };
  }

  async refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
    const response = await fetch(this.config.tokenEndpoint, {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: this.config.clientId,
        client_secret: this.config.clientSecret,
      }).toString(),
    });

    if (!response.ok) {
      throw new Error('Token refresh failed');
    }

    const tokens = await response.json();

    return {
      accessToken: tokens.access_token,
      refreshToken: tokens.refresh_token || refreshToken,
      expiresIn: tokens.expires_in,
      tokenType: tokens.token_type,
      scope: tokens.scope,
    };
  }
}

// Usage
const github = new OAuthClient({
  clientId: process.env.GITHUB_CLIENT_ID!,
  clientSecret: process.env.GITHUB_CLIENT_SECRET!,
  authEndpoint: 'https://github.com/login/oauth/authorize',
  tokenEndpoint: 'https://github.com/login/oauth/access_token',
  redirectUri: 'http://localhost:3000/auth/callback',
  scopes: ['read:user', 'user:email'],
});

// Step 1: Get auth URL
const { url, state } = github.getAuthorizationUrl();
console.log('Redirect user to:', url);
console.log('Save this state:', state);

// Step 2: After callback (in callback handler)
// const tokens = await github.handleCallback(callbackUrl, savedState);

Key Takeaways

  1. OAuth allows users to grant limited access without sharing passwords
  2. The Authorization Code flow is the most secure for web applications
  3. Scopes define what permissions are being requested
  4. The state parameter prevents CSRF attacks - always validate it
  5. Access tokens are short-lived; refresh tokens maintain long-term access
  6. Client secrets must never be exposed in client-side code
  7. Always use HTTPS for OAuth flows
  8. Authentication (who you are) is different from Authorization (what you can do)

Resources

Resource Type Level
OAuth 2.0 Simplified Tutorial Beginner
RFC 6749: OAuth 2.0 Specification Advanced
GitHub OAuth Docs Documentation Beginner
Google OAuth Docs Documentation Beginner

Next Lesson

Now that you understand authentication concepts, let us put them into practice by building a complete integration with the GitHub API.

Continue to Lesson 6.4: Practice - GitHub API