Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/auth/csrf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { randomBytes } from 'crypto'

const STATE_TTL_MS = 10 * 60 * 1_000 // 10 minutes

// In-memory store: state token → expiry epoch ms.
// Tokens are single-use: consumed on first validation attempt.
// For a multi-instance deployment, replace this with a Redis-backed store.
const pendingStates = new Map<string, number>()

export function generateCsrfState(): string {
const state = randomBytes(32).toString('hex')
pendingStates.set(state, Date.now() + STATE_TTL_MS)
return state
}

/**
* Returns true and removes the state if it exists and has not expired.
* Always returns false (and removes the state if present) on reuse,
* making states single-use regardless of outcome.
*/
export function validateCsrfState(state: string): boolean {
const expiry = pendingStates.get(state)
// Always delete so replayed states are always rejected
pendingStates.delete(state)
if (expiry === undefined) return false
return Date.now() < expiry
}

/** Exposed for test teardown only — do not call in production code. */
export function clearPendingStates(): void {
pendingStates.clear()
}
129 changes: 129 additions & 0 deletions src/auth/github-oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { GitHubTokenResponse, GitHubUser } from './types.js'

export const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'
export const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'
export const GITHUB_API_BASE = 'https://api.github.com'

export function buildAuthorizeUrl(
clientId: string,
redirectUri: string,
state: string,
scopes = 'read:user user:email',
): string {
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: scopes,
state,
})
return `${GITHUB_AUTHORIZE_URL}?${params}`
}

export async function exchangeCodeForToken(
code: string,
clientId: string,
clientSecret: string,
redirectUri: string,
): Promise<GitHubTokenResponse> {
const res = await fetch(GITHUB_TOKEN_URL, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: redirectUri,
}),
})

if (!res.ok) {
throw new Error(`GitHub token exchange failed: HTTP ${res.status}`)
}

const data = await res.json() as GitHubTokenResponse
if (data.error) {
throw new Error(`GitHub OAuth error: ${data.error} — ${data.error_description ?? ''}`)
}

return data
}

export async function refreshGitHubToken(
refreshToken: string,
clientId: string,
clientSecret: string,
): Promise<GitHubTokenResponse> {
const res = await fetch(GITHUB_TOKEN_URL, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
})

if (!res.ok) {
throw new Error(`GitHub token refresh failed: HTTP ${res.status}`)
}

const data = await res.json() as GitHubTokenResponse
if (data.error) {
throw new Error(`GitHub token refresh error: ${data.error} — ${data.error_description ?? ''}`)
}

return data
}

export async function fetchGitHubUser(accessToken: string): Promise<GitHubUser> {
const res = await fetch(`${GITHUB_API_BASE}/user`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
})

if (res.status === 401) {
throw new Error('GitHub token is invalid or has been revoked')
}
if (!res.ok) {
throw new Error(`Failed to fetch GitHub user: HTTP ${res.status}`)
}

return res.json() as Promise<GitHubUser>
}

/**
* Deletes the token via the GitHub Applications API so it can no longer be used.
* Uses HTTP Basic auth with the OAuth app credentials (not a Bearer token).
*/
export async function revokeGitHubToken(
accessToken: string,
clientId: string,
clientSecret: string,
): Promise<void> {
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
const res = await fetch(`${GITHUB_API_BASE}/applications/${clientId}/token`, {
method: 'DELETE',
headers: {
Authorization: `Basic ${credentials}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json',
},
body: JSON.stringify({ access_token: accessToken }),
})

// 404 means the token was already invalid — acceptable outcome
if (!res.ok && res.status !== 404) {
throw new Error(`Failed to revoke GitHub token: HTTP ${res.status}`)
}
}
213 changes: 213 additions & 0 deletions src/auth/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'http'
import type { Pool } from 'pg'
import {
buildAuthorizeUrl,
exchangeCodeForToken,
fetchGitHubUser,
refreshGitHubToken,
} from './github-oauth.js'
import { SessionStore } from './session-store.js'
import { generateCsrfState, validateCsrfState } from './csrf.js'
import type { AuthConfig } from './types.js'

const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1_000 // 24 hours

function sendJson(res: ServerResponse, status: number, data: unknown): void {
const body = JSON.stringify(data)
res.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
})
res.end(body)
}

function extractBearerToken(req: IncomingMessage): string | null {
const auth = req.headers['authorization']
if (!auth?.startsWith('Bearer ')) return null
const token = auth.slice(7).trim()
return token.length > 0 ? token : null
}

export function createAuthServer(db: Pool, config: AuthConfig) {
const store = new SessionStore(db)
const ttl = config.session_ttl_ms ?? DEFAULT_SESSION_TTL_MS

const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const url = new URL(
req.url ?? '/',
`http://${req.headers.host ?? 'localhost'}`,
)

try {
// ── GET /auth/github — kick off OAuth flow ──────────────────────────────
if (req.method === 'GET' && url.pathname === '/auth/github') {
const state = generateCsrfState()
const redirectUrl = buildAuthorizeUrl(
config.github_client_id,
config.github_redirect_uri,
state,
)
res.writeHead(302, { Location: redirectUrl })
res.end()
return
}

// ── GET /auth/github/callback — exchange code, create session ───────────
if (req.method === 'GET' && url.pathname === '/auth/github/callback') {
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')

if (!state || !validateCsrfState(state)) {
sendJson(res, 403, {
error: 'invalid_csrf_state',
message: 'CSRF state is missing, invalid, or has already been used',
})
return
}

if (!code) {
sendJson(res, 400, { error: 'missing_code', message: 'OAuth code is required' })
return
}

const tokenData = await exchangeCodeForToken(
code,
config.github_client_id,
config.github_client_secret,
config.github_redirect_uri,
)

const ghUser = await fetchGitHubUser(tokenData.access_token)
const user = await store.upsertUser(
ghUser.id,
ghUser.login,
ghUser.email,
ghUser.avatar_url,
)

const session = await store.createSession(
user.id,
tokenData.access_token,
tokenData.refresh_token ?? null,
ttl,
)

sendJson(res, 200, {
session_id: session.id,
csrf_token: session.csrf_token,
expires_at: session.expires_at,
user: {
id: user.id,
login: user.login,
email: user.email,
avatar_url: user.avatar_url,
},
})
return
}

// ── POST /auth/refresh — extend session with a fresh GitHub token ───────
if (req.method === 'POST' && url.pathname === '/auth/refresh') {
const sessionId = extractBearerToken(req)
if (!sessionId) {
sendJson(res, 401, { error: 'unauthenticated', message: 'Bearer token required' })
return
}

const session = await store.getSession(sessionId)
if (!session) {
sendJson(res, 401, { error: 'session_not_found', message: 'Session does not exist' })
return
}
if (session.revoked_at) {
sendJson(res, 401, { error: 'session_revoked', message: 'Session has been revoked' })
return
}
if (new Date(session.expires_at) <= new Date()) {
sendJson(res, 401, { error: 'session_expired', message: 'Session has expired' })
return
}
if (!session.refresh_token) {
sendJson(res, 400, { error: 'no_refresh_token', message: 'No refresh token available for this session' })
return
}

const tokenData = await refreshGitHubToken(
session.refresh_token,
config.github_client_id,
config.github_client_secret,
)

const updated = await store.updateSessionToken(
sessionId,
tokenData.access_token,
tokenData.refresh_token ?? null,
ttl,
)

sendJson(res, 200, {
session_id: sessionId,
expires_at: updated?.expires_at,
})
return
}

// ── POST /auth/logout — revoke session ──────────────────────────────────
if (req.method === 'POST' && url.pathname === '/auth/logout') {
const sessionId = extractBearerToken(req)
if (!sessionId) {
sendJson(res, 401, { error: 'unauthenticated', message: 'Bearer token required' })
return
}

await store.revokeSession(sessionId)
sendJson(res, 200, { ok: true })
return
}

// ── GET /auth/me — return authenticated user ─────────────────────────────
if (req.method === 'GET' && url.pathname === '/auth/me') {
const sessionId = extractBearerToken(req)
if (!sessionId) {
sendJson(res, 401, { error: 'unauthenticated', message: 'Bearer token required' })
return
}

const session = await store.getSession(sessionId)
if (!session) {
sendJson(res, 401, { error: 'session_not_found', message: 'Session does not exist' })
return
}
if (session.revoked_at) {
sendJson(res, 401, { error: 'session_revoked', message: 'Session has been revoked' })
return
}
if (new Date(session.expires_at) <= new Date()) {
sendJson(res, 401, { error: 'session_expired', message: 'Session has expired' })
return
}

const user = await store.getUser(session.user_id)
if (!user) {
sendJson(res, 404, { error: 'user_not_found', message: 'User record not found' })
return
}

sendJson(res, 200, {
id: user.id,
login: user.login,
email: user.email,
avatar_url: user.avatar_url,
})
return
}

sendJson(res, 404, { error: 'not_found' })
} catch (err) {
console.error('[auth-server] Unhandled error:', err)
sendJson(res, 500, { error: 'internal_error', message: String(err) })
}
})

return { server, store }
}
Loading
Loading