689 lines
24 KiB
TypeScript
689 lines
24 KiB
TypeScript
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, '&')
|
||
.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/<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 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,
|
||
};
|
||
}
|