import { base64url, jwtVerify, SignJWT, type JWTPayload } from 'jose'; import type { Env } from '../types.ts'; import { APIError, apiFailure } from './http.ts'; import { secureResponse } from '../http-security.ts'; import { workspaceState } from './workspace.ts'; const sessionCookie = '__Host-edgessh-session'; const flowCookie = '__Host-edgessh-oauth'; const sessionSeconds = 8 * 60 * 60; const flowSeconds = 10 * 60; const encoder = new TextEncoder(); type GitHubConfig = Env & Required>; function config(env: Env): GitHubConfig { if (!env.APP_ORIGIN || !env.GH_CLIENT_ID || !env.GH_CLIENT_SECRET || !env.GH_ADMIN_ID || !env.ENCRYPTION_KEY) { throw new APIError('管理员尚未配置 GitHub 登录。', 503); } return env as GitHubConfig; } function cookie(request: Request, name: string): string | undefined { return request.headers.get('Cookie')?.split(';').map((part) => part.trim()) .find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1); } function setCookie(name: string, value: string, maxAge: number): string { return `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`; } // 使用 HKDF 做用途隔离,不直接把资料加密密钥用于签名,也不新增用户需要维护的密钥。 async function signingKey(secret: string): Promise { const material = await crypto.subtle.importKey('raw', new Uint8Array(base64url.decode(secret)), 'HKDF', false, ['deriveBits']); return new Uint8Array(await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: encoder.encode('edgessh:v1'), info: encoder.encode('github-oauth-cookie'), }, material, 256)); } function audience(env: GitHubConfig, purpose: string): string { return `edgessh:${purpose}:${env.GH_CLIENT_ID}:${env.GH_ADMIN_ID}`; } async function sign(env: GitHubConfig, purpose: string, payload: JWTPayload, seconds: number): Promise { return new SignJWT(payload).setProtectedHeader({ alg: 'HS256' }).setIssuer(env.APP_ORIGIN) .setAudience(audience(env, purpose)).setIssuedAt().setExpirationTime(`${seconds}s`) .sign(await signingKey(env.ENCRYPTION_KEY)); } async function verify(env: GitHubConfig, purpose: string, token: string): Promise { const { payload } = await jwtVerify(token, await signingKey(env.ENCRYPTION_KEY), { issuer: env.APP_ORIGIN, audience: audience(env, purpose), algorithms: ['HS256'], requiredClaims: ['exp', 'iat'], }); return payload; } function redirect(location: string, cookies: string[] = []): Response { const headers = new Headers({ Location: location, 'Cache-Control': 'no-store' }); for (const value of cookies) headers.append('Set-Cookie', value); return secureResponse(new Response(null, { status: 302, headers })); } export async function githubLogin(request: Request, env: Env): Promise { const settings = config(env); if (new URL(request.url).origin !== settings.APP_ORIGIN) throw new APIError('请从配置的正式入口登录。', 400); const state = base64url.encode(crypto.getRandomValues(new Uint8Array(32))); const verifier = base64url.encode(crypto.getRandomValues(new Uint8Array(32))); const challenge = base64url.encode(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(verifier)))); const url = new URL('https://github.com/login/oauth/authorize'); // 只验证公开身份,不申请仓库、邮箱或组织权限。 url.search = new URLSearchParams({ client_id: settings.GH_CLIENT_ID, redirect_uri: `${settings.APP_ORIGIN}/auth/callback`, state, code_challenge: challenge, code_challenge_method: 'S256', scope: '', }).toString(); const flow = await sign(settings, 'flow', { state, verifier }, flowSeconds); return redirect(url.toString(), [setCookie(flowCookie, flow, flowSeconds)]); } export async function githubCallback(request: Request, env: Env, fetcher: typeof fetch = fetch): Promise { let response: Response; try { const settings = config(env); const url = new URL(request.url); if (url.origin !== settings.APP_ORIGIN) throw new APIError('登录回调地址不匹配。', 400); const saved = cookie(request, flowCookie); const code = url.searchParams.get('code'); if (!saved || !code || url.searchParams.has('error')) throw new APIError('GitHub 登录已取消或过期,请重新登录。', 401); let flow: JWTPayload; try { flow = await verify(settings, 'flow', saved); } catch { throw new APIError('登录请求已过期,请重新登录。', 401); } if (typeof flow.state !== 'string' || flow.state !== url.searchParams.get('state') || typeof flow.verifier !== 'string') { throw new APIError('登录请求不匹配,请重新登录。', 401); } const tokenResponse = await fetcher('https://github.com/login/oauth/access_token', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: settings.GH_CLIENT_ID, client_secret: settings.GH_CLIENT_SECRET, redirect_uri: `${settings.APP_ORIGIN}/auth/callback`, code, code_verifier: flow.verifier, }), signal: AbortSignal.timeout(15_000), }); if (!tokenResponse.ok) throw new APIError('GitHub 登录服务暂时不可用,请重试。', 502); const token = await tokenResponse.json() as { access_token?: string }; if (!token.access_token) throw new APIError('GitHub 授权码无效或已使用,请重新登录。', 401); const userResponse = await fetcher('https://api.github.com/user', { headers: { Authorization: `Bearer ${token.access_token}`, Accept: 'application/vnd.github+json', 'User-Agent': 'EdgeSSH' }, signal: AbortSignal.timeout(15_000), }); if (!userResponse.ok) throw new APIError('无法验证 GitHub 身份,请重试。', 502); const user = await userResponse.json() as { id: number; login: string }; // 以数字 ID 授权,不信任可改名的用户名,也不把任意 GitHub 用户视为管理员。 if (!Number.isSafeInteger(user.id) || String(user.id) !== settings.GH_ADMIN_ID || typeof user.login !== 'string') { throw new APIError('此 GitHub 账号不是本实例的管理员。', 403); } const workspace = await workspaceState(env); if (workspace.authProvider !== 'github') throw new APIError('当前未启用 GitHub 登录。', 503); const session = await sign(settings, 'session', { sub: String(user.id), username: user.login, revision: workspace.authRevision, }, sessionSeconds); // GitHub access_token 只在本次验证中使用,不写数据库、Cookie 或日志。 response = redirect('/', [setCookie(sessionCookie, session, sessionSeconds)]); } catch (error) { response = apiFailure(error); } response.headers.append('Set-Cookie', setCookie(flowCookie, '', 0)); return response; } export async function githubAccount(request: Request, env: Env, authRevision: number): Promise<{ username: string }> { const settings = config(env); const token = cookie(request, sessionCookie); if (!token) throw new APIError('请先使用 GitHub 登录。', 401); try { const payload = await verify(settings, 'session', token); if (payload.sub !== settings.GH_ADMIN_ID || typeof payload.username !== 'string' || payload.revision !== authRevision) throw new Error('Invalid identity'); return { username: payload.username }; } catch { throw new APIError('登录已过期,请重新登录。', 401); } } export function clearGithubCookies(response: Response): Response { response.headers.append('Set-Cookie', setCookie(sessionCookie, '', 0)); response.headers.append('Set-Cookie', setCookie(flowCookie, '', 0)); return response; }