Support Codex OAuth login and add CLI setup docs

This commit is contained in:
Garfield
2026-05-11 10:39:24 -04:00
parent ffb67560b9
commit 6bf4cfd069
6 changed files with 458 additions and 113 deletions

View File

@@ -7,6 +7,30 @@ const password = process.env.MYSQL_PASSWORD || '';
let pool: mysql.Pool | null = null;
async function ensureColumn(
db: mysql.PoolConnection,
tableName: string,
columnName: string,
definition: string
): Promise<void> {
const [rows] = await db.execute<mysql.RowDataPacket[]>(
`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
`,
[tableName, columnName]
);
if (Array.isArray(rows) && rows.length > 0) {
return;
}
await db.execute(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${definition}`);
}
export function getPool(): mysql.Pool {
if (!pool) {
throw new Error('Database pool not initialized. Call initDatabase() first.');
@@ -57,12 +81,19 @@ export async function initDatabase(): Promise<void> {
code VARCHAR(255) PRIMARY KEY,
client_id VARCHAR(255),
redirect_uri TEXT,
scope TEXT NULL,
code_challenge TEXT NULL,
code_challenge_method VARCHAR(20) NULL,
expires_at TIMESTAMP,
used BOOLEAN DEFAULT FALSE,
INDEX idx_expires (expires_at)
)
`);
await ensureColumn(db, 'oauth_auth_codes', 'scope', 'TEXT NULL');
await ensureColumn(db, 'oauth_auth_codes', 'code_challenge', 'TEXT NULL');
await ensureColumn(db, 'oauth_auth_codes', 'code_challenge_method', 'VARCHAR(20) NULL');
await db.execute(`
CREATE TABLE IF NOT EXISTS oauth_tokens (
token VARCHAR(255) PRIMARY KEY,

View File

@@ -165,6 +165,28 @@ function extractBearerToken(req: express.Request): string | undefined {
return undefined;
}
function extractBasicClientCredentials(req: express.Request): { clientId?: string; clientSecret?: string } {
const authHeader = req.headers.authorization as string | undefined;
if (!authHeader || !authHeader.startsWith('Basic ')) {
return {};
}
try {
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
const separatorIndex = decoded.indexOf(':');
if (separatorIndex === -1) {
return {};
}
return {
clientId: decoded.slice(0, separatorIndex),
clientSecret: decoded.slice(separatorIndex + 1),
};
} catch {
return {};
}
}
async function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction) {
try {
// No API key configured = open access
@@ -236,6 +258,8 @@ app.get('/oauth/authorize', async (req, res) => {
const redirectUri = req.query.redirect_uri as string | undefined;
const state = req.query.state as string | undefined;
const scope = req.query.scope as string | undefined;
const codeChallenge = req.query.code_challenge as string | undefined;
const codeChallengeMethod = req.query.code_challenge_method as string | undefined;
const responseType = req.query.response_type as string | undefined;
if (!clientId || !redirectUri) {
@@ -254,7 +278,14 @@ app.get('/oauth/authorize', async (req, res) => {
}
res.setHeader('Content-Type', 'text/html');
res.send(getAuthorizeHtml({ client_id: clientId, redirect_uri: redirectUri, state, scope }));
res.send(getAuthorizeHtml({
client_id: clientId,
redirect_uri: redirectUri,
state,
scope,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
}));
});
app.post('/oauth/authorize', async (req, res) => {
@@ -262,6 +293,8 @@ app.post('/oauth/authorize', async (req, res) => {
const redirectUri = req.body.redirect_uri as string | undefined;
const state = req.body.state as string | undefined;
const scope = req.body.scope as string | undefined;
const codeChallenge = req.body.code_challenge as string | undefined;
const codeChallengeMethod = req.body.code_challenge_method as string | undefined;
const action = req.body.action as string | undefined;
if (!clientId || !redirectUri) {
@@ -284,7 +317,7 @@ app.post('/oauth/authorize', async (req, res) => {
return;
}
const code = await createAuthCode(clientId, redirectUri, scope);
const code = await createAuthCode(clientId, redirectUri, scope, codeChallenge, codeChallengeMethod);
const url = new URL(redirectUri);
url.searchParams.set('code', code.code);
if (state) url.searchParams.set('state', state);
@@ -299,17 +332,19 @@ app.post('/oauth/token', async (req, res) => {
return;
}
const clientId = req.body.client_id as string | undefined;
const clientSecret = req.body.client_secret as string | undefined;
const basicAuth = extractBasicClientCredentials(req);
const clientId = (req.body.client_id as string | undefined) || basicAuth.clientId;
const clientSecret = (req.body.client_secret as string | undefined) || basicAuth.clientSecret;
const code = req.body.code as string | undefined;
const redirectUri = req.body.redirect_uri as string | undefined;
const codeVerifier = req.body.code_verifier as string | undefined;
if (!clientId || !clientSecret || !code || !redirectUri) {
if (!clientId || !code || !redirectUri || (!clientSecret && !codeVerifier)) {
res.status(400).json({ error: 'invalid_request' });
return;
}
const token = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri);
const token = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri, codeVerifier);
if (!token) {
res.status(400).json({ error: 'invalid_grant' });
return;
@@ -973,7 +1008,8 @@ const oauthDiscovery = {
registration_endpoint: `${SERVER_URL}/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
token_endpoint_auth_methods_supported: ['client_secret_post'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic', 'none'],
code_challenge_methods_supported: ['S256', 'plain'],
};
const protectedResourceMetadata = {

View File

@@ -18,6 +18,8 @@ interface AuthCode {
client_id: string;
redirect_uri: string;
scope?: string;
code_challenge?: string;
code_challenge_method?: string;
expires_at: number;
}
@@ -57,6 +59,19 @@ export function generateAccessToken(): string {
return crypto.randomBytes(32).toString('hex');
}
function verifyPkce(codeVerifier: string, storedChallenge: string, method?: string): boolean {
if (!method || method === 'plain') {
return codeVerifier === storedChallenge;
}
if (method === 'S256') {
const hashed = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
return hashed === storedChallenge;
}
return false;
}
export async function registerClient(body: {
client_name?: string;
redirect_uris?: string[];
@@ -122,20 +137,24 @@ export async function getClient(clientId: string): Promise<Client | undefined> {
export async function createAuthCode(
clientId: string,
redirectUri: string,
scope?: string
scope?: string,
codeChallenge?: string,
codeChallengeMethod?: string
): Promise<AuthCode> {
const code: AuthCode = {
code: generateAuthCode(),
client_id: clientId,
redirect_uri: redirectUri,
scope,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
expires_at: Date.now() + AUTH_CODE_EXPIRY_MS,
};
const pool = getPool();
await pool.execute(
'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, expires_at) VALUES (?, ?, ?, ?)',
[code.code, clientId, redirectUri, new Date(code.expires_at)]
'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, scope, code_challenge, code_challenge_method, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[code.code, clientId, redirectUri, scope || null, codeChallenge || null, codeChallengeMethod || null, new Date(code.expires_at)]
);
console.log(`[oauth] Created auth code ${code.code.slice(0, 8)}... for client ${clientId}`);
@@ -144,9 +163,10 @@ export async function createAuthCode(
export async function exchangeCodeForToken(
clientId: string,
clientSecret: string,
clientSecret: string | undefined,
code: string,
redirectUri: string
redirectUri: string,
codeVerifier?: string
): Promise<Token | null> {
let client: Client | undefined;
try {
@@ -156,7 +176,17 @@ export async function exchangeCodeForToken(
return null;
}
if (!client || client.client_secret !== clientSecret) {
if (!client) {
console.log('[oauth] Invalid client');
return null;
}
if (clientSecret) {
if (client.client_secret !== clientSecret) {
console.log('[oauth] Invalid client credentials');
return null;
}
} else if (!codeVerifier) {
console.log('[oauth] Invalid client credentials');
return null;
}
@@ -180,6 +210,18 @@ export async function exchangeCodeForToken(
return null;
}
if (authCode.code_challenge) {
if (!codeVerifier) {
console.log('[oauth] Missing code_verifier for PKCE exchange');
return null;
}
if (!verifyPkce(codeVerifier, authCode.code_challenge, authCode.code_challenge_method || undefined)) {
console.log('[oauth] PKCE verification failed');
return null;
}
}
// Mark auth code as used
await db.execute('UPDATE oauth_auth_codes SET used = TRUE WHERE code = ?', [code]);
@@ -230,8 +272,10 @@ export function getAuthorizeHtml(params: {
redirect_uri: string;
state?: string;
scope?: string;
code_challenge?: string;
code_challenge_method?: string;
}): string {
const { client_id, redirect_uri, state, scope } = params;
const { client_id, redirect_uri, state, scope, code_challenge, code_challenge_method } = params;
return `<!DOCTYPE html>
<html lang="en">
<head>
@@ -261,6 +305,8 @@ export function getAuthorizeHtml(params: {
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}">
${state ? `<input type="hidden" name="state" value="${escapeHtml(state)}">` : ''}
${scope ? `<input type="hidden" name="scope" value="${escapeHtml(scope)}">` : ''}
${code_challenge ? `<input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge)}">` : ''}
${code_challenge_method ? `<input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method)}">` : ''}
<div class="buttons">
<button type="submit" name="action" value="deny" class="deny">Deny</button>
<button type="submit" name="action" value="allow" class="allow">Allow</button>