feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+636
View File
@@ -0,0 +1,636 @@
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 } from '../config.js';
import type { Repository } from '../db/repository.js';
import { logger } from '../logger.js';
/**
* 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;');
}
/**
* 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 primary = authConfig.primaryProvider;
const googleConfigured = !!authConfig.providers.google?.clientId;
const giteaConfigured = !!authConfig.providers.gitea?.clientId;
// 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 (!googleConfig) 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 (!giteaConfig) 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 (authConfig.providers.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 (authConfig.providers.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();
// セッションミドルウェア
const sessionMiddleware = session({
secret: authConfig.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,
};
}