maestro/src/bridge/auth.ts
oss-sync 3848b5efd7
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (63a6e76)
2026-06-09 03:17:43 +00:00

689 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Express.User | null>;
// ── 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 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/<kind> 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*${startMarker}\\s*-->[\\s\\S]*?<!--\\s*${endMarker}\\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<void> {
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<string[]> {
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 upgradeWebSocketリクエストから認証済みユーザーを解決する。
* 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,
};
}