maestro/src/bridge/auth.ts
oss-sync 2044f0a2c4
Some checks failed
CI / build-and-test (push) Failing after 7m0s
sync: update from private repo (91d8d79c)
2026-07-09 23:57:26 +00:00

1025 lines
41 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, existsSync, writeFileSync, chmodSync, mkdirSync } 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, User } from '../db/repository.js';
import { logger } from '../logger.js';
import { randomBytes } from 'crypto';
import { LoginRateLimiter, throttleScopeForIp } from './login-rate-limit.js';
// Stash the URL a user was trying to reach before being bounced to login, so we
// can send them back after authenticating. Lives on the passport session and
// survives the OAuth round trip.
declare module 'express-session' {
interface SessionData {
returnTo?: string;
}
}
/**
* 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';
// primary=local restricts login to local accounts → OAuth providers off.
if (primary === 'local' && isLocalEnabled(authConfig)) return false;
return true;
}
/** Local email+password login is turned on. */
export function isLocalEnabled(authConfig: AuthConfig): boolean {
return authConfig.local?.enabled === true;
}
/**
* The org ids a user belongs to, for session.orgIds. Union of Gitea orgs
* (from OAuth login) and local orgs (membership). buildVisibilityWhere is
* provider-agnostic, so 'org' visibility works for either once these are set.
*/
export function resolveOrgIds(repo: Repository, userId: string): string[] {
const gitea = repo.listUserGiteaOrgs(userId).map(o => o.orgId);
const local = repo.listUserLocalOrgs(userId).map(o => o.orgId);
return [...new Set([...gitea, ...local])];
}
/**
* Self-service password change for the authenticated local user. Mount behind
* requireAuth + a JSON body parser. Requires the CURRENT password (so a
* hijacked session can't silently re-key the account) and only applies to
* accounts that already have a local credential — OAuth-only users have none
* (adding one is a separate, deferred action). 204 on success.
*/
export function buildChangePasswordHandler(repo: Repository): RequestHandler {
return function changePassword(req: Request, res: Response): void {
const user = req.user as Express.User | undefined;
if (!user) { res.status(401).json({ error: 'unauthenticated' }); return; }
const { currentPassword, newPassword } = (req.body ?? {}) as {
currentPassword?: unknown;
newPassword?: unknown;
};
if (typeof newPassword !== 'string' || newPassword.length < 8) {
res.status(400).json({ error: 'new password must be at least 8 characters' });
return;
}
if (!repo.hasLocalCredential(user.id)) {
res.status(400).json({ error: 'this account has no local password to change' });
return;
}
if (typeof currentPassword !== 'string' || !repo.verifyLocalPassword(user.id, currentPassword)) {
res.status(403).json({ error: 'current password is incorrect' });
return;
}
repo.setLocalPassword(user.id, newPassword);
res.status(204).end();
};
}
/**
* 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');
const localEnabled = isLocalEnabled(authConfig);
const allowSignup = authConfig.local?.allowSignup === true;
// Ignore a primaryProvider that points to an unconfigured/disabled provider —
// otherwise it would hide the only working login and lock the operator out.
const primary =
(authConfig.primaryProvider === 'google' && googleConfigured) ||
(authConfig.primaryProvider === 'gitea' && giteaConfigured) ||
(authConfig.primaryProvider === 'local' && localEnabled)
? authConfig.primaryProvider
: undefined;
// Decide which login options to show
let showGoogle: boolean;
let showGitea: boolean;
let showLocal: boolean;
if (primary === 'google') {
showGoogle = googleConfigured; showGitea = false; showLocal = false;
} else if (primary === 'gitea') {
showGoogle = false; showGitea = giteaConfigured; showLocal = false;
} else if (primary === 'local') {
showGoogle = false; showGitea = false; showLocal = localEnabled;
} else {
// No primary specified: show every configured/enabled option
showGoogle = googleConfigured;
showGitea = giteaConfigured;
showLocal = localEnabled;
}
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');
// OAuth-only divider: only when BOTH oauth buttons are visible
if (!(showGoogle && showGitea)) out = stripBlock(out, 'DIVIDER_START', 'DIVIDER_END');
// Local form + signup + its divider
if (!showLocal) out = stripBlock(out, 'LOCAL_FORM_START', 'LOCAL_FORM_END');
else {
if (!allowSignup) out = stripBlock(out, 'LOCAL_SIGNUP_START', 'LOCAL_SIGNUP_END');
// Divider above the local form only when an OAuth button sits above it
if (!(showGoogle || showGitea)) out = stripBlock(out, 'LOCAL_DIVIDER_START', 'LOCAL_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;
/**
* Whether this account has a local (email+password) credential set, as
* opposed to being OAuth-only (Google/Gitea). Drives whether the
* password-change UI (Settings → Preferences) is shown — never carries
* the credential itself. See repo.hasLocalCredential.
*/
hasLocalCredential: boolean;
}
}
}
// ── Middleware ────────────────────────────────────────────────────────────────
/**
* safeReturnTo: オープンリダイレクト対策。同一オリジンの絶対パス(先頭が単一の
* `/`)だけを許可し、正規化済みの文字列を返す。それ以外は null。
* - プロトコル相対 (`//host`)・バックスラッシュ経由 (`/\host`) の外部遷移を拒否
* - スキーム付き (`http:` / `javascript:` 等) を拒否(先頭が `/` でない時点で弾かれる)
* - 制御文字・空文字・非文字列・極端に長い値を拒否
*/
export function safeReturnTo(value: unknown): string | null {
if (typeof value !== 'string') return null;
if (value.length === 0 || value.length > 2048) return null;
if (value[0] !== '/') return null; // must be a rooted, same-origin path
if (value[1] === '/') return null; // protocol-relative //host
if (value.includes('\\')) return null; // backslash smuggling (/\host)
if (/[\u0000-\u001f]/.test(value)) return null; // control chars (CR/LF, tab, etc.)
return value;
}
/**
* isTopLevelNavigation: トップレベルのブラウザ遷移(アドレスバー入力・リンク
* クリック・iframe 直開けかを判定する。SPA の fetch/XHR は除外する。
* - GET 以外は false
* - `Sec-Fetch-Mode: navigate` を主判定(モダンブラウザ)
* - 同ヘッダが無い環境では `Accept: text/html` を代替条件にする
*/
export function isTopLevelNavigation(req: Request): boolean {
if (req.method !== 'GET') return false;
const headers = (req.headers ?? {}) as Record<string, string | string[] | undefined>;
const secFetchMode = headers['sec-fetch-mode'];
if (typeof secFetchMode === 'string') return secFetchMode === 'navigate';
const accept = headers['accept'];
return typeof accept === 'string' && accept.includes('text/html');
}
/**
* consumeReturnTo: session に退避された returnTo を取り出して削除し、再検証して
* 返す(使い切り)。無効値・未設定なら null。
*/
export function consumeReturnTo(req: Request): string | null {
const session = req.session as (typeof req.session & { returnTo?: string }) | undefined;
if (!session) return null;
const raw = session.returnTo;
if (raw !== undefined) delete session.returnTo;
return safeReturnTo(raw);
}
/**
* requireAuth: 認証済みかつ status=active のユーザーのみ通過させる。
* 未認証のトップレベルのブラウザ遷移は、戻り先 (returnTo) を付けてログイン画面へ
* リダイレクトする共有された信頼済みHTMLの直リンク等を救済する
* それ以外の未認証リクエストは従来通り、/api/ には 401 JSON、それ以外は
* /auth/loginreturnTo 無し)にリダイレクトする。
*/
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 (isTopLevelNavigation(req)) {
const returnTo = safeReturnTo(req.originalUrl);
const query = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '';
res.redirect(`/auth/login${query}`);
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 ──────────────────────────────────────────────────────
/**
* Default session lifetime (ms) when auth.session_max_age is unset. Applied to
* BOTH the cookie maxAge and the store TTL so an unconfigured deployment gets a
* concrete, sliding expiry window instead of a browser-session cookie paired
* with a store row that expires after a day (the old `?? 86400` fallback).
* With rolling this is an inactivity window — an active session never expires;
* we intentionally don't impose an absolute timeout for this self-hosted tool.
*/
export const DEFAULT_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
/** Default on-disk location for the auto-generated session secret. */
export const DEFAULT_SESSION_SECRET_PATH = './data/secrets/session-secret.key';
/**
* Resolve the session secret when auth.session_secret is unset. Reads a stable
* secret persisted at `secretPath`, generating + saving one (0600, in a 0700
* dir) on first run. This keeps sessions valid across restarts instead of the
* old per-process random secret that logged everyone out on every restart.
* Mirrors initMasterKey (src/crypto/sessions.ts). Falls back to an in-memory
* random secret only if the file can't be read or written.
*/
export function loadOrCreateSessionSecret(secretPath: string): string {
try {
if (existsSync(secretPath)) {
const existing = readFileSync(secretPath, 'utf8').trim();
if (existing.length > 0) {
if (existing.length < 16) {
logger.warn(
`[auth] session secret at ${secretPath} is suspiciously short (${existing.length} chars) — it may be truncated. Delete it to regenerate, or set auth.session_secret.`,
);
}
return existing;
}
}
const secret = randomBytes(32).toString('hex');
mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
if (existsSync(secretPath)) {
// File exists but was empty/blank (we'd have returned above otherwise) —
// overwrite it; no creation race to worry about since it already exists.
writeFileSync(secretPath, secret, { mode: 0o600 });
} else {
// Atomic create: 'wx' fails if another process won the race to create the
// file first; re-read theirs so all processes converge on one secret.
try {
writeFileSync(secretPath, secret, { mode: 0o600, flag: 'wx' });
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'EEXIST') {
const won = readFileSync(secretPath, 'utf8').trim();
if (won.length > 0) return won;
writeFileSync(secretPath, secret, { mode: 0o600 }); // racer wrote blank — overwrite
} else {
throw e;
}
}
}
chmodSync(secretPath, 0o600);
logger.info(
`[auth] generated a persistent session secret at ${secretPath} (sessions now survive restarts). Override with auth.session_secret in Settings → Authentication.`,
);
return secret;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(
`[auth] could not persist a session secret at ${secretPath} (${msg}) — using a random per-process secret; sessions reset on restart. Set auth.session_secret in Settings → Authentication.`,
);
return randomBytes(32).toString('hex');
}
}
/**
* Repository の SQLite DB を使ったカスタムセッションストア。
* sessions テーブル (sid, sess, expired) を直接操作する。
*/
export 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 {
// cookie.maxAge is already in milliseconds; don't re-scale it. The
// fallback is the same ms default the middleware applies, so the store
// row and the cookie expire together.
const ttl = sessionData.cookie?.maxAge ?? DEFAULT_SESSION_MAX_AGE_MS;
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 ?? DEFAULT_SESSION_MAX_AGE_MS;
const expired = new Date(Date.now() + ttl).toISOString();
db.prepare("UPDATE sessions SET expired = ? WHERE sid = ?").run(expired, sid);
callback?.();
} catch (err) {
callback?.(err);
}
}
}
// Eagerly sweep already-expired rows at startup. get() prunes lazily, but a
// session whose owner never returns would otherwise linger forever; the
// idx_sessions_expired index keeps this cheap.
try {
db.prepare("DELETE FROM sessions WHERE expired < ?").run(new Date().toISOString());
} catch {
// Non-fatal: a missing table on a brand-new DB is fine; the store still works.
}
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,
hasLocalCredential: repo.hasLocalCredential(user.id),
};
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 = resolveOrgIds(repo, user.id);
const sessionUser: Express.User = {
...user,
orgIds,
defaultVisibility: user.defaultVisibility ?? 'private',
defaultVisibilityOrgId: user.defaultVisibilityOrgId ?? null,
hasLocalCredential: repo.hasLocalCredential(user.id),
};
done(null, sessionUser);
} catch (err) {
done(err);
}
}
)
);
}
// ── Auth Router ───────────────────────────────────────────────────────────────
function createAuthRouter(
repo: Repository,
authConfig: AuthConfig,
getBranding?: () => LoginBranding,
): Router {
const router = express.Router();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ログインページ
router.get('/login', (req: Request, res: Response) => {
// 戻り先 (returnTo) が付いていれば session に退避する。OAuth は外部往復で
// session が生き残るため、ここで保存すれば全プロバイダで復帰できる。
const returnTo = safeReturnTo(req.query.returnTo);
if (returnTo && req.session) req.session.returnTo = returnTo;
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', keepSessionInfo: true }),
(req, res) => {
const user = req.user as Express.User | undefined;
if (user?.status === 'active') {
res.redirect(consumeReturnTo(req) ?? '/');
} 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', keepSessionInfo: true }),
(req, res) => {
const user = req.user as Express.User | undefined;
if (user?.status === 'active') {
res.redirect(consumeReturnTo(req) ?? '/');
} else {
res.redirect('/auth/pending');
}
}
);
}
// Local accounts (email + password)
if (isLocalEnabled(authConfig)) {
const parseBody = [express.urlencoded({ extended: false }), express.json()];
// Throttle online password guessing per client IP: lock for 15 min after
// 10 failures within 15 min. scrypt already makes each guess costly; this
// caps the rate. Keyed by IP only — NOT by account — on purpose: account
// lockout would let anyone lock a victim out by failing on their email
// (targeted DoS), and would let unauthenticated callers grow the key map
// with arbitrary email strings (memory DoS). Process-local; a multi-process
// / HA deployment behind a load balancer would need a shared store.
const loginLimiter = new LoginRateLimiter({
maxAttempts: 10,
windowMs: 15 * 60 * 1000,
lockoutMs: 15 * 60 * 1000,
maxKeys: 10_000,
});
// `req.ip` honors Express `trust proxy` (set when secureCookie is on, i.e.
// behind a TLS proxy). Falls back to the raw socket address otherwise.
const clientIp = (req: Request): string => req.ip ?? req.socket?.remoteAddress ?? 'unknown';
// Keep raw user input out of log lines (strip control chars, cap length).
const logSafe = (s: string): string => s.replace(/[^\x20-\x7e]/g, '').slice(0, 120);
const toExpressUser = (u: User): Express.User => ({
...u,
orgIds: resolveOrgIds(repo, u.id),
defaultVisibility: u.defaultVisibility ?? 'private',
defaultVisibilityOrgId: u.defaultVisibilityOrgId ?? null,
hasLocalCredential: repo.hasLocalCredential(u.id),
});
const readCreds = (req: Request): { email: string; password: string } | null => {
const b = (req.body ?? {}) as { email?: unknown; password?: unknown };
if (typeof b.email !== 'string' || typeof b.password !== 'string') return null;
const email = b.email.trim();
if (!email || !b.password) return null;
return { email, password: b.password };
};
// Login: verify password, then establish the passport session via req.login.
router.post('/local', ...parseBody, (req: Request, res: Response, next: NextFunction) => {
const creds = readCreds(req);
if (!creds) { res.redirect('/auth/login?error=invalid'); return; }
const ip = clientIp(req);
// Key on the /64 for IPv6 so a client holding a whole /64 can't rotate
// source addresses to evade the throttle (raw `ip` is kept for logs).
const ipKey = `ip:${throttleScopeForIp(ip)}`;
if (loginLimiter.isLocked(ipKey)) {
logger.warn(`[auth] local login throttled ip=${logSafe(ip)}`);
res.redirect('/auth/login?error=locked');
return;
}
const user = repo.getUserByEmail(creds.email);
// Verify even when the user is missing is not needed; getUserByEmail is the
// gate. Wrong password and unknown email both surface as the same error.
if (!user || !repo.verifyLocalPassword(user.id, creds.password)) {
loginLimiter.recordFailure(ipKey);
logger.warn(`[auth] local login failed ip=${logSafe(ip)} email=${logSafe(creds.email)}`);
res.redirect('/auth/login?error=credentials');
return;
}
if (user.status === 'disabled') { res.redirect('/auth/login?error=disabled'); return; }
// Clear the throttle on success. req.login (passport SessionManager.logIn)
// regenerates the session id itself, so no explicit regenerate is needed
// here for session-fixation protection.
loginLimiter.reset(ipKey);
// keepSessionInfo: req.login regenerates the session id (fixation
// protection) and would otherwise drop the returnTo we stashed on
// GET /auth/login — keep it so the post-login redirect can use it.
req.login(toExpressUser(user), { session: true, keepSessionInfo: true }, (err) => {
if (err) { next(err); return; }
res.redirect(user.status === 'active' ? (consumeReturnTo(req) ?? '/') : '/auth/pending');
});
});
// Self-signup (opt-in): creates a pending user an admin must approve.
if (authConfig.local?.allowSignup) {
router.post('/local/signup', ...parseBody, (req: Request, res: Response, next: NextFunction) => {
const creds = readCreds(req);
if (!creds) { res.redirect('/auth/login?error=invalid'); return; }
if (creds.password.length < 8) { res.redirect('/auth/login?error=weak'); return; }
let user;
try {
user = repo.createLocalUser({ email: creds.email, password: creds.password, role: 'user', status: 'pending' });
} catch {
// Most likely the email is already registered. Don't disclose which.
res.redirect('/auth/login?error=signup');
return;
}
// req.login regenerates the session id (passport SessionManager.logIn),
// which is the session-fixation protection for this manual flow.
req.login(toExpressUser(user), (err) => {
if (err) { next(err); return; }
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,
sessionSecretPath: string = DEFAULT_SESSION_SECRET_PATH,
): 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 when it's unset we use a secret persisted on disk
// (generated once) rather than a per-process random one — the latter logged
// everyone out on every restart. A configured auth.session_secret still wins.
// Trim so a whitespace-only YAML value (" ") isn't accepted as a (very weak)
// secret — treat it as unset and fall back to the persisted secret.
let sessionSecret = authConfig.sessionSecret?.trim() ?? '';
if (sessionSecret.length === 0) {
sessionSecret = loadOrCreateSessionSecret(sessionSecretPath);
}
// セッションミドルウェア
const sessionMiddleware = session({
secret: sessionSecret,
resave: false,
saveUninitialized: false,
// rolling: re-issue the cookie (and bump the store TTL via touch) on every
// response so an actively-used session never expires mid-use. Without it the
// cookie's Max-Age is frozen at login and the user is logged out at a fixed
// wall-clock time regardless of activity.
rolling: true,
store: createSqliteSessionStore(db),
cookie: {
// httpOnly: keep the session cookie out of document.cookie (defense in
// depth against XSS token theft). sameSite 'lax': don't send the cookie on
// cross-site POST/DELETE, which blocks the basic CSRF vector without a
// token for this same-origin app.
httpOnly: true,
sameSite: 'lax',
secure: authConfig.secureCookie,
// Always give the cookie a concrete lifetime. When session_max_age is
// unset, fall back to the default instead of leaving maxAge undefined —
// an undefined maxAge yields a browser-session cookie (no Max-Age) that the
// browser keeps only until it closes, paired with the store's 1-day TTL.
// A concrete maxAge + rolling gives a proper sliding inactivity window.
maxAge: authConfig.sessionMaxAge ?? DEFAULT_SESSION_MAX_AGE_MS,
},
});
// 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 enriched: Express.User = {
...baseUser,
orgIds: resolveOrgIds(repo, id),
defaultVisibility: baseUser.defaultVisibility ?? 'private',
defaultVisibilityOrgId: baseUser.defaultVisibilityOrgId ?? null,
hasLocalCredential: repo.hasLocalCredential(id),
};
done(null, enriched);
} catch (err) {
done(err);
}
});
// OAuth ストラテジー登録
registerGoogleStrategy(repo, authConfig);
registerGiteaStrategy(repo, authConfig);
// 認証ルーター
const authRouter = createAuthRouter(repo, 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,
};
}