Files
EdgeSSH/src/accounts/github-auth.ts
T

148 lines
7.7 KiB
TypeScript

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<Pick<Env, 'APP_ORIGIN' | 'GH_CLIENT_ID' | 'GH_CLIENT_SECRET' | 'GH_ADMIN_ID'>>;
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<Uint8Array> {
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<string> {
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<JWTPayload> {
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<Response> {
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<Response> {
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;
}