sync: update from private repo (88dd58b)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-09 10:50:27 +00:00
parent 2bab882d08
commit 2ec9853655
17 changed files with 1099 additions and 27 deletions
+145
View File
@@ -0,0 +1,145 @@
/**
* Admin user-management with local accounts: create, password reset, and the
* deletion path (folder removal + local-user guard).
*
* Uses a real admin session (request.agent cookie jar) so requireAdmin passes.
* See docs/superpowers/plans/2026-06-09-local-auth.md.
*/
import { afterAll, beforeAll, describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import express, { type Express } from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { runMigrations } from '../db/migrate.js';
import { setupAuth, requireAuth, buildChangePasswordHandler } from './auth.js';
import { mountAdminApi } from './admin-api.js';
import { ensureUserFolder } from '../user-folder/paths.js';
import type { AuthConfig } from '../config.js';
const AUTH: AuthConfig = {
sessionSecret: 'test', sessionMaxAge: 3600_000, secureCookie: false,
adminEmails: [], providers: {}, local: { enabled: true },
};
describe('admin-api local user management', () => {
let tempDir = '';
let userFolderRoot = '';
let repo: Repository;
let app: Express;
// setupAuth ONCE per file: passport.serializeUser/deserializeUser register
// onto a stack (they don't replace), so calling setupAuth per-test would
// leave earlier tests' deserializers (bound to closed repos) running first.
// The server only ever calls setupAuth once, so this mirrors production.
beforeAll(() => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-adminlocal-'));
userFolderRoot = join(tempDir, 'users');
repo = new Repository(join(tempDir, 'orchestrator.db'));
runMigrations(repo.getDb());
repo.upsertLocalSystemAdmin({ email: '[email protected]', password: 'adminpw12' });
app = express();
const auth = setupAuth(repo, AUTH);
app.use(auth.sessionMiddleware);
app.use(auth.passportInit);
app.use(auth.passportSession);
app.use('/auth', auth.authRouter);
app.use('/api/admin', express.json());
mountAdminApi(app, repo, true, userFolderRoot);
// Self-service password change (mounted the same way server.ts does).
app.post('/api/auth/password', requireAuth, express.json(), buildChangePasswordHandler(repo));
});
afterAll(() => {
repo.close();
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
});
async function adminAgent() {
const agent = request.agent(app);
await agent.post('/auth/local').type('form').send({ email: '[email protected]', password: 'adminpw12' });
return agent;
}
it('POST creates a local user (active) that can authenticate', async () => {
const agent = await adminAgent();
const res = await agent.post('/api/admin/users').send({ email: '[email protected]', password: 'bobpw1234' });
expect(res.status).toBe(201);
const u = repo.getUserByEmail('[email protected]')!;
expect(u.status).toBe('active');
expect(repo.verifyLocalPassword(u.id, 'bobpw1234')).toBe(true);
});
it('POST rejects a duplicate email (409)', async () => {
const agent = await adminAgent();
await agent.post('/api/admin/users').send({ email: '[email protected]', password: 'pw123456' });
const res = await agent.post('/api/admin/users').send({ email: '[email protected]', password: 'other123' });
expect(res.status).toBe(409);
});
it('POST .../password resets the password', async () => {
const agent = await adminAgent();
const u = repo.createLocalUser({ email: '[email protected]', password: 'oldpw1234', role: 'user', status: 'active' });
const res = await agent.post(`/api/admin/users/${u.id}/password`).send({ password: 'newpw1234' });
expect(res.status).toBe(204);
expect(repo.verifyLocalPassword(u.id, 'oldpw1234')).toBe(false);
expect(repo.verifyLocalPassword(u.id, 'newpw1234')).toBe(true);
});
it('DELETE removes the user AND their on-disk folder', async () => {
const agent = await adminAgent();
const u = repo.createLocalUser({ email: '[email protected]', password: 'davepw12', role: 'user', status: 'active' });
const dir = ensureUserFolder(userFolderRoot, u.id);
writeFileSync(join(dir, 'notes', 'secret.md'), 'data');
expect(existsSync(dir)).toBe(true);
const res = await agent.delete(`/api/admin/users/${u.id}`);
expect(res.status).toBe(204);
expect(repo.getUserById(u.id)).toBeNull();
expect(existsSync(dir)).toBe(false); // folder gone, no orphan
});
it('DELETE refuses the local/system user (400) and preserves it + its folder', async () => {
const agent = await adminAgent();
const dir = ensureUserFolder(userFolderRoot, 'local');
const res = await agent.delete('/api/admin/users/local');
expect(res.status).toBe(400);
expect(repo.getUserById('local')).not.toBeNull();
expect(existsSync(dir)).toBe(true);
});
it('mutations require an admin session (401/403 without login)', async () => {
const res = await request(app).delete('/api/admin/users/whoever');
expect([401, 403]).toContain(res.status);
});
// ── self-service password change ──────────────────────────────
it('POST /api/auth/password changes own password with the correct current one', async () => {
const agent = await adminAgent(); // logged in as the local admin (pw adminpw12)
const res = await agent.post('/api/auth/password').send({ currentPassword: 'adminpw12', newPassword: 'brandnewpw1' });
expect(res.status).toBe(204);
expect(repo.verifyLocalPassword('local', 'brandnewpw1')).toBe(true);
// restore so later assumptions about the admin password hold
repo.setLocalPassword('local', 'adminpw12');
});
it('POST /api/auth/password rejects a wrong current password (403)', async () => {
const agent = await adminAgent();
const res = await agent.post('/api/auth/password').send({ currentPassword: 'WRONG', newPassword: 'whatever123' });
expect(res.status).toBe(403);
expect(repo.verifyLocalPassword('local', 'adminpw12')).toBe(true); // unchanged
});
it('POST /api/auth/password rejects a too-short new password (400)', async () => {
const agent = await adminAgent();
const res = await agent.post('/api/auth/password').send({ currentPassword: 'adminpw12', newPassword: 'short' });
expect(res.status).toBe(400);
});
it('POST /api/auth/password requires authentication (401/403)', async () => {
const res = await request(app).post('/api/auth/password').send({ currentPassword: 'x', newPassword: 'yyyyyyyy' });
expect([401, 403]).toContain(res.status);
});
});
+51 -1
View File
@@ -1,10 +1,16 @@
import { type Application, type Request, type Response, type RequestHandler } from 'express';
import type { Repository } from '../db/repository.js';
import { requireAdmin } from './auth.js';
import { deleteUserFolder } from '../user-folder/paths.js';
const passthrough: RequestHandler = (_req, _res, next) => next();
export function mountAdminApi(app: Application, repo: Repository, authActive = true): void {
export function mountAdminApi(
app: Application,
repo: Repository,
authActive = true,
userFolderRoot = './data/users',
): void {
const guard = authActive ? requireAdmin : passthrough;
app.get('/api/admin/users', guard, (_req: Request, res: Response) => {
@@ -12,10 +18,45 @@ export function mountAdminApi(app: Application, repo: Repository, authActive = t
const enriched = users.map(u => ({
...u,
orgs: repo.listUserGiteaOrgs(u.id),
hasLocalPassword: repo.hasLocalCredential(u.id),
}));
res.json(enriched);
});
// Create a local (email + password) account. Admin-created accounts are
// pre-approved (status=active). Rejects an email that already exists.
app.post('/api/admin/users', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { email, password, role } = (req.body ?? {}) as { email?: string; password?: string; role?: string };
if (typeof email !== 'string' || !email.trim() || typeof password !== 'string' || password.length < 8) {
res.status(400).json({ error: 'email and a password of at least 8 characters are required' });
return;
}
const wantRole = role === 'admin' ? 'admin' : 'user';
try {
const user = repo.createLocalUser({ email: email.trim(), password, role: wantRole, status: 'active' });
res.status(201).json(user);
} catch {
res.status(409).json({ error: 'a user with that email already exists' });
}
});
// Reset (or set) a user's local password. Invalidates their sessions so the
// new password must be used.
app.post('/api/admin/users/:id/password', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id } = req.params;
const { password } = (req.body ?? {}) as { password?: string };
if (typeof password !== 'string' || password.length < 8) {
res.status(400).json({ error: 'password must be at least 8 characters' });
return;
}
if (!repo.getUserById(id)) { res.status(404).json({ error: 'User not found' }); return; }
repo.setLocalPassword(id, password);
repo.deleteSessionsByUserId(id);
res.status(204).end();
});
app.patch('/api/admin/users/:id', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id } = req.params;
@@ -39,6 +80,12 @@ export function mountAdminApi(app: Application, repo: Repository, authActive = t
app.delete('/api/admin/users/:id', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id } = req.params;
// The shared `local` system/admin user is the no-auth fallback owner and
// owns all single-user-mode data — never deletable.
if (id === 'local') {
res.status(400).json({ error: 'the local/system user cannot be deleted' });
return;
}
const user = repo.getUserById(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
@@ -46,6 +93,9 @@ export function mountAdminApi(app: Application, repo: Repository, authActive = t
}
repo.deleteSessionsByUserId(id);
repo.deleteUser(id);
// Remove the on-disk user folder too — the DB cascade only handles rows.
// Without this the user's scripts/macros/recordings/notes orphaned on disk.
deleteUserFolder(userFolderRoot, id);
res.status(204).end();
});
}
+27
View File
@@ -325,6 +325,33 @@
</a>
<!-- GITEA_BUTTON_END -->
<!-- LOCAL_FORM_START -->
<!-- LOCAL_DIVIDER_START -->
<div class="divider">または</div>
<!-- LOCAL_DIVIDER_END -->
<form method="post" action="/auth/local" style="display:flex;flex-direction:column;gap:10px;margin-bottom:8px;">
<input type="email" name="email" placeholder="メールアドレス" required autocomplete="username"
style="padding:11px 12px;border:1px solid #d0d5dd;border-radius:8px;font-size:14px;width:100%;box-sizing:border-box;">
<input type="password" name="password" placeholder="パスワード" required autocomplete="current-password"
style="padding:11px 12px;border:1px solid #d0d5dd;border-radius:8px;font-size:14px;width:100%;box-sizing:border-box;">
<button type="submit" class="oauth-button" style="justify-content:center;background:#111827;color:#fff;border:none;cursor:pointer;">
メールアドレスでログイン
</button>
</form>
<!-- LOCAL_SIGNUP_START -->
<div class="divider">アカウントが無い場合</div>
<form method="post" action="/auth/local/signup" style="display:flex;flex-direction:column;gap:10px;">
<input type="email" name="email" placeholder="メールアドレス" required autocomplete="username"
style="padding:11px 12px;border:1px solid #d0d5dd;border-radius:8px;font-size:14px;width:100%;box-sizing:border-box;">
<input type="password" name="password" placeholder="パスワード(8文字以上)" minlength="8" required autocomplete="new-password"
style="padding:11px 12px;border:1px solid #d0d5dd;border-radius:8px;font-size:14px;width:100%;box-sizing:border-box;">
<button type="submit" class="oauth-button" style="justify-content:center;background:#fff;color:#111827;border:1px solid #d0d5dd;cursor:pointer;">
新規登録(管理者の承認後に利用可)
</button>
</form>
<!-- LOCAL_SIGNUP_END -->
<!-- LOCAL_FORM_END -->
<p class="footer-note">
ログインすることで、利用規約とプライバシーポリシーに<br>同意したものとみなされます。
</p>
+137
View File
@@ -0,0 +1,137 @@
/**
* Local-auth HTTP routes + helpers: login, self-signup, login-page rendering,
* and the primary=local provider gating.
*
* See docs/superpowers/plans/2026-06-09-local-auth.md.
*/
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import express, { type Express } from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { runMigrations } from '../db/migrate.js';
import { setupAuth, isLocalEnabled, isProviderActive } from './auth.js';
import type { AuthConfig } from '../config.js';
function mkAuthConfig(over: Partial<AuthConfig> = {}): AuthConfig {
return {
sessionSecret: 'test-secret',
sessionMaxAge: 3600_000,
secureCookie: false,
adminEmails: [],
providers: {},
local: { enabled: true, allowSignup: true },
...over,
};
}
function mkApp(repo: Repository, authConfig: AuthConfig): Express {
const app = express();
const auth = setupAuth(repo, authConfig);
app.use(auth.sessionMiddleware);
app.use(auth.passportInit);
app.use(auth.passportSession);
app.use('/auth', auth.authRouter);
app.get('/', (_req, res) => res.send('home'));
return app;
}
describe('isLocalEnabled', () => {
it('reflects auth.local.enabled', () => {
expect(isLocalEnabled(mkAuthConfig({ local: { enabled: true } }))).toBe(true);
expect(isLocalEnabled(mkAuthConfig({ local: { enabled: false } }))).toBe(false);
expect(isLocalEnabled(mkAuthConfig({ local: undefined }))).toBe(false);
});
});
describe('isProviderActive with primary=local', () => {
it('turns OAuth providers OFF when primary is local', () => {
const c = mkAuthConfig({
primaryProvider: 'local',
providers: {
google: { clientId: 'g', clientSecret: 's', callbackUrl: 'http://x/cb' },
},
});
expect(isProviderActive(c, 'google')).toBe(false);
});
});
describe('local auth routes', () => {
let tempDir = '';
let repo: Repository;
let app: Express;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-localroutes-'));
repo = new Repository(join(tempDir, 'orchestrator.db'));
runMigrations(repo.getDb());
app = mkApp(repo, mkAuthConfig());
});
afterEach(() => {
repo.close();
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
});
it('GET /auth/login renders the local form when enabled', async () => {
const res = await request(app).get('/auth/login');
expect(res.status).toBe(200);
expect(res.text).toContain('action="/auth/local"');
expect(res.text).toContain('action="/auth/local/signup"'); // signup on
});
it('POST /auth/local with valid active creds redirects home', async () => {
repo.createLocalUser({ email: '[email protected]', password: 'pw12345678', role: 'user', status: 'active' });
const res = await request(app).post('/auth/local').type('form').send({ email: '[email protected]', password: 'pw12345678' });
expect(res.status).toBe(302);
expect(res.headers['location']).toBe('/');
});
it('POST /auth/local with wrong password redirects to login error', async () => {
repo.createLocalUser({ email: '[email protected]', password: 'rightpassword', role: 'user', status: 'active' });
const res = await request(app).post('/auth/local').type('form').send({ email: '[email protected]', password: 'nope' });
expect(res.status).toBe(302);
expect(res.headers['location']).toContain('/auth/login?error=');
});
it('POST /auth/local for a pending user redirects to the pending page', async () => {
repo.createLocalUser({ email: '[email protected]', password: 'pw12345678', role: 'user', status: 'pending' });
const res = await request(app).post('/auth/local').type('form').send({ email: '[email protected]', password: 'pw12345678' });
expect(res.status).toBe(302);
expect(res.headers['location']).toBe('/auth/pending');
});
it('POST /auth/local/signup creates a pending user', async () => {
const res = await request(app).post('/auth/local/signup').type('form').send({ email: '[email protected]', password: 'pw12345678' });
expect(res.status).toBe(302);
expect(res.headers['location']).toBe('/auth/pending');
const u = repo.getUserByEmail('[email protected]');
expect(u?.status).toBe('pending');
expect(repo.verifyLocalPassword(u!.id, 'pw12345678')).toBe(true);
});
it('POST /auth/local/signup rejects a short password', async () => {
const res = await request(app).post('/auth/local/signup').type('form').send({ email: '[email protected]', password: 'abc' });
expect(res.status).toBe(302);
expect(res.headers['location']).toContain('error=weak');
expect(repo.getUserByEmail('[email protected]')).toBeNull();
});
it('POST /auth/local/signup does not disclose an existing email (generic error, no overwrite)', async () => {
repo.createLocalUser({ email: '[email protected]', password: 'originalpw', role: 'user', status: 'active' });
const res = await request(app).post('/auth/local/signup').type('form').send({ email: '[email protected]', password: 'attackerpw' });
expect(res.status).toBe(302);
expect(res.headers['location']).toContain('error=signup');
// original password unchanged (no takeover)
const u = repo.getUserByEmail('[email protected]')!;
expect(repo.verifyLocalPassword(u.id, 'originalpw')).toBe(true);
expect(repo.verifyLocalPassword(u.id, 'attackerpw')).toBe(false);
});
it('signup route is absent when allowSignup is off', async () => {
const app2 = mkApp(repo, mkAuthConfig({ local: { enabled: true, allowSignup: false } }));
const res = await request(app2).post('/auth/local/signup').type('form').send({ email: '[email protected]', password: 'pw12345678' });
expect(res.status).toBe(404);
});
});
+123 -12
View File
@@ -10,7 +10,7 @@ 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 type { Repository, User } from '../db/repository.js';
import { logger } from '../logger.js';
import { randomBytes } from 'crypto';
@@ -71,9 +71,48 @@ export function isProviderActive(authConfig: AuthConfig, kind: 'google' | 'gitea
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;
}
/**
* 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 状態に応じて
@@ -84,27 +123,32 @@ function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAU
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 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 === 'gitea' && giteaConfigured) ||
(authConfig.primaryProvider === 'local' && localEnabled)
? authConfig.primaryProvider
: undefined;
// Decide which buttons to show
// Decide which login options to show
let showGoogle: boolean;
let showGitea: boolean;
let showLocal: boolean;
if (primary === 'google') {
showGoogle = googleConfigured;
showGitea = false;
showGoogle = googleConfigured; showGitea = false; showLocal = false;
} else if (primary === 'gitea') {
showGoogle = false;
showGitea = giteaConfigured;
showGoogle = false; showGitea = giteaConfigured; showLocal = false;
} else if (primary === 'local') {
showGoogle = false; showGitea = false; showLocal = localEnabled;
} else {
// No primary specified: show every configured provider
// No primary specified: show every configured/enabled option
showGoogle = googleConfigured;
showGitea = giteaConfigured;
showLocal = localEnabled;
}
const stripBlock = (html: string, startMarker: string, endMarker: string): string => {
@@ -115,8 +159,15 @@ function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAU
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
// 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
@@ -465,6 +516,7 @@ function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
// ── Auth Router ───────────────────────────────────────────────────────────────
function createAuthRouter(
repo: Repository,
authConfig: AuthConfig,
getBranding?: () => LoginBranding,
): Router {
@@ -531,6 +583,65 @@ function createAuthRouter(
);
}
// Local accounts (email + password)
if (isLocalEnabled(authConfig)) {
const parseBody = [express.urlencoded({ extended: false }), express.json()];
const toExpressUser = (u: User): Express.User => ({
...u,
orgIds: [],
defaultVisibility: u.defaultVisibility ?? 'private',
defaultVisibilityOrgId: u.defaultVisibilityOrgId ?? null,
});
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 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)) {
res.redirect('/auth/login?error=credentials');
return;
}
if (user.status === 'disabled') { res.redirect('/auth/login?error=disabled'); return; }
req.login(toExpressUser(user), (err) => {
if (err) { next(err); return; }
res.redirect(user.status === 'active' ? '/' : '/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(toExpressUser(user), (err) => {
if (err) { next(err); return; }
res.redirect('/auth/pending');
});
});
}
}
// ログアウト
router.get('/logout', (req, res, next) => {
req.logout((err) => {
@@ -624,7 +735,7 @@ export function setupAuth(
registerGiteaStrategy(repo, authConfig);
// 認証ルーター
const authRouter = createAuthRouter(authConfig, getBranding);
const authRouter = createAuthRouter(repo, authConfig, getBranding);
const passportInit = passport.initialize();
const passportSession = passport.session();
+22 -4
View File
@@ -22,7 +22,7 @@ import { setSessionManager } from '../engine/tools/browser.js';
import { setUserFolderToolDeps } from '../engine/tools/user-folder.js';
import { setSkillToolDeps } from '../engine/tools/skills.js';
import { setAppDocsDeps } from '../engine/tools/app-docs.js';
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured } from './auth.js';
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler } from './auth.js';
import { canUserSeeTask } from './visibility.js';
import { mountAdminApi } from './admin-api.js';
import { createAdminGatewayApi } from './admin-gateway-api.js';
@@ -238,14 +238,20 @@ export function createCoreServer(opts: CoreServerOptions): {
const _hasAnyField = (p?: { clientId?: string; clientSecret?: string; callbackUrl?: string; baseUrl?: string }) =>
!!(p?.clientId || p?.clientSecret || p?.callbackUrl || p?.baseUrl);
const authIntended = _hasAnyField(_authProviders?.google) || _hasAnyField(_authProviders?.gitea);
if (authIntended && !authUsable) {
// Local email+password is a first-class auth mode: when enabled, auth is
// active even without any OAuth provider.
const localEnabled = !!opts.authConfig && isLocalEnabled(opts.authConfig);
// Fail closed on a partial OAuth config ONLY when it would otherwise drop to
// no-auth. If local auth is on, auth is already active (no fail-open), so a
// half-configured OAuth provider just stays inactive rather than aborting boot.
if (authIntended && !authUsable && !localEnabled) {
throw new Error(
'[auth] auth is partially configured: a provider has a client_id but is missing ' +
'client_secret / callback_url (Gitea also needs base_url). Refusing to start in an ' +
'insecure no-auth state — complete the provider config or remove it from config.yaml.',
);
}
const authActive = authUsable;
const authActive = authUsable || localEnabled;
if (!authActive) {
// No-auth single-user mode: per-user rows are owned by the synthetic
// 'local' id, and several tables FK to users(id) (ssh_user_deks,
@@ -256,6 +262,15 @@ export function createCoreServer(opts: CoreServerOptions): {
let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined;
if (authActive) {
// Idempotently seed the shared `local` system admin (id='local', the same
// owner the no-auth path uses) so an existing single-user / no-auth
// deployment gains a login mid-stream and keeps its `local`-owned data.
const bootstrap = opts.authConfig?.local?.bootstrapAdmin;
if (localEnabled && bootstrap?.email && bootstrap?.password) {
repo.upsertLocalSystemAdmin({ email: bootstrap.email, password: bootstrap.password });
logger.info(`[auth] seeded local system admin id=local email=${bootstrap.email}`);
}
const auth = setupAuth(
repo,
opts.authConfig!,
@@ -280,6 +295,9 @@ export function createCoreServer(opts: CoreServerOptions): {
res.json(req.user);
});
// Self-service password change for local accounts (see auth.ts).
app.post('/api/auth/password', requireAuth, express.json(), buildChangePasswordHandler(repo));
// Protect all API routes (except /api/version and /health)
app.use('/api/local', requireAuth);
app.use('/api/repos', requireAuth);
@@ -304,7 +322,7 @@ export function createCoreServer(opts: CoreServerOptions): {
// Admin user management API (always mounted; protected by requireAdmin when auth is active)
app.use('/api/admin', express.json());
mountAdminApi(app, repo, authActive);
mountAdminApi(app, repo, authActive, loadConfig()?.userFolderRoot ?? './data/users');
// AAO Gateway Phase 2a: admin-only CRUD over gateway_virtual_keys.
// Enabled regardless of gateway.enabled so an admin can prep keys