Ctrl K
ComunidadGuías…

server/src/auth.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import nodeCrypto from 'node:crypto';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { config } from './config.ts';
import { all, get, newId, now, run, tx } from './db.ts';
import { randomToken, sameString, sha256 } from './crypto.ts';
import { addTeamMember, canUseGateway, ensurePersonalVault, type TeamRole, type User } from './access.ts';
import { audit, body, HttpError, str } from './http.ts';
import { endLive } from './live.ts';
import { openSecret, recoveryHash, verifyTotp } from './totp.ts';
import { checkNewPassword, hashPassword, hashSlot, normalizeInviteCode, passwordField, Throttle, tooMany, verifyPassword } from './password.ts';

// 앱(화면이 앱 안에 들어 있다)과 서버가 서로 맞는지: 서버 API 수준과, 이 서버가 받아 주는 가장 낮은 앱 수준.
// 앱이 쓰는 API 를 새로 넣으면 API_LEVEL 을, 옛 앱을 더는 받지 않을 때 MIN_APP_LEVEL 을 올린다 (web/src/compat.ts 와 짝)
// 2: 항목 지우기 충돌 확인(baseUpdatedAt)·개인 볼트 서버 사본 비우기·오프라인 기록 시각 (팀 오프라인 기간은 7일 고정이라 없음)
// 3: 볼트 키 다시 봉하기(PUT /api/vaults/:id/key/mine)·봉한 사람(wrappedBy)·팀 초대 수락/거절(/api/me/invites)
export const API_LEVEL = 3;
// SHELL_MIN_APP_API: 시험·긴급용 — 문제가 있는 옛 앱을 당장 막아야 할 때 올린다 (코드 값보다 낮출 수는 없다)
export const MIN_APP_LEVEL = Math.max(1, Math.floor(Number(process.env.SHELL_MIN_APP_API) || 0));

// 아이디·비밀번호: 아이디마다 15분에 10번, 주소(IP)마다 15분에 50번 틀리면 막는다
const loginThrottle = new Throttle(10, 15 * 60 * 1000);
const ipThrottle = new Throttle(50, 15 * 60 * 1000);
// Google 로그인 시작: 주소마다 10분에 30번 (시작할 때마다 10분짜리 행이 생긴다)
const startThrottle = new Throttle(30, 10 * 60 * 1000);
const MAX_OAUTH_STATES = 5000;

declare module 'fastify' {
  interface FastifyRequest {
    user: User | null;
    // Google 로그인은 끝났지만 2단계 인증 코드를 아직 넣지 않은 세션
    mfaPending: boolean;
  }
}

// https 에서는 __Host- 쿠키: 같은 도메인의 다른 하위 도메인이 세션 쿠키를 심지 못한다(Secure·Path=/·Domain 없음이 강제됨).
// 이름이 바뀌어 예전 쿠키(ss_sid)로 로그인해 있던 웹은 한 번 다시 로그인한다 — 예전 이름은 읽지 않는다 (보안 점검 09-29)
const LEGACY_SESSION_COOKIE = 'ss_sid';
const SESSION_COOKIE = config.secureCookies ? '__Host-ss_sid' : 'ss_sid';
const OAUTH_COOKIE = config.secureCookies ? '__Secure-ss_oauth' : 'ss_oauth';
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
const googleKeys = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs'));

class LoginRejected extends Error {
  code: string;
  constructor(code: string) {
    super(code);
    this.code = code;
  }
}

// 초대받은 계정만 들어온다. 첫 로그인 때 받아 둔 초대를 모두 수락한다.
function admitUser(identity: { sub: string; email: string; name: string; picture: string }): User {
  const email = identity.email.toLowerCase();
  return tx(() => {
    let user = get<User>('SELECT * FROM users WHERE google_sub = ?', identity.sub) ?? get<User>('SELECT * FROM users WHERE email = ?', email);
    const invites = all<{ id: string; team_id: string; role: TeamRole }>('SELECT id, team_id, role FROM invites WHERE email = ?', email);
    const bootstrap = config.bootstrapAdmins.includes(email);

    if (user?.google_sub && user.google_sub !== identity.sub) throw new LoginRejected('account_mismatch');
    // 아이디·비밀번호로 만든 계정(초대 코드 가입·env 관리자)에는 이메일이 같다는 것만으로 Google 을 붙이지 않는다.
    // 그 이메일은 서버가 확인한 적이 없다 — 남이 먼저 만들어 둔 계정에 진짜 주인이 들어가 버린다 (보안 점검 H-1)
    if (user && !user.google_sub && user.password_hash) throw new LoginRejected('password_account');
    const isNew = !user;
    if (!user) {
      if (!invites.length && !bootstrap && !config.openSignup) throw new LoginRejected('not_invited');
      const id = newId();
      run('INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)', id, email, now());
      user = get<User>('SELECT * FROM users WHERE id = ?', id)!;
    }
    if (user.disabled) throw new LoginRejected('disabled');

    run(
      'UPDATE users SET google_sub = ?, email = ?, name = ?, avatar_url = ?, is_admin = ?, last_login_at = ? WHERE id = ?',
      identity.sub,
      email,
      identity.name || email.split('@')[0],
      identity.picture,
      user.is_admin || bootstrap,
      now(),
      user.id,
    );
    // 초대는 받은 사람이 로그인해서 수락해야 들어간다 (보안 점검 M-3 — 이미 계정이 있는 사람을 묻지 않고 팀에 넣지 않는다).
    // 초대받아야만 들어오는 서버의 새 계정만은 그 초대 덕분에 들어온 것이라 바로 수락한다
    if (isNew && !config.openSignup) {
      for (const invite of invites) {
        addTeamMember(invite.team_id, user.id, invite.role);
        run('DELETE FROM invites WHERE id = ?', invite.id);
        audit({ userId: user.id, teamId: invite.team_id, action: 'invite_accept', target: email });
      }
    }
    const fresh = get<User>('SELECT * FROM users WHERE id = ?', user.id)!;
    // 팀에서 모두 빠진 사람은 게이트웨이를 못 쓴다 (관리자·누구나 가입하는 서버 제외)
    if (!canUseGateway(fresh)) throw new LoginRejected('no_team');
    ensurePersonalVault(fresh.id);
    return fresh;
  });
}

function clientIp(req: FastifyRequest) {
  return req.ip ?? '';
}

function createSession(req: FastifyRequest, user: User, via: string) {
  const token = randomToken(32);
  run(
    'INSERT INTO sessions (id_hash, user_id, created_at, expires_at, ip, user_agent, mfa_ok) VALUES (?, ?, ?, ?, ?, ?, ?)',
    sha256(token),
    user.id,
    now(),
    now() + config.sessionTtlMs,
    clientIp(req),
    String(req.headers['user-agent'] ?? '').slice(0, 300),
    // OTP 를 켠 사람은 코드를 넣을 때까지 반쯤 로그인한 상태 (POST /api/auth/mfa/verify)
    user.totp_secret ? 0 : 1,
  );
  audit({ userId: user.id, action: 'login', detail: { via }, ip: clientIp(req) });
  return token;
}

// 비밀번호·초대 코드 로그인의 끝: 웹은 쿠키, 앱은 토큰
function finishLogin(req: FastifyRequest, reply: FastifyReply, user: User, via: string, desktop: boolean) {
  const mfa = Boolean(user.totp_secret);
  if (desktop) return { token: createSession(req, user, `${via}-desktop`), maxAgeSeconds: Math.floor(config.sessionTtlMs / 1000), mfa };
  startSession(req, reply, user, via);
  return { ok: true, mfa };
}

function startSession(req: FastifyRequest, reply: FastifyReply, user: User, via: string) {
  const token = createSession(req, user, via);
  if (LEGACY_SESSION_COOKIE !== SESSION_COOKIE && req.cookies?.[LEGACY_SESSION_COOKIE]) reply.clearCookie(LEGACY_SESSION_COOKIE, { path: '/' });
  reply.setCookie(SESSION_COOKIE, token, {
    path: '/',
    httpOnly: true,
    sameSite: 'lax',
    secure: config.secureCookies,
    maxAge: Math.floor(config.sessionTtlMs / 1000),
  });
}

// 웹은 쿠키, 데스크톱 앱은 Authorization: Bearer (앱 화면은 앱 안에 들어 있고 API 는 앱 본체가 대신 부른다)
export function sessionToken(req: FastifyRequest): string | undefined {
  const auth = req.headers.authorization;
  // Bearer 로 부르면 쿠키는 보지 않는다 (값이 비어도) — CSRF 확인과 같은 기준 (index.ts)
  if (typeof auth === 'string' && auth.startsWith('Bearer')) return auth.slice(6).trim() || undefined;
  return req.cookies?.[SESSION_COOKIE];
}

export function sessionHash(req: FastifyRequest): string | null {
  const token = sessionToken(req);
  return token ? sha256(token) : null;
}

// 세션이 살아 있고, 그 사람이 아직 게이트웨이를 쓸 수 있으면 사용자를 돌려준다 (오래 가는 연결의 재확인에도 쓴다)
// 2단계 인증까지 끝난 세션만 사용자로 본다. 코드를 기다리는 세션은 sessionRow 로 따로 본다
export function userBySessionHash(hash: string): User | null {
  const row = sessionRow(hash);
  return row && row.mfa_ok ? row : null;
}

type SessionUser = User & { expires_at: number; mfa_ok: number; mfa_fails: number };
function sessionRow(hash: string): SessionUser | null {
  const row = get<SessionUser>('SELECT u.*, s.expires_at, s.mfa_ok, s.mfa_fails FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id_hash = ?', hash);
  if (!row || row.expires_at < now() || row.disabled) return null;
  if (!canUseGateway(row)) return null;
  return row;
}

// 로그인한 사람의 OTP 코드(또는 복구 코드) 확인 — 맞으면 쓴 칸·복구 코드를 표시한다
export function checkSecondFactor(user: User, code: string, allowRecovery: boolean): 'totp' | 'recovery' | null {
  if (!user.totp_secret) return null;
  const clean = code.replace(/[\s-]/g, '');
  const step = verifyTotp(openSecret(user.totp_secret, user.id), clean, user.totp_last_step);
  if (step !== null) {
    // 쓴 칸을 조건부로 적는다 — 같은 코드를 동시에 두 번 보내도(요청마다 읽어 둔 사용자 정보가 옛것이어도) 한 번만 통과한다
    const used = run('UPDATE users SET totp_last_step = ? WHERE id = ? AND totp_last_step < ?', step, user.id, step);
    return used.changes ? 'totp' : null;
  }
  if (!allowRecovery || !/^[a-z2-7]{10}$/i.test(clean)) return null;
  const used = run('UPDATE totp_recovery SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL', now(), user.id, recoveryHash(clean));
  return used.changes ? 'recovery' : null;
}

// 2단계 인증 코드 대입 막기: 사람마다 다섯 번 틀리면 잠그고, 거듭 잠길수록 길게(15분 → 30분 → … 하루). 맞히면 풀린다.
// 세션마다 세던 것은 다시 로그인하면 새로 셌다 (보안 점검 M-1). 로그인 확인·끄기·복구 코드 새로 받기가 함께 쓴다
const MFA_MAX_FAILS = 5;
export function mfaGuard(userId: string) {
  const r = get<{ mfa_locked_until: number }>('SELECT mfa_locked_until FROM users WHERE id = ?', userId);
  if (r && r.mfa_locked_until > now()) {
    const minutes = Math.max(1, Math.ceil((r.mfa_locked_until - now()) / 60_000));
    throw new HttpError(429, 'mfa_throttled', `코드를 여러 번 틀렸습니다. ${minutes}분 뒤에 다시 시도해 주세요.`);
  }
}
export function mfaFailed(userId: string) {
  const r = get<{ mfa_fails: number; mfa_lock_level: number }>('SELECT mfa_fails, mfa_lock_level FROM users WHERE id = ?', userId);
  if (!r) return;
  if (r.mfa_fails + 1 >= MFA_MAX_FAILS) {
    const minutes = Math.min(15 * 2 ** r.mfa_lock_level, 24 * 60);
    run('UPDATE users SET mfa_fails = 0, mfa_lock_level = mfa_lock_level + 1, mfa_locked_until = ? WHERE id = ?', now() + minutes * 60_000, userId);
  } else {
    run('UPDATE users SET mfa_fails = mfa_fails + 1 WHERE id = ?', userId);
  }
}
export function mfaPassed(userId: string) {
  run('UPDATE users SET mfa_fails = 0, mfa_lock_level = 0, mfa_locked_until = 0 WHERE id = ?', userId);
}

// 로그인한 지 얼마나 됐는지 (지우기처럼 되돌릴 수 없는 일 앞에서 확인한다)
export function sessionAgeMs(req: FastifyRequest): number | null {
  const hash = sessionHash(req);
  const row = hash ? get<{ created_at: number }>('SELECT created_at FROM sessions WHERE id_hash = ?', hash) : undefined;
  return row ? now() - row.created_at : null;
}

export function userFromRequest(req: FastifyRequest): User | null {
  const hash = sessionHash(req);
  return hash ? userBySessionHash(hash) : null;
}

export function requireUser(req: FastifyRequest): User {
  if (!req.user) {
    if (req.mfaPending) throw new HttpError(401, 'mfa_required', '2단계 인증 코드를 입력해 주세요');
    throw new HttpError(401, 'unauthorized', '로그인이 필요합니다');
  }
  return req.user;
}

export function registerAuth(app: FastifyInstance) {
  app.decorateRequest('user', null);
  app.decorateRequest('mfaPending', false);
  app.addHook('onRequest', async (req) => {
    const hash = sessionHash(req);
    const row = hash ? sessionRow(hash) : null;
    req.user = row && row.mfa_ok ? row : null;
    req.mfaPending = Boolean(row && !row.mfa_ok);
  });

  // 2단계 인증: Google 로그인 뒤 인증 앱의 코드(또는 복구 코드)를 넣는다. 다섯 번 틀리면 그 세션을 지운다
  app.post('/api/auth/mfa/verify', async (req) => {
    const hash = sessionHash(req);
    const row = hash ? sessionRow(hash) : null;
    if (!row) throw new HttpError(401, 'unauthorized', '로그인이 필요합니다');
    if (row.mfa_ok) return { ok: true };
    // 그사이 OTP 를 껐으면 그대로 통과
    if (!row.totp_secret) {
      run('UPDATE sessions SET mfa_ok = 1 WHERE id_hash = ?', hash);
      return { ok: true };
    }
    const code = str(body(req.body), 'code', { min: 6, max: 24 })!;
    mfaGuard(row.id);
    const via = checkSecondFactor(row, code, true);
    if (!via) {
      mfaFailed(row.id);
      const fails = row.mfa_fails + 1;
      audit({ userId: row.id, action: 'mfa_fail', detail: { fails }, ip: clientIp(req) });
      if (fails >= 5) {
        run('DELETE FROM sessions WHERE id_hash = ?', hash);
        throw new HttpError(401, 'mfa_locked', '코드를 여러 번 틀려 로그아웃했습니다. 다시 로그인해 주세요.');
      }
      run('UPDATE sessions SET mfa_fails = ? WHERE id_hash = ?', fails, hash);
      throw new HttpError(400, 'mfa_bad', '코드가 맞지 않습니다. 인증 앱의 지금 코드를 입력해 주세요.');
    }
    run('UPDATE sessions SET mfa_ok = 1, mfa_fails = 0 WHERE id_hash = ?', hash);
    mfaPassed(row.id);
    audit({ userId: row.id, action: 'mfa_verify', detail: { via }, ip: clientIp(req) });
    return { ok: true, via };
  });

  app.get('/api/auth/config', async () => ({
    google: Boolean(config.googleClientId && config.googleClientSecret),
    devLogin: config.devLogin,
    password: config.passwordLogin,
    openSignup: config.openSignup,
    links: { terms: config.termsUrl, privacy: config.privacyUrl, source: config.sourceUrl },
    api: API_LEVEL,
    minAppApi: MIN_APP_LEVEL,
  }));

  // 아이디·비밀번호 로그인. 웹은 쿠키, 앱(desktop: true)은 토큰으로 받는다. 2단계 인증을 켠 사람은 이어서 코드를 넣는다
  app.post('/api/auth/password', async (req, reply) => {
    if (!config.passwordLogin) throw new HttpError(404, 'not_found');
    const b = body(req.body);
    const id = str(b, 'id', { min: 1, max: 200 })!.toLowerCase();
    const password = passwordField(b, 'password');
    const ip = clientIp(req);
    if (ipThrottle.blocked(ip) || loginThrottle.blocked(id)) throw tooMany();
    // 느린 해시를 기다리기 전에 먼저 센다 — 한꺼번에 보내도 제한을 넘지 못하게 (보안 점검 M-2). 맞으면 되돌린다
    ipThrottle.fail(ip);
    loginThrottle.fail(id);
    const release = hashSlot(`login:${id}`);
    const user = get<User>('SELECT * FROM users WHERE email = ?', id);
    let ok: boolean;
    try {
      ok = await verifyPassword(password, user?.password_hash);
    } finally {
      release();
    }
    // 해시를 기다리는 동안 비밀번호가 바뀌었거나 2단계 인증이 켜졌을 수 있다 — 지금 계정을 다시 읽어, 확인한 해시가 그대로일 때만
    // 그 상태(지금의 2단계 인증)로 세션을 만든다 (공개 전 점검 OS-02). 다시 읽기부터 세션 만들기까지는 await 가 없어 끼어들 수 없다
    const current = user && ok ? get<User>('SELECT * FROM users WHERE id = ?', user.id) : undefined;
    if (!user || !ok || !current || current.password_hash !== user.password_hash) {
      audit({ action: 'login_rejected', target: id, detail: { reason: 'bad_password' }, ip });
      throw new HttpError(401, 'bad_password', '아이디 또는 비밀번호가 맞지 않습니다');
    }
    loginThrottle.reset(id);
    ipThrottle.undo(ip);
    const reason = current.disabled ? 'disabled' : !canUseGateway(current) ? 'no_team' : '';
    if (reason) {
      audit({ userId: current.id, action: 'login_rejected', target: id, detail: { reason }, ip });
      throw new HttpError(403, reason, reason);
    }
    run('UPDATE users SET last_login_at = ? WHERE id = ?', now(), current.id);
    ensurePersonalVault(current.id);
    return finishLogin(req, reply, current, 'password', b.desktop === true);
  });

  // 초대 코드로 처음 비밀번호를 정하고 들어온다 (비밀번호 로그인 서버). 코드가 맞은 그 초대 하나만 수락한다
  app.post('/api/auth/invite-signup', async (req, reply) => {
    if (!config.passwordLogin) throw new HttpError(404, 'not_found');
    const b = body(req.body);
    const email = str(b, 'email', { min: 3, max: 200 })!.toLowerCase();
    const code = normalizeInviteCode(str(b, 'code', { min: 6, max: 64 })!);
    const name = str(b, 'name', { optional: true, max: 80 }) ?? '';
    const password = passwordField(b, 'password');
    checkNewPassword(password);
    const ip = clientIp(req);
    if (ipThrottle.blocked(ip) || loginThrottle.blocked(`invite:${email}`)) throw tooMany();
    // 서버 관리자가 될 이메일(부트스트랩·env 관리자)은 초대 코드로 만들 수 없다 — 코드를 받은 사람이 관리자 계정을 먼저 차지하지 못하게
    if (config.bootstrapAdmins.includes(email) || email === config.adminId) throw new HttpError(403, 'forbidden', '이 이메일은 초대 코드로 가입할 수 없습니다');
    const invite = all<{ id: string; team_id: string; role: TeamRole; code_hash: string | null; code_expires_at: number | null }>(
      'SELECT id, team_id, role, code_hash, code_expires_at FROM invites WHERE email = ?',
      email,
    ).find((i) => i.code_hash && (i.code_expires_at ?? 0) > now() && sameString(i.code_hash, sha256(code)));
    if (!invite) {
      ipThrottle.fail(ip);
      loginThrottle.fail(`invite:${email}`);
      audit({ action: 'login_rejected', target: email, detail: { reason: 'invite_bad' }, ip });
      throw new HttpError(400, 'invite_bad', '초대 코드가 맞지 않거나 기한이 지났습니다');
    }
    if (get('SELECT 1 AS x FROM users WHERE email = ?', email)) throw new HttpError(409, 'account_exists', '이미 계정이 있습니다. 로그인해 주세요.');
    const release = hashSlot(`invite:${email}`);
    let hash: string;
    try {
      hash = await hashPassword(password);
    } finally {
      release();
    }
    const user = tx(() => {
      const id = newId();
      run(
        'INSERT INTO users (id, email, name, password_hash, password_changed_at, created_at, last_login_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
        id,
        email,
        name || email.split('@')[0],
        hash,
        now(),
        now(),
        now(),
      );
      addTeamMember(invite.team_id, id, invite.role);
      run('DELETE FROM invites WHERE id = ?', invite.id);
      audit({ userId: id, teamId: invite.team_id, action: 'invite_accept', target: email, detail: { via: 'code' }, ip });
      ensurePersonalVault(id);
      return get<User>('SELECT * FROM users WHERE id = ?', id)!;
    });
    return finishLogin(req, reply, user, 'invite', b.desktop === true);
  });

  app.get('/api/auth/google/start', async (req, reply) => {
    if (!config.googleClientId || !config.googleClientSecret) {
      throw new HttpError(503, 'google_not_configured', 'Google 로그인이 아직 설정되지 않았습니다');
    }
    // 시작을 끝없이 되풀이해 표를 채우지 못하게: 주소마다 10분에 30번, 서버 전체로 기다리는 로그인 5000개까지
    const ip = clientIp(req);
    run('DELETE FROM oauth_states WHERE created_at < ?', now() - 10 * 60 * 1000);
    if (startThrottle.blocked(ip) || get<{ n: number }>('SELECT COUNT(*) AS n FROM oauth_states')!.n >= MAX_OAUTH_STATES) return reply.redirect('/?login_error=too_many');
    startThrottle.fail(ip);
    const state = randomToken(24);
    const nonce = randomToken(24);
    const verifier = randomToken(48);
    const q = req.query as { desktop_port?: string; desktop_challenge?: string };
    let desktopPort: number | null = null;
    let desktopChallenge: string | null = null;
    if (q.desktop_port || q.desktop_challenge) {
      desktopPort = Number(q.desktop_port);
      desktopChallenge = String(q.desktop_challenge ?? '');
      if (!Number.isInteger(desktopPort) || desktopPort < 1024 || desktopPort > 65535 || !/^[A-Za-z0-9_-]{43}$/.test(desktopChallenge)) {
        throw new HttpError(400, 'bad_request', '앱 로그인 요청이 올바르지 않습니다');
      }
    }
    run(
      'INSERT INTO oauth_states (state, nonce, verifier, created_at, desktop_port, desktop_challenge) VALUES (?, ?, ?, ?, ?, ?)',
      state,
      nonce,
      verifier,
      now(),
      desktopPort,
      desktopChallenge,
    );
    const url = new URL(GOOGLE_AUTH_URL);
    url.search = new URLSearchParams({
      client_id: config.googleClientId,
      redirect_uri: config.googleRedirectUri,
      response_type: 'code',
      scope: 'openid email profile',
      state,
      nonce,
      code_challenge: nodeCrypto.createHash('sha256').update(verifier).digest('base64url'),
      code_challenge_method: 'S256',
      prompt: 'select_account',
    }).toString();
    // state 를 이 브라우저에 묶어 둔다 (남의 로그인 결과를 끼워 넣는 공격 방지)
    reply.setCookie(OAUTH_COOKIE, state, { path: '/api/auth/google', httpOnly: true, sameSite: 'lax', secure: config.secureCookies, maxAge: 600 });
    return reply.redirect(url.toString());
  });

  app.get('/api/auth/google/callback', async (req, reply) => {
    const q = req.query as Record<string, string | undefined>;
    const row = q.state ? get<{ nonce: string; verifier: string; created_at: number; desktop_port: number | null; desktop_challenge: string | null }>('SELECT * FROM oauth_states WHERE state = ?', q.state) : undefined;
    const loopback = row?.desktop_port ? `http://127.0.0.1:${row.desktop_port}/callback` : null;
    const fail = (code: string) => {
      reply.clearCookie(OAUTH_COOKIE, { path: '/api/auth/google' });
      return reply.redirect(loopback ? `${loopback}?error=${encodeURIComponent(code)}` : `/?login_error=${encodeURIComponent(code)}`);
    };
    if (q.error || !q.code || !q.state) return fail('cancelled');
    run('DELETE FROM oauth_states WHERE state = ?', q.state);
    if (!row || row.created_at < now() - 10 * 60 * 1000 || req.cookies[OAUTH_COOKIE] !== q.state) return fail('expired');

    let claims: { sub: string; email: string; name: string; picture: string };
    try {
      const res = await fetch(GOOGLE_TOKEN_URL, {
        method: 'POST',
        headers: { 'content-type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
          code: q.code,
          client_id: config.googleClientId,
          client_secret: config.googleClientSecret,
          redirect_uri: config.googleRedirectUri,
          grant_type: 'authorization_code',
          code_verifier: row.verifier,
        }),
      });
      if (!res.ok) throw new Error(`token endpoint ${res.status}: ${(await res.text()).slice(0, 200)}`);
      const tokens = (await res.json()) as { id_token?: string };
      if (!tokens.id_token) throw new Error('no id_token');
      const { payload } = await jwtVerify(tokens.id_token, googleKeys, {
        issuer: ['https://accounts.google.com', 'accounts.google.com'],
        audience: config.googleClientId,
      });
      if (payload.nonce !== row.nonce) throw new Error('nonce mismatch');
      if (payload.email_verified !== true || typeof payload.email !== 'string' || !payload.sub) return fail('unverified_email');
      claims = {
        sub: `google:${payload.sub}`,
        email: payload.email,
        name: typeof payload.name === 'string' ? payload.name : '',
        picture: typeof payload.picture === 'string' ? payload.picture : '',
      };
    } catch (err) {
      req.log.warn({ err }, 'google login failed');
      return fail('google_error');
    }

    try {
      const user = admitUser(claims);
      reply.clearCookie(OAUTH_COOKIE, { path: '/api/auth/google' });
      if (loopback && row?.desktop_challenge) {
        const code = randomToken(32);
        run('DELETE FROM desktop_codes WHERE created_at < ?', now() - 2 * 60 * 1000);
        run('INSERT INTO desktop_codes (code_hash, user_id, challenge, created_at) VALUES (?, ?, ?, ?)', sha256(code), user.id, row.desktop_challenge, now());
        return reply.redirect(`${loopback}?code=${encodeURIComponent(code)}`);
      }
      startSession(req, reply, user, 'google');
      return reply.redirect('/');
    } catch (err) {
      if (err instanceof LoginRejected) {
        audit({ action: 'login_rejected', target: claims.email.toLowerCase(), detail: { reason: err.code }, ip: clientIp(req) });
        return fail(err.code);
      }
      throw err;
    }
  });

  app.post('/api/auth/desktop/exchange', async (req) => {
    const b = body(req.body);
    const code = str(b, 'code', { min: 10, max: 200 })!;
    const verifier = str(b, 'verifier', { min: 43, max: 128 })!;
    const row = get<{ user_id: string; challenge: string; created_at: number }>('SELECT * FROM desktop_codes WHERE code_hash = ?', sha256(code));
    run('DELETE FROM desktop_codes WHERE code_hash = ?', sha256(code));
    if (!row || row.created_at < now() - 2 * 60 * 1000) throw new HttpError(400, 'expired', '로그인 코드가 만료되었습니다. 다시 로그인해 주세요.');
    if (nodeCrypto.createHash('sha256').update(verifier).digest('base64url') !== row.challenge) throw new HttpError(400, 'bad_verifier', '로그인 확인에 실패했습니다.');
    const user = get<User>('SELECT * FROM users WHERE id = ?', row.user_id);
    if (!user || !canUseGateway(user)) throw new HttpError(403, 'forbidden', '로그인할 수 없는 계정입니다.');
    return { token: createSession(req, user, 'desktop'), maxAgeSeconds: Math.floor(config.sessionTtlMs / 1000) };
  });

  app.post('/api/auth/dev-login', async (req, reply) => {
    if (!config.devLogin) throw new HttpError(404, 'not_found');
    const b = body(req.body);
    const email = str(b, 'email', { min: 3, max: 200 })!.toLowerCase();
    try {
      const user = admitUser({ sub: `dev:${email}`, email, name: str(b, 'name', { optional: true, max: 80 }) ?? '', picture: '' });
      startSession(req, reply, user, 'dev');
      return { ok: true };
    } catch (err) {
      if (err instanceof LoginRejected) {
        audit({ action: 'login_rejected', target: email, detail: { reason: err.code, dev: true }, ip: clientIp(req) });
        throw new HttpError(403, err.code, err.code);
      }
      throw err;
    }
  });

  // 개발용: 데스크톱 앱이 쓰는 토큰으로 로그인 (SHELL_DEV_LOGIN=1 이고 localhost 일 때만)
  app.post('/api/auth/dev-login-token', async (req) => {
    if (!config.devLogin) throw new HttpError(404, 'not_found');
    const email = str(body(req.body), 'email', { min: 3, max: 200 })!.toLowerCase();
    try {
      const user = admitUser({ sub: `dev:${email}`, email, name: '', picture: '' });
      return { token: createSession(req, user, 'dev-desktop'), maxAgeSeconds: Math.floor(config.sessionTtlMs / 1000) };
    } catch (err) {
      if (err instanceof LoginRejected) throw new HttpError(403, err.code, err.code);
      throw err;
    }
  });

  app.post('/api/auth/logout', async (req, reply) => {
    const token = sessionToken(req);
    if (token) {
      const hash = sha256(token);
      run('DELETE FROM sessions WHERE id_hash = ?', hash);
      // 이 세션으로 열어 둔 웹 SSH 중계도 닫는다 (보안 검토 F-05)
      endLive((l) => l.sessionHash === hash, 'logged out');
    }
    if (req.user) audit({ userId: req.user.id, action: 'logout', ip: clientIp(req) });
    reply.clearCookie(SESSION_COOKIE, { path: '/' });
    if (LEGACY_SESSION_COOKIE !== SESSION_COOKIE) reply.clearCookie(LEGACY_SESSION_COOKIE, { path: '/' });
    return { ok: true };
  });
}

export function purgeExpiredSessions() {
  run('DELETE FROM sessions WHERE expires_at < ?', now());
  run('DELETE FROM oauth_states WHERE created_at < ?', now() - 10 * 60 * 1000);
  run('DELETE FROM desktop_codes WHERE created_at < ?', now() - 2 * 60 * 1000);
}

// 처음 켤 때: SHELL_ADMIN_ID·SHELL_ADMIN_PASSWORD 로 서버 관리자 계정을 만든다 (그 계정에 비밀번호가 아직 없을 때만).
// 비밀번호를 잊으면: npm run user:password -w server -- 아이디
export async function ensureEnvAdmin(log: { info: (msg: string) => void; warn: (msg: string) => void }) {
  if (!config.passwordLogin || !config.adminId) return;
  const existing = get<User>('SELECT * FROM users WHERE email = ?', config.adminId);
  if (existing?.password_hash) return;
  if (!config.adminPassword) {
    log.warn(`SHELL_ADMIN_ID=${config.adminId} 계정에 비밀번호가 없습니다. SHELL_ADMIN_PASSWORD 를 넣거나 npm run user:password 로 정해 주세요.`);
    return;
  }
  const hash = await hashPassword(config.adminPassword);
  const id = tx(() => {
    const userId = existing?.id ?? newId();
    if (!existing) run('INSERT INTO users (id, email, name, is_admin, created_at) VALUES (?, ?, ?, 1, ?)', userId, config.adminId, config.adminId.split('@')[0], now());
    run('UPDATE users SET password_hash = ?, password_changed_at = ?, is_admin = 1 WHERE id = ?', hash, now(), userId);
    ensurePersonalVault(userId);
    return userId;
  });
  audit({ userId: id, action: 'password_set', target: config.adminId, detail: { by: 'env' } });
  log.info(`서버 관리자 계정 ${config.adminId} 에 SHELL_ADMIN_PASSWORD 로 비밀번호를 정했습니다. 이제 env 에서 SHELL_ADMIN_PASSWORD 를 지워도 됩니다.`);
}