OAuth Social Login#
Buntok provides built-in OAuth 2.0 support for social login with Google, GitHub, and Apple. It handles PKCE, state management, and cookie cleanup automatically.
Info
All providers use the Authorization Code Flow with PKCE (RFC 7636) - the recommended flow for 2026+.
Quick Start#
typescript
import { createOAuth } from "@buntok/core/oauth";
const google = createOAuth.google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectURI: "http://localhost:1212/auth/google/callback",
});Start OAuth Flow#
Generate a state + PKCE code verifier, store them in cookies, and redirect the user to the provider.
typescript
import { createOAuth, generateCodeVerifier, generateCodeChallenge,
storeOAuthState } from "@buntok/core/oauth";
const google = createOAuth.google({ ... });
app.get("/auth/google", async (ctx) => {
const state = crypto.randomUUID();
const codeVerifier = generateCodeVerifier();
const url = await google.createAuthorizationURL(state, codeVerifier);
// Framework handles state + codeVerifier storage in HttpOnly cookies
let response = ctx.redirect(url);
response = storeOAuthState(response, state, codeVerifier);
return response;
});Handle Callback#
Verify the state, exchange the code for tokens, and get user info. Cookies are cleaned up automatically.
typescript
import { createOAuth, verifyOAuthState, getCodeVerifier,
clearOAuthCookies } from "@buntok/core/oauth";
app.get("/auth/google/callback", async (ctx) => {
const code = ctx.query.code;
const state = ctx.query.state;
// Verify state (CSRF protection)
if (!verifyOAuthState(ctx.request, state)) {
return ctx.json({ error: "Invalid state" }, 400);
}
// Get code verifier from cookie
const codeVerifier = getCodeVerifier(ctx.request)!;
// Exchange code for tokens
const tokens = await google.validateAuthorizationCode(
code, "http://localhost:1212/auth/google/callback", codeVerifier
);
// Get user info
const user = await google.getUserInfo(tokens);
// Create/find user in your database, issue JWT, etc.
// Clean up OAuth cookies (automatic)
let response = ctx.json({ user });
response = clearOAuthCookies(response);
return response;
});Built-in Providers#
| Provider | Type | PKCE | User ID |
|---|---|---|---|
| OIDC | ✅ | sub | |
| GitHub | OAuth2 | ❌ | id |
| Apple | OIDC | ✅ | sub |
Google Setup#
- Go to Google Cloud Console
- Create OAuth 2.0 Client ID (Web application type)
- Add authorized redirect URI:
http://localhost:1212/auth/google/callback - Copy Client ID and Client Secret
typescript
# .env
GOOGLE_CLIENT_ID=your_client_id
GOOGLE_CLIENT_SECRET=your_client_secretGitHub Setup#
- Go to GitHub Developer Settings
- Create new OAuth App
- Set Authorization callback URL:
http://localhost:1212/auth/github/callback - Copy Client ID and Client Secret
typescript
# .env
GITHUB_CLIENT_ID=your_client_id
GITHUB_CLIENT_SECRET=your_client_secretWarning
GitHub does not guarantee email in the basic profile scope. Use the
user:email scope and the provider will automatically fetch emails from the /user/emails endpoint.Apple Setup#
- Go to Apple Developer Console
- Create an App ID with Sign in with Apple enabled
- Create a Services ID and configure Sign in with Apple
- Create a key with Sign in with Apple enabled
- Download the private key (.p8 file)
- Set authorized redirect URI:
http://localhost:1212/auth/apple/callback
typescript
# .env
APPLE_CLIENT_ID=your_services_id
APPLE_TEAM_ID=your_team_id
APPLE_KEY_ID=your_key_id
APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----Warning
Apple only sends the user's name on the firstauthentication. Store it if you receive it - you won't get it again unless the user revokes and re-authorizes.
Error Handling#
Buntok provides specific error classes for OAuth failures:
| Error | Cause |
|---|---|
OAuthStateError | State mismatch - possible CSRF attack |
OAuthTokenError | Token exchange failed (code expired, invalid, etc.) |
OAuthProviderError | Provider returned an error (invalid_client, access_denied, etc.) |
typescript
import { OAuthStateError, OAuthTokenError, OAuthProviderError } from "@buntok/core/oauth";
try {
const tokens = await google.validateAuthorizationCode(code, redirectURI, codeVerifier);
const user = await google.getUserInfo(tokens);
} catch (error) {
if (error instanceof OAuthStateError) {
return ctx.json({ error: "Invalid state" }, 400);
}
if (error instanceof OAuthTokenError) {
return ctx.json({ error: "Token exchange failed" }, 400);
}
if (error instanceof OAuthProviderError) {
return ctx.json({ error: error.providerErrorDescription }, 400);
}
throw error;
}Custom Providers#
For providers not built-in, use the generic OAuth helpers:
typescript
import { createOAuth2AuthorizationURL, validateOAuth2AuthorizationCode,
generateCodeVerifier, generateCodeChallenge, storeOAuthState,
verifyOAuthState, getCodeVerifier, clearOAuthCookies } from "@buntok/core/oauth";
// Discord (not OIDC - uses OAuth2)
const DISCORD_AUTH = "https://discord.com/api/oauth2/authorize";
const DISCORD_TOKEN = "https://discord.com/api/oauth2/token";
const DISCORD_USER = "https://discord.com/api/users/@me";
app.get("/auth/discord", async (ctx) => {
const state = crypto.randomUUID();
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const url = await createOAuth2AuthorizationURL(DISCORD_AUTH, {
clientId: process.env.DISCORD_CLIENT_ID!,
redirectURI: "http://localhost:1212/auth/discord/callback",
scopes: ["identify", "email"],
state,
codeChallenge,
});
let response = ctx.redirect(url);
response = storeOAuthState(response, state, codeVerifier);
return response;
});
app.get("/auth/discord/callback", async (ctx) => {
const code = ctx.query.code;
const state = ctx.query.state;
if (!verifyOAuthState(ctx.request, state)) {
return ctx.json({ error: "Invalid state" }, 400);
}
const codeVerifier = getCodeVerifier(ctx.request)!;
const tokens = await validateOAuth2AuthorizationCode({
code,
redirectURI: "http://localhost:1212/auth/discord/callback",
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
codeVerifier,
tokenEndpoint: DISCORD_TOKEN,
});
// Fetch user from provider API
const userRes = await fetch(DISCORD_USER, {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
const discordUser = await userRes.json();
let response = ctx.json({ user: discordUser });
response = clearOAuthCookies(response);
return response;
});User Info Interface#
All providers return a flexible OAuthUser object:
typescript
interface OAuthUser {
id: string; // Unique, stable identifier (sub claim)
name?: string;
email?: string;
emailVerified?: boolean;
image?: string;
[key: string]: unknown; // Provider-specific fields
}Always use id (the sub claim) as the primary user identifier - not email, which can change.
Extend a Provider#
Extend BaseOAuthProvider to add custom providers:
typescript
import { BaseOAuthProvider, createOAuth2AuthorizationURL,
generateCodeChallenge } from "@buntok/core/oauth";
import type { OAuth2Tokens, OAuthUser } from "@buntok/core/oauth";
class SpotifyProvider extends BaseOAuthProvider {
override readonly id = "spotify";
override async createAuthorizationURL(state: string, codeVerifier: string): Promise<string> {
const codeChallenge = await generateCodeChallenge(codeVerifier);
return createOAuth2AuthorizationURL(
"https://accounts.spotify.com/authorize",
{
clientId: this.config.clientId,
redirectURI: this.config.redirectURI,
scopes: this.config.scopes,
state,
codeChallenge,
}
);
}
override async validateAuthorizationCode(
code: string, redirectURI: string, codeVerifier?: string
): Promise<OAuth2Tokens> {
// ... implement token exchange
}
override async getUserInfo(tokens: OAuth2Tokens): Promise<OAuthUser> {
// ... implement user info fetch
}
}Security Best Practices#
- Always use PKCE (S256 method)
- Validate state parameter to prevent CSRF
- Use
subclaim as user ID (not email) - Store tokens encrypted if needed for API access
- Use exact redirect URI matching
- Never expose tokens in URLs
- Verify ID tokens server-side