import { readFileSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import type { Request, Response, NextFunction, RequestHandler, Router } from 'express'; import type { IncomingMessage } from 'http'; import express from 'express'; import session from 'express-session'; import passport from 'passport'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { Strategy as OAuth2Strategy } from 'passport-oauth2'; import type { Database } from 'better-sqlite3'; import type { AuthConfig, AuthProviderConfig } from '../config.js'; import type { Repository } from '../db/repository.js'; import { logger } from '../logger.js'; import { randomBytes } from 'crypto'; /** * WebSocket upgrade(生 IncomingMessage)から認証済みユーザーを解決するチェッカー。 * Express の上では express-session + passport が自動でこれを担うが、 * server.on('upgrade', ...) は middleware を素通しするので個別に呼ぶ必要がある。 */ export type UpgradeAuthChecker = (req: IncomingMessage) => Promise; // ── Login Page Renderer ────────────────────────────────────────────────────── const __authDirname = path.dirname(fileURLToPath(import.meta.url)); export interface LoginBranding { appName: string; loginPageTitle: string; } const DEFAULT_LOGIN_BRANDING: LoginBranding = { appName: 'MAESTRO', loginPageTitle: 'MAESTRO', }; function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * A provider is usable only when ALL fields needed to complete an OAuth round * trip are present (Gitea additionally needs base_url). Used to gate auth * activation and login-button visibility so a partial config saved from the * Settings UI can't enable auth in a state where nobody can log in. */ export function isProviderConfigured( p: AuthProviderConfig | undefined, kind: 'google' | 'gitea', ): p is AuthProviderConfig { if (!p || !p.clientId || !p.clientSecret || !p.callbackUrl) return false; if (kind === 'gitea' && !p.baseUrl) return false; return true; } /** * Whether a provider should actually be LIVE (strategy + /auth/ route). * A valid `primaryProvider` restricts auth to that single provider, so the * restriction is enforced at the route layer too — not just hidden on the login * page (otherwise the "disabled" provider's route stayed open to direct URLs). * An invalid primary (pointing at an unconfigured provider) is ignored. */ export function isProviderActive(authConfig: AuthConfig, kind: 'google' | 'gitea'): boolean { if (!isProviderConfigured(authConfig.providers[kind], kind)) return false; const primary = authConfig.primaryProvider; if (primary === 'google' && isProviderConfigured(authConfig.providers.google, 'google')) return kind === 'google'; if (primary === 'gitea' && isProviderConfigured(authConfig.providers.gitea, 'gitea')) return kind === 'gitea'; return true; } /** * auth-login.html をレンダリングする。 * primary_provider 設定と各プロバイダの configured 状態に応じて * Google/Gitea ボタンおよび divider を表示/非表示する。 * branding が指定されていれば {{APP_NAME}} / {{LOGIN_PAGE_TITLE}} を差し替える。 */ function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAULT_LOGIN_BRANDING): string { const raw = readFileSync(path.join(__authDirname, 'auth-login.html'), 'utf-8'); const googleConfigured = isProviderConfigured(authConfig.providers.google, 'google'); const giteaConfigured = isProviderConfigured(authConfig.providers.gitea, 'gitea'); // Ignore a primaryProvider that points to an unconfigured provider — otherwise // it would hide the only working login button and lock the operator out. const primary = (authConfig.primaryProvider === 'google' && googleConfigured) || (authConfig.primaryProvider === 'gitea' && giteaConfigured) ? authConfig.primaryProvider : undefined; // Decide which buttons to show let showGoogle: boolean; let showGitea: boolean; if (primary === 'google') { showGoogle = googleConfigured; showGitea = false; } else if (primary === 'gitea') { showGoogle = false; showGitea = giteaConfigured; } else { // No primary specified: show every configured provider showGoogle = googleConfigured; showGitea = giteaConfigured; } const stripBlock = (html: string, startMarker: string, endMarker: string): string => { const re = new RegExp(`[\\s\\S]*?`, 'g'); return html.replace(re, ''); }; let out = raw; if (!showGoogle) out = stripBlock(out, 'GOOGLE_BUTTON_START', 'GOOGLE_BUTTON_END'); if (!showGitea) out = stripBlock(out, 'GITEA_BUTTON_START', 'GITEA_BUTTON_END'); // Hide divider unless both buttons are visible if (!(showGoogle && showGitea)) out = stripBlock(out, 'DIVIDER_START', 'DIVIDER_END'); // Branding placeholders out = out .replace(/\{\{APP_NAME\}\}/g, escapeHtml(branding.appName)) .replace(/\{\{LOGIN_PAGE_TITLE\}\}/g, escapeHtml(branding.loginPageTitle)); return out; } // ── Global type augmentation ───────────────────────────────────────────────── declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Express { interface User { id: string; email: string; name: string | null; avatarUrl: string | null; role: 'admin' | 'user'; status: 'active' | 'pending' | 'disabled'; orgIds: string[]; defaultVisibility: 'private' | 'org' | 'public'; defaultVisibilityOrgId: string | null; } } } // ── Middleware ──────────────────────────────────────────────────────────────── /** * requireAuth: 認証済みかつ status=active のユーザーのみ通過させる。 * API リクエスト(/api/ プレフィックス)には 401 JSON を返す。 * それ以外のリクエストは /auth/login にリダイレクトする。 */ export function requireAuth(req: Request, res: Response, next: NextFunction): void { if (req.isAuthenticated() && req.user && (req.user as Express.User).status === 'active') { next(); return; } if (req.originalUrl.startsWith('/api/')) { res.status(401).json({ error: 'Unauthorized' }); } else { res.redirect('/auth/login'); } } /** * requireAdmin: admin ロールのユーザーのみ通過させる。 * 未認証の場合は requireAuth と同じ挙動(401 or redirect)。 * 認証済みだが admin でない場合は 403 を返す。 */ export function requireAdmin(req: Request, res: Response, next: NextFunction): void { if (!req.isAuthenticated() || !req.user) { if (req.originalUrl.startsWith('/api/')) { res.status(401).json({ error: 'Unauthorized' }); } else { res.redirect('/auth/login'); } return; } const user = req.user as Express.User; if (user.role !== 'admin') { res.status(403).json({ error: 'Forbidden' }); return; } next(); } // ── SQLite Session Store ────────────────────────────────────────────────────── /** * Repository の SQLite DB を使ったカスタムセッションストア。 * sessions テーブル (sid, sess, expired) を直接操作する。 */ function createSqliteSessionStore(db: Database): session.Store { // session.Store の基底クラスを継承 const Store = session.Store as unknown as new () => session.Store; class SqliteStore extends Store { get(sid: string, callback: (err: unknown, session?: session.SessionData | null) => void): void { try { const row = db .prepare('SELECT sess, expired FROM sessions WHERE sid = ?') .get(sid) as { sess: string; expired: string } | undefined; if (!row) { callback(null, null); return; } // 期限切れチェック if (new Date(row.expired) <= new Date()) { db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid); callback(null, null); return; } const sessionData = JSON.parse(row.sess) as session.SessionData; callback(null, sessionData); } catch (err) { callback(err); } } set(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void { try { const ttl = (sessionData.cookie?.maxAge ?? 86400) * 1000; const expired = new Date(Date.now() + ttl).toISOString(); const sess = JSON.stringify(sessionData); db.prepare(` INSERT INTO sessions (sid, sess, expired) VALUES (?, ?, ?) ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expired = excluded.expired `).run(sid, sess, expired); callback?.(); } catch (err) { callback?.(err); } } destroy(sid: string, callback?: (err?: unknown) => void): void { try { db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid); callback?.(); } catch (err) { callback?.(err); } } touch(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void { try { const ttl = (sessionData.cookie?.maxAge ?? 86400) * 1000; const expired = new Date(Date.now() + ttl).toISOString(); db.prepare("UPDATE sessions SET expired = ? WHERE sid = ?").run(expired, sid); callback?.(); } catch (err) { callback?.(err); } } } return new SqliteStore(); } // ── OAuth Callback ──────────────────────────────────────────────────────────── /** * OAuth コールバック共通処理。 * email から findOrCreateUserByOAuth を呼び出し、 * adminEmails に一致する pending ユーザーは自動で admin に昇格する。 */ async function handleOAuthCallback( repo: Repository, adminEmails: string[], provider: string, providerId: string, email: string, name: string, avatarUrl: string | undefined, done: (err: unknown, user?: Express.User | false) => void ): Promise { try { let user = repo.findOrCreateUserByOAuth({ provider, providerId, email, name, avatarUrl, }); // adminEmails に一致する pending ユーザーを自動昇格 if (user.status === 'pending' && adminEmails.includes(email)) { repo.updateUser(user.id, { status: 'active', role: 'admin' }); const updated = repo.getUserById(user.id); if (updated) user = updated; } // deserializeUser will enrich with orgIds + defaults on subsequent requests. const sessionUser: Express.User = { ...user, orgIds: [], defaultVisibility: user.defaultVisibility ?? 'private', defaultVisibilityOrgId: user.defaultVisibilityOrgId ?? null, }; done(null, sessionUser); } catch (err) { done(err); } } // ── Gitea Orgs Fetch ───────────────────────────────────────────────────────── /** * Gitea の /api/v1/user/orgs を呼び出してユーザーの所属 org 一覧を取得し、 * Repository に永続化する。返り値は org ID の文字列配列。 * 失敗時は空配列を返し、警告ログを出力する(認証フロー自体は継続)。 */ export async function fetchGiteaOrgsForUser( repo: Repository, userId: string, baseUrl: string, accessToken: string, ): Promise { let res: globalThis.Response; try { res = await fetch(`${baseUrl}/api/v1/user/orgs`, { headers: { Authorization: `token ${accessToken}`, Accept: 'application/json' }, }); } catch (err) { console.warn(`[auth] gitea orgs fetch error: ${(err as Error).message}`); // Clear stale cache: if we can't confirm membership, don't keep old grants around. repo.replaceUserGiteaOrgs(userId, []); return []; } if (!res.ok) { console.warn(`[auth] gitea orgs fetch failed: ${res.status}`); repo.replaceUserGiteaOrgs(userId, []); return []; } const orgs = (await res.json()) as Array<{ id: number; username: string }>; const items = orgs.map(o => ({ orgId: String(o.id), orgName: o.username })); repo.replaceUserGiteaOrgs(userId, items); return items.map(i => i.orgId); } // ── Strategy Registration ───────────────────────────────────────────────────── function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void { const googleConfig = authConfig.providers.google; if (!isProviderConfigured(googleConfig, 'google')) return; if (!isProviderActive(authConfig, 'google')) return; passport.use( new GoogleStrategy( { clientID: googleConfig.clientId, clientSecret: googleConfig.clientSecret, callbackURL: googleConfig.callbackUrl, }, async (_accessToken, _refreshToken, profile, done) => { const email = profile.emails?.[0]?.value ?? ''; const name = profile.displayName ?? ''; const avatarUrl = profile.photos?.[0]?.value; await handleOAuthCallback( repo, authConfig.adminEmails ?? [], 'google', profile.id, email, name, avatarUrl, done as (err: unknown, user?: Express.User | false) => void ); } ) ); } function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void { const giteaConfig = authConfig.providers.gitea; if (!isProviderConfigured(giteaConfig, 'gitea')) return; if (!isProviderActive(authConfig, 'gitea')) return; const baseUrl = giteaConfig.baseUrl ?? ''; passport.use( 'gitea', new OAuth2Strategy( { authorizationURL: `${baseUrl}/login/oauth/authorize`, tokenURL: `${baseUrl}/login/oauth/access_token`, clientID: giteaConfig.clientId, clientSecret: giteaConfig.clientSecret, callbackURL: giteaConfig.callbackUrl, }, async (accessToken: string, _refreshToken: string, _params: unknown, _profile: unknown, done: (err: unknown, user?: Express.User | false) => void) => { try { // Gitea 専用: アクセストークンでユーザー情報を取得 const response = await fetch(`${baseUrl}/api/v1/user`, { headers: { Authorization: `token ${accessToken}`, 'Content-Type': 'application/json', }, }); if (!response.ok) { done(new Error(`Gitea userinfo fetch failed: ${response.status}`)); return; } const profile = await response.json() as { id: number; login: string; email: string; full_name?: string; avatar_url?: string; }; const email = profile.email && profile.email.length > 0 ? profile.email : `${profile.login}@gitea.local`; // Gitea returns full_name="" when the user hasn't set it; `??` would // keep that empty string, so use `||` to fall through to the login. const name = profile.full_name || profile.login || ''; const avatarUrl = profile.avatar_url; // Gitea verify は handleOAuthCallback をインライン化: // accessToken/baseUrl がこのスコープでしか得られないため、 // user を確定させた後 fetchGiteaOrgsForUser を呼んで orgs を永続化する。 let user = repo.findOrCreateUserByOAuth({ provider: 'gitea', providerId: String(profile.id), email, name, avatarUrl, }); if (user.status === 'pending' && (authConfig.adminEmails ?? []).includes(email)) { repo.updateUser(user.id, { status: 'active', role: 'admin' }); const updated = repo.getUserById(user.id); if (updated) user = updated; } await fetchGiteaOrgsForUser(repo, user.id, baseUrl, accessToken); const orgIds = repo.listUserGiteaOrgs(user.id).map(o => o.orgId); const sessionUser: Express.User = { ...user, orgIds, defaultVisibility: user.defaultVisibility ?? 'private', defaultVisibilityOrgId: user.defaultVisibilityOrgId ?? null, }; done(null, sessionUser); } catch (err) { done(err); } } ) ); } // ── Auth Router ─────────────────────────────────────────────────────────────── function createAuthRouter( authConfig: AuthConfig, getBranding?: () => LoginBranding, ): Router { const router = express.Router(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); // ログインページ router.get('/login', (_req: Request, res: Response) => { const branding = getBranding ? getBranding() : DEFAULT_LOGIN_BRANDING; res.type('html').send(renderLoginPage(authConfig, branding)); }); // 承認待ちページ(承認済みなら自動リダイレクト) router.get('/pending', (req, res) => { if (req.isAuthenticated() && (req.user as Express.User).status === 'active') { res.redirect('/'); return; } res.sendFile(path.join(__dirname, 'auth-pending.html')); }); // ステータス確認エンドポイント(承認待ちページのポーリング用) router.get('/status', (req, res) => { if (!req.isAuthenticated() || !req.user) { res.json({ status: 'unauthenticated' }); return; } res.json({ status: (req.user as Express.User).status }); }); // Google OAuth if (isProviderActive(authConfig, 'google')) { router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] })); router.get( '/google/callback', passport.authenticate('google', { failureRedirect: '/auth/login' }), (req, res) => { const user = req.user as Express.User | undefined; if (user?.status === 'active') { res.redirect('/'); } else { res.redirect('/auth/pending'); } } ); } // Gitea OAuth if (isProviderActive(authConfig, 'gitea')) { router.get('/gitea', passport.authenticate('gitea')); router.get( '/gitea/callback', passport.authenticate('gitea', { failureRedirect: '/auth/login' }), (req, res) => { const user = req.user as Express.User | undefined; if (user?.status === 'active') { res.redirect('/'); } else { res.redirect('/auth/pending'); } } ); } // ログアウト router.get('/logout', (req, res, next) => { req.logout((err) => { if (err) { next(err); return; } res.redirect('/auth/login'); }); }); return router; } // ── setupAuth ───────────────────────────────────────────────────────────────── export interface AuthMiddlewares { sessionMiddleware: RequestHandler; passportInit: RequestHandler; passportSession: RequestHandler; authRouter: Router; /** * Raw HTTP upgrade(WebSocket)リクエストから認証済みユーザーを解決する。 * Cookie → セッション → Passport deserialize の順に通し、最終的な req.user を返す。 * 認証されていなければ null を返す。 */ authenticateUpgrade: UpgradeAuthChecker; } /** * 認証モジュールのセットアップ。 * セッション、Passport、OAuth ストラテジーを設定し、 * ミドルウェアと認証ルーターを返す。 */ export function setupAuth( repo: Repository, authConfig: AuthConfig, getBranding?: () => LoginBranding, ): AuthMiddlewares { const db = repo.getDb(); // express-session throws "secret option required" (→ 500 on every request) if // the secret is empty. Auth can be enabled from the Settings UI before a // session_secret is set, so fall back to a random per-process secret with a // warning rather than bricking the server. Sessions reset on restart until a // stable value is configured. let sessionSecret = authConfig.sessionSecret; if (!sessionSecret || sessionSecret.length === 0) { sessionSecret = randomBytes(32).toString('hex'); logger.warn( '[auth] auth.session_secret is unset — using a random per-process secret; sessions reset on restart. Set a stable value in Settings → Authentication.', ); } // セッションミドルウェア const sessionMiddleware = session({ secret: sessionSecret, resave: false, saveUninitialized: false, store: createSqliteSessionStore(db), cookie: { secure: authConfig.secureCookie, maxAge: authConfig.sessionMaxAge, }, }); // Passport シリアライズ/デシリアライズ passport.serializeUser((user: Express.User, done) => { done(null, user.id); }); passport.deserializeUser((id: string, done) => { try { const baseUser = repo.getUserById(id); if (!baseUser) { done(null, false); return; } const orgs = repo.listUserGiteaOrgs(id); const enriched: Express.User = { ...baseUser, orgIds: orgs.map(o => o.orgId), defaultVisibility: baseUser.defaultVisibility ?? 'private', defaultVisibilityOrgId: baseUser.defaultVisibilityOrgId ?? null, }; done(null, enriched); } catch (err) { done(err); } }); // OAuth ストラテジー登録 registerGoogleStrategy(repo, authConfig); registerGiteaStrategy(repo, authConfig); // 認証ルーター const authRouter = createAuthRouter(authConfig, getBranding); const passportInit = passport.initialize(); const passportSession = passport.session(); // 生 upgrade リクエスト用の認証チェッカー。 // sessionMiddleware → passportInit → passportSession を順に走らせ、req.user を populate する。 // 失敗時は null を返し、呼び出し側で socket.destroy() する想定。 // 各 middleware が next(err) を呼んだ場合(session store 障害・deserialize 失敗等)は // ログを出してから null を返す(fail-closed)。 const authenticateUpgrade: UpgradeAuthChecker = (req) => { return new Promise((resolve) => { // express-session 等が res.setHeader / res.end を呼ぶことがあるため、 // 必要最小限のメソッドを no-op で備えたスタブを渡す。 const fakeRes = { setHeader: () => fakeRes, getHeader: () => undefined, removeHeader: () => fakeRes, end: () => fakeRes, writeHead: () => fakeRes, statusCode: 200, on: () => fakeRes, } as unknown as Response; const reqAny = req as unknown as Request; const failClosed = (stage: string, err: unknown): void => { const msg = err instanceof Error ? err.message : String(err); logger.warn(`[auth] authenticateUpgrade ${stage} failed: ${msg}`); resolve(null); }; sessionMiddleware(reqAny, fakeRes, (sessionErr?: unknown) => { if (sessionErr) { failClosed('sessionMiddleware', sessionErr); return; } passportInit(reqAny, fakeRes, (initErr?: unknown) => { if (initErr) { failClosed('passportInit', initErr); return; } passportSession(reqAny, fakeRes, (sessErr?: unknown) => { if (sessErr) { failClosed('passportSession', sessErr); return; } const user = reqAny.user as Express.User | undefined; if (!user) { resolve(null); return; } // status=active のみ認める(disabled/pending を弾く) if (user.status !== 'active') { resolve(null); return; } resolve(user); }); }); }); }); }; return { sessionMiddleware, passportInit, passportSession, authRouter, authenticateUpgrade, }; }