CtrlK
BlogDocsLog inGet started
Tessl Logo

oauth2-skill

Implement OAuth2 authentication flows (authorization code, PKCE for SPAs), provider integration (Google, GitHub), callback handling, token exchange, session management, and CSRF protection.

72

Quality

66%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Advisory

Suggest reviewing before use

Optimize this skill with Tessl

npx tessl skill review --optimize ./auth/oauth2-skill/SKILL.md
SKILL.md
Quality
Evals
Security

OAuth2 Skill

Implement OAuth2 authentication flows (authorization code, PKCE for SPAs), provider integration (Google, GitHub), callback handling, token exchange, session management, and CSRF protection.

Prerequisites

  • Node.js >= 20.x or Python >= 3.11
  • OAuth2 app credentials from providers (Client ID + Client Secret)
  • A callback URL registered with each provider
  • Session store (Redis, database, or cookie-based)

Scaffold Command

# Node.js (Express)
npm install openid-client cookie-session
npm install -D @types/cookie-session

# Node.js (Passport-based alternative)
npm install passport passport-google-oauth20 passport-github2 express-session
npm install -D @types/passport @types/passport-google-oauth20 @types/express-session

# Python (FastAPI)
pip install httpx authlib itsdangerous

Project Structure

src/
  auth/
    oauth.controller.ts           # /auth/google, /auth/google/callback, etc.
    oauth.service.ts              # Token exchange, user lookup/creation
    providers/
      google.ts                   # Google OAuth2 config
      github.ts                   # GitHub OAuth2 config
    types.ts                      # OAuth profile types
  lib/
    session.ts                    # Session middleware config

Key Conventions

  • Use Authorization Code flow with PKCE for all clients (SPAs and server-rendered apps). PKCE replaces the implicit flow.
  • Store state and code_verifier in the server session (or encrypted cookie) before redirecting to the provider. Validate state on callback to prevent CSRF.
  • After token exchange, look up or create the user in your database. Link accounts by email or provider-specific ID.
  • Issue your own session or JWT after successful OAuth2 flow. Do not pass provider tokens to the frontend.
  • Separate provider configs into individual files. Add new providers by creating a new config file.

Essential Patterns

Provider Config — Google (auth/providers/google.ts)

export const googleConfig = {
  clientId: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
  tokenUrl: "https://oauth2.googleapis.com/token",
  userInfoUrl: "https://www.googleapis.com/oauth2/v3/userinfo",
  callbackUrl: process.env.GOOGLE_CALLBACK_URL ?? "http://localhost:3000/api/auth/google/callback",
  scopes: ["openid", "email", "profile"],
};

Provider Config — GitHub (auth/providers/github.ts)

export const githubConfig = {
  clientId: process.env.GITHUB_CLIENT_ID!,
  clientSecret: process.env.GITHUB_CLIENT_SECRET!,
  authorizationUrl: "https://github.com/login/oauth/authorize",
  tokenUrl: "https://github.com/login/oauth/access_token",
  userInfoUrl: "https://api.github.com/user",
  userEmailsUrl: "https://api.github.com/user/emails",
  callbackUrl: process.env.GITHUB_CALLBACK_URL ?? "http://localhost:3000/api/auth/github/callback",
  scopes: ["read:user", "user:email"],
};

OAuth2 Service with PKCE (auth/oauth.service.ts)

import crypto from "node:crypto";

interface OAuthProviderConfig {
  clientId: string;
  clientSecret: string;
  authorizationUrl: string;
  tokenUrl: string;
  userInfoUrl: string;
  callbackUrl: string;
  scopes: string[];
}

interface OAuthTokenResponse {
  access_token: string;
  token_type: string;
  scope?: string;
  id_token?: string;
}

// Generate PKCE code verifier and challenge
function generatePKCE() {
  const codeVerifier = crypto.randomBytes(32).toString("base64url");
  const codeChallenge = crypto
    .createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");
  return { codeVerifier, codeChallenge };
}

export function buildAuthorizationUrl(
  config: OAuthProviderConfig,
  state: string,
  codeChallenge: string
): string {
  const params = new URLSearchParams({
    client_id: config.clientId,
    redirect_uri: config.callbackUrl,
    response_type: "code",
    scope: config.scopes.join(" "),
    state,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  });
  return `${config.authorizationUrl}?${params.toString()}`;
}

export async function exchangeCodeForTokens(
  config: OAuthProviderConfig,
  code: string,
  codeVerifier: string
): Promise<OAuthTokenResponse> {
  const response = await fetch(config.tokenUrl, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Accept: "application/json",
    },
    body: new URLSearchParams({
      client_id: config.clientId,
      client_secret: config.clientSecret,
      code,
      redirect_uri: config.callbackUrl,
      grant_type: "authorization_code",
      code_verifier: codeVerifier,
    }),
  });

  if (!response.ok) {
    throw new Error(`Token exchange failed: ${response.status}`);
  }

  return response.json() as Promise<OAuthTokenResponse>;
}

export async function fetchUserInfo(
  userInfoUrl: string,
  accessToken: string
): Promise<Record<string, unknown>> {
  const response = await fetch(userInfoUrl, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      Accept: "application/json",
    },
  });

  if (!response.ok) {
    throw new Error(`User info fetch failed: ${response.status}`);
  }

  return response.json() as Promise<Record<string, unknown>>;
}

export function generateState(): string {
  return crypto.randomBytes(16).toString("hex");
}

export { generatePKCE };

OAuth2 Controller (auth/oauth.controller.ts)

import { Router, type Request, type Response } from "express";
import { googleConfig } from "./providers/google";
import { githubConfig } from "./providers/github";
import {
  buildAuthorizationUrl,
  exchangeCodeForTokens,
  fetchUserInfo,
  generateState,
  generatePKCE,
} from "./oauth.service";

// Replace with your user creation/lookup and JWT issuance
import { findOrCreateUserByOAuth } from "../modules/users/users.service";
import { generateTokenPair } from "./auth.service";

export const oauthRouter = Router();

// --- Google ---
oauthRouter.get("/google", (req: Request, res: Response) => {
  const state = generateState();
  const { codeVerifier, codeChallenge } = generatePKCE();

  // Store in session for validation on callback
  req.session!.oauthState = state;
  req.session!.codeVerifier = codeVerifier;

  const url = buildAuthorizationUrl(googleConfig, state, codeChallenge);
  return res.redirect(url);
});

oauthRouter.get("/google/callback", async (req: Request, res: Response) => {
  const { code, state } = req.query;

  // Validate state (CSRF protection)
  if (state !== req.session!.oauthState) {
    return res.status(403).json({ message: "Invalid state parameter" });
  }

  try {
    const tokens = await exchangeCodeForTokens(
      googleConfig,
      code as string,
      req.session!.codeVerifier
    );

    const userInfo = await fetchUserInfo(googleConfig.userInfoUrl, tokens.access_token);

    const user = await findOrCreateUserByOAuth({
      provider: "google",
      providerId: userInfo.sub as string,
      email: userInfo.email as string,
      name: userInfo.name as string,
      avatar: userInfo.picture as string,
    });

    const { accessToken, refreshToken } = await generateTokenPair(user.id, user.role);

    // Clean up session
    delete req.session!.oauthState;
    delete req.session!.codeVerifier;

    // Set refresh token cookie and redirect to frontend with access token
    res.cookie("refresh_token", refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      path: "/api/auth",
    });

    // Redirect to frontend with access token as URL fragment (not query param)
    return res.redirect(`${process.env.FRONTEND_URL}/auth/callback#token=${accessToken}`);
  } catch (error) {
    console.error("OAuth callback error:", error);
    return res.redirect(`${process.env.FRONTEND_URL}/auth/error`);
  }
});

// --- GitHub ---
oauthRouter.get("/github", (req: Request, res: Response) => {
  const state = generateState();
  const { codeVerifier, codeChallenge } = generatePKCE();

  req.session!.oauthState = state;
  req.session!.codeVerifier = codeVerifier;

  const url = buildAuthorizationUrl(githubConfig, state, codeChallenge);
  return res.redirect(url);
});

oauthRouter.get("/github/callback", async (req: Request, res: Response) => {
  const { code, state } = req.query;

  if (state !== req.session!.oauthState) {
    return res.status(403).json({ message: "Invalid state parameter" });
  }

  try {
    const tokens = await exchangeCodeForTokens(
      githubConfig,
      code as string,
      req.session!.codeVerifier
    );

    const userInfo = await fetchUserInfo(githubConfig.userInfoUrl, tokens.access_token);

    // GitHub may not return email in profile — fetch from emails endpoint
    let email = userInfo.email as string | null;
    if (!email) {
      const emails = await fetchUserInfo(githubConfig.userEmailsUrl!, tokens.access_token) as any[];
      const primary = emails.find((e: any) => e.primary && e.verified);
      email = primary?.email ?? null;
    }

    if (!email) {
      return res.redirect(`${process.env.FRONTEND_URL}/auth/error?reason=no-email`);
    }

    const user = await findOrCreateUserByOAuth({
      provider: "github",
      providerId: String(userInfo.id),
      email,
      name: (userInfo.name as string) ?? (userInfo.login as string),
      avatar: userInfo.avatar_url as string,
    });

    const jwtTokens = await generateTokenPair(user.id, user.role);

    delete req.session!.oauthState;
    delete req.session!.codeVerifier;

    res.cookie("refresh_token", jwtTokens.refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      path: "/api/auth",
    });

    return res.redirect(`${process.env.FRONTEND_URL}/auth/callback#token=${jwtTokens.accessToken}`);
  } catch (error) {
    console.error("OAuth callback error:", error);
    return res.redirect(`${process.env.FRONTEND_URL}/auth/error`);
  }
});

User Linking Service

// modules/users/users.service.ts
interface OAuthProfile {
  provider: string;
  providerId: string;
  email: string;
  name: string;
  avatar?: string;
}

export async function findOrCreateUserByOAuth(profile: OAuthProfile) {
  // Check if OAuth account already linked
  let account = await findOAuthAccount(profile.provider, profile.providerId);
  if (account) {
    return findUserById(account.userId);
  }

  // Check if user exists with same email
  let user = await findUserByEmail(profile.email);
  if (!user) {
    user = await createUser({
      email: profile.email,
      name: profile.name,
      avatar: profile.avatar,
      // No password — OAuth-only account
    });
  }

  // Link OAuth account to user
  // NOTE: Add a unique constraint on (provider, provider_account_id) in your database schema
  // to prevent duplicate account links and enforce data integrity.
  await createOAuthAccount({
    provider: profile.provider,
    providerId: profile.providerId,
    userId: user.id,
  });

  return user;
}

Python / FastAPI Variant

from authlib.integrations.starlette_client import OAuth
from starlette.config import Config
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse

config = Config(".env")
oauth = OAuth(config)

oauth.register(
    name="google",
    client_id=config("GOOGLE_CLIENT_ID"),
    client_secret=config("GOOGLE_CLIENT_SECRET"),
    server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
    client_kwargs={"scope": "openid email profile"},
)

router = APIRouter(prefix="/auth")


@router.get("/google")
async def google_login(request: Request):
    redirect_uri = request.url_for("google_callback")
    return await oauth.google.authorize_redirect(request, redirect_uri)


@router.get("/google/callback")
async def google_callback(request: Request):
    token = await oauth.google.authorize_access_token(request)
    user_info = token.get("userinfo")
    # Find or create user, issue JWT, redirect...
    return RedirectResponse(url="/")

Common Commands

# Set up environment variables
export GOOGLE_CLIENT_ID=your-google-client-id
export GOOGLE_CLIENT_SECRET=your-google-client-secret
export GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
export GITHUB_CLIENT_ID=your-github-client-id
export GITHUB_CLIENT_SECRET=your-github-client-secret
export GITHUB_CALLBACK_URL=http://localhost:3000/api/auth/github/callback

# Test OAuth flow (open in browser)
open http://localhost:3000/api/auth/google
open http://localhost:3000/api/auth/github

Integration Notes

  • JWT: Pair with jwt-auth-skill. After OAuth2 callback, issue JWT access+refresh tokens using the JWT skill's generateTokenPair.
  • NextAuth: If using Next.js, consider nextauth-skill instead. NextAuth handles OAuth2 flows out of the box.
  • Database: Store OAuth account links in an oauth_accounts table (provider, providerId, userId). Pair with any database skill for the schema.
  • Frontend: The frontend receives the access token via URL fragment on redirect. Extract it in the callback page and store in memory (not localStorage).
  • Testing: Mock the token exchange and user info endpoints. Use nock (Node.js) or responses (Python) for HTTP mocking.
Repository
achreftlili/deep-dev-skills
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.