sync: update from private repo (88dd58b)
Some checks failed
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

View File

@ -315,7 +315,7 @@ tools:
# secure_cookie: false # HTTPS 環境では true # secure_cookie: false # HTTPS 環境では true
# admin_emails: # admin_emails:
# - "admin@example.com" # - "admin@example.com"
# # primary_provider: gitea # 'google' | 'gitea'。両方有効時に明示 # # primary_provider: gitea # 'google' | 'gitea' | 'local'。複数有効時に明示
# providers: # providers:
# google: # google:
# client_id: "" # client_id: ""
@ -326,6 +326,15 @@ tools:
# client_secret: "" # client_secret: ""
# base_url: "https://gitea.example.com" # base_url: "https://gitea.example.com"
# callback_url: "http://localhost:3000/auth/gitea/callback" # callback_url: "http://localhost:3000/auth/gitea/callback"
# # ローカルアカウント (email + password)。OAuth を立てずにパスワード保護したい時。
# # 既存の no-auth デプロイに後付け可: 起動時に bootstrap_admin を id='local' で冪等 seed し、
# # no-auth 時代に 'local' 所有で溜まったデータをそのまま admin が引き継ぐ。
# local:
# enabled: true
# allow_signup: false # true でセルフ登録 (status=pending → admin 承認)
# bootstrap_admin: # 初回 admin (id='local')。seed 後は Settings/CLI で変更
# email: "admin@example.com"
# password: "強いパスワードを設定" # 平文保存される。初回ログイン後に変更推奨
# ─── Branding (オプション) ──────────────────────────────────── # ─── Branding (オプション) ────────────────────────────────────
# config.yaml / data/branding/ は .gitignore 済みで git pull 影響なし。 # config.yaml / data/branding/ は .gitignore 済みで git pull 影響なし。

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: 'admin@x.com', 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: 'admin@x.com', 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: 'bob@x.com', password: 'bobpw1234' });
expect(res.status).toBe(201);
const u = repo.getUserByEmail('bob@x.com')!;
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: 'dup@x.com', password: 'pw123456' });
const res = await agent.post('/api/admin/users').send({ email: 'dup@x.com', password: 'other123' });
expect(res.status).toBe(409);
});
it('POST .../password resets the password', async () => {
const agent = await adminAgent();
const u = repo.createLocalUser({ email: 'carol@x.com', 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: 'dave@x.com', 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);
});
});

View File

@ -1,10 +1,16 @@
import { type Application, type Request, type Response, type RequestHandler } from 'express'; import { type Application, type Request, type Response, type RequestHandler } from 'express';
import type { Repository } from '../db/repository.js'; import type { Repository } from '../db/repository.js';
import { requireAdmin } from './auth.js'; import { requireAdmin } from './auth.js';
import { deleteUserFolder } from '../user-folder/paths.js';
const passthrough: RequestHandler = (_req, _res, next) => next(); 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; const guard = authActive ? requireAdmin : passthrough;
app.get('/api/admin/users', guard, (_req: Request, res: Response) => { 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 => ({ const enriched = users.map(u => ({
...u, ...u,
orgs: repo.listUserGiteaOrgs(u.id), orgs: repo.listUserGiteaOrgs(u.id),
hasLocalPassword: repo.hasLocalCredential(u.id),
})); }));
res.json(enriched); 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) => { app.patch('/api/admin/users/:id', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; } if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id } = req.params; 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) => { app.delete('/api/admin/users/:id', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; } if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id } = req.params; 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); const user = repo.getUserById(id);
if (!user) { if (!user) {
res.status(404).json({ error: 'User not found' }); 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.deleteSessionsByUserId(id);
repo.deleteUser(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(); res.status(204).end();
}); });
} }

View File

@ -325,6 +325,33 @@
</a> </a>
<!-- GITEA_BUTTON_END --> <!-- 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"> <p class="footer-note">
ログインすることで、利用規約とプライバシーポリシーに<br>同意したものとみなされます。 ログインすることで、利用規約とプライバシーポリシーに<br>同意したものとみなされます。
</p> </p>

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: 'a@x.com', password: 'pw12345678', role: 'user', status: 'active' });
const res = await request(app).post('/auth/local').type('form').send({ email: 'a@x.com', 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: 'b@x.com', password: 'rightpassword', role: 'user', status: 'active' });
const res = await request(app).post('/auth/local').type('form').send({ email: 'b@x.com', 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: 'c@x.com', password: 'pw12345678', role: 'user', status: 'pending' });
const res = await request(app).post('/auth/local').type('form').send({ email: 'c@x.com', 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: 'new@x.com', password: 'pw12345678' });
expect(res.status).toBe(302);
expect(res.headers['location']).toBe('/auth/pending');
const u = repo.getUserByEmail('new@x.com');
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: 'short@x.com', password: 'abc' });
expect(res.status).toBe(302);
expect(res.headers['location']).toContain('error=weak');
expect(repo.getUserByEmail('short@x.com')).toBeNull();
});
it('POST /auth/local/signup does not disclose an existing email (generic error, no overwrite)', async () => {
repo.createLocalUser({ email: 'dup@x.com', password: 'originalpw', role: 'user', status: 'active' });
const res = await request(app).post('/auth/local/signup').type('form').send({ email: 'dup@x.com', password: 'attackerpw' });
expect(res.status).toBe(302);
expect(res.headers['location']).toContain('error=signup');
// original password unchanged (no takeover)
const u = repo.getUserByEmail('dup@x.com')!;
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: 'x@x.com', password: 'pw12345678' });
expect(res.status).toBe(404);
});
});

View File

@ -10,7 +10,7 @@ import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Strategy as OAuth2Strategy } from 'passport-oauth2'; import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import type { Database } from 'better-sqlite3'; import type { Database } from 'better-sqlite3';
import type { AuthConfig, AuthProviderConfig } from '../config.js'; 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 { logger } from '../logger.js';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
@ -71,9 +71,48 @@ export function isProviderActive(authConfig: AuthConfig, kind: 'google' | 'gitea
const primary = authConfig.primaryProvider; const primary = authConfig.primaryProvider;
if (primary === 'google' && isProviderConfigured(authConfig.providers.google, 'google')) return kind === 'google'; if (primary === 'google' && isProviderConfigured(authConfig.providers.google, 'google')) return kind === 'google';
if (primary === 'gitea' && isProviderConfigured(authConfig.providers.gitea, 'gitea')) return kind === 'gitea'; 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; 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 * auth-login.html
* primary_provider configured * 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 raw = readFileSync(path.join(__authDirname, 'auth-login.html'), 'utf-8');
const googleConfigured = isProviderConfigured(authConfig.providers.google, 'google'); const googleConfigured = isProviderConfigured(authConfig.providers.google, 'google');
const giteaConfigured = isProviderConfigured(authConfig.providers.gitea, 'gitea'); const giteaConfigured = isProviderConfigured(authConfig.providers.gitea, 'gitea');
// Ignore a primaryProvider that points to an unconfigured provider — otherwise const localEnabled = isLocalEnabled(authConfig);
// it would hide the only working login button and lock the operator out. 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 = const primary =
(authConfig.primaryProvider === 'google' && googleConfigured) || (authConfig.primaryProvider === 'google' && googleConfigured) ||
(authConfig.primaryProvider === 'gitea' && giteaConfigured) (authConfig.primaryProvider === 'gitea' && giteaConfigured) ||
(authConfig.primaryProvider === 'local' && localEnabled)
? authConfig.primaryProvider ? authConfig.primaryProvider
: undefined; : undefined;
// Decide which buttons to show // Decide which login options to show
let showGoogle: boolean; let showGoogle: boolean;
let showGitea: boolean; let showGitea: boolean;
let showLocal: boolean;
if (primary === 'google') { if (primary === 'google') {
showGoogle = googleConfigured; showGoogle = googleConfigured; showGitea = false; showLocal = false;
showGitea = false;
} else if (primary === 'gitea') { } else if (primary === 'gitea') {
showGoogle = false; showGoogle = false; showGitea = giteaConfigured; showLocal = false;
showGitea = giteaConfigured; } else if (primary === 'local') {
showGoogle = false; showGitea = false; showLocal = localEnabled;
} else { } else {
// No primary specified: show every configured provider // No primary specified: show every configured/enabled option
showGoogle = googleConfigured; showGoogle = googleConfigured;
showGitea = giteaConfigured; showGitea = giteaConfigured;
showLocal = localEnabled;
} }
const stripBlock = (html: string, startMarker: string, endMarker: string): string => { const stripBlock = (html: string, startMarker: string, endMarker: string): string => {
@ -115,8 +159,15 @@ function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAU
let out = raw; let out = raw;
if (!showGoogle) out = stripBlock(out, 'GOOGLE_BUTTON_START', 'GOOGLE_BUTTON_END'); if (!showGoogle) out = stripBlock(out, 'GOOGLE_BUTTON_START', 'GOOGLE_BUTTON_END');
if (!showGitea) out = stripBlock(out, 'GITEA_BUTTON_START', 'GITEA_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'); 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 // Branding placeholders
out = out out = out
@ -465,6 +516,7 @@ function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
// ── Auth Router ─────────────────────────────────────────────────────────────── // ── Auth Router ───────────────────────────────────────────────────────────────
function createAuthRouter( function createAuthRouter(
repo: Repository,
authConfig: AuthConfig, authConfig: AuthConfig,
getBranding?: () => LoginBranding, getBranding?: () => LoginBranding,
): Router { ): 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) => { router.get('/logout', (req, res, next) => {
req.logout((err) => { req.logout((err) => {
@ -624,7 +735,7 @@ export function setupAuth(
registerGiteaStrategy(repo, authConfig); registerGiteaStrategy(repo, authConfig);
// 認証ルーター // 認証ルーター
const authRouter = createAuthRouter(authConfig, getBranding); const authRouter = createAuthRouter(repo, authConfig, getBranding);
const passportInit = passport.initialize(); const passportInit = passport.initialize();
const passportSession = passport.session(); const passportSession = passport.session();

View File

@ -22,7 +22,7 @@ import { setSessionManager } from '../engine/tools/browser.js';
import { setUserFolderToolDeps } from '../engine/tools/user-folder.js'; import { setUserFolderToolDeps } from '../engine/tools/user-folder.js';
import { setSkillToolDeps } from '../engine/tools/skills.js'; import { setSkillToolDeps } from '../engine/tools/skills.js';
import { setAppDocsDeps } from '../engine/tools/app-docs.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 { canUserSeeTask } from './visibility.js';
import { mountAdminApi } from './admin-api.js'; import { mountAdminApi } from './admin-api.js';
import { createAdminGatewayApi } from './admin-gateway-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 }) => const _hasAnyField = (p?: { clientId?: string; clientSecret?: string; callbackUrl?: string; baseUrl?: string }) =>
!!(p?.clientId || p?.clientSecret || p?.callbackUrl || p?.baseUrl); !!(p?.clientId || p?.clientSecret || p?.callbackUrl || p?.baseUrl);
const authIntended = _hasAnyField(_authProviders?.google) || _hasAnyField(_authProviders?.gitea); 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( throw new Error(
'[auth] auth is partially configured: a provider has a client_id but is missing ' + '[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 ' + '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.', 'insecure no-auth state — complete the provider config or remove it from config.yaml.',
); );
} }
const authActive = authUsable; const authActive = authUsable || localEnabled;
if (!authActive) { if (!authActive) {
// No-auth single-user mode: per-user rows are owned by the synthetic // 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, // '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; let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined;
if (authActive) { 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( const auth = setupAuth(
repo, repo,
opts.authConfig!, opts.authConfig!,
@ -280,6 +295,9 @@ export function createCoreServer(opts: CoreServerOptions): {
res.json(req.user); 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) // Protect all API routes (except /api/version and /health)
app.use('/api/local', requireAuth); app.use('/api/local', requireAuth);
app.use('/api/repos', 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) // Admin user management API (always mounted; protected by requireAdmin when auth is active)
app.use('/api/admin', express.json()); 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. // AAO Gateway Phase 2a: admin-only CRUD over gateway_virtual_keys.
// Enabled regardless of gateway.enabled so an admin can prep keys // Enabled regardless of gateway.enabled so an admin can prep keys

View File

@ -309,7 +309,24 @@ export interface AuthProviderConfig {
baseUrl?: string; // Gitea 用 baseUrl?: string; // Gitea 用
} }
export type PrimaryAuthProvider = 'google' | 'gitea'; export type PrimaryAuthProvider = 'google' | 'gitea' | 'local';
/**
* Local-account auth (email + password). A third option between OAuth-only and
* open no-auth. See docs/superpowers/plans/2026-06-09-local-auth.md.
*/
export interface LocalAuthConfig {
/** Turn local login on. When true, authActive becomes true even without OAuth. */
enabled?: boolean;
/** Allow self-signup (creates a pending user an admin must approve). */
allowSignup?: boolean;
/**
* Seed (idempotently, at startup) the shared `local` system admin under the
* same owner id the no-auth path uses, so existing single-user-mode data is
* carried over. Re-running updates the password.
*/
bootstrapAdmin?: { email: string; password: string };
}
export interface AuthConfig { export interface AuthConfig {
sessionSecret: string; sessionSecret: string;
@ -321,6 +338,8 @@ export interface AuthConfig {
google?: AuthProviderConfig; google?: AuthProviderConfig;
gitea?: AuthProviderConfig; gitea?: AuthProviderConfig;
}; };
/** Local email+password accounts. Undefined = disabled. */
local?: LocalAuthConfig;
} }
export interface BrandingConfig { export interface BrandingConfig {

View File

@ -546,5 +546,14 @@ function migratePushNotificationsTables(db: Database.Database): void {
v1_migrated INTEGER NOT NULL DEFAULT 0, v1_migrated INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- Local-auth credentials (email + password accounts). Idempotent add so
-- an existing no-auth / OAuth-only deployment gains local login on upgrade.
CREATE TABLE IF NOT EXISTS local_credentials (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`); `);
} }

View File

@ -0,0 +1,126 @@
/**
* Local-auth repository layer: password hashing, local account creation,
* the shared `local` system admin, and the user-deletion guards.
*
* See docs/superpowers/plans/2026-06-09-local-auth.md.
*/
import { afterEach, describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from './repository.js';
import { runMigrations } from './migrate.js';
describe('Repository local-auth', () => {
let tempDir = '';
let repo: Repository;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-localauth-'));
repo = new Repository(join(tempDir, 'orchestrator.db'));
runMigrations(repo.getDb());
});
afterEach(() => {
repo.close();
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
}
});
// ── password hashing ──────────────────────────────────────────
it('setLocalPassword + verifyLocalPassword round-trips', () => {
const u = repo.createUser({ email: 'a@x.com', name: 'A', role: 'user', status: 'active' });
repo.setLocalPassword(u.id, 'correct horse battery staple');
expect(repo.verifyLocalPassword(u.id, 'correct horse battery staple')).toBe(true);
expect(repo.verifyLocalPassword(u.id, 'wrong')).toBe(false);
});
it('verifyLocalPassword returns false for a user with no credential', () => {
const u = repo.createUser({ email: 'b@x.com', name: 'B', role: 'user', status: 'active' });
expect(repo.verifyLocalPassword(u.id, 'anything')).toBe(false);
});
it('setLocalPassword is idempotent-overwrite (re-set changes the password)', () => {
const u = repo.createUser({ email: 'c@x.com', name: 'C', role: 'user', status: 'active' });
repo.setLocalPassword(u.id, 'first');
repo.setLocalPassword(u.id, 'second');
expect(repo.verifyLocalPassword(u.id, 'first')).toBe(false);
expect(repo.verifyLocalPassword(u.id, 'second')).toBe(true);
});
it('stores a per-user random salt (two users, same password → different hash)', () => {
const u1 = repo.createUser({ email: 'd@x.com', name: 'D', role: 'user', status: 'active' });
const u2 = repo.createUser({ email: 'e@x.com', name: 'E', role: 'user', status: 'active' });
repo.setLocalPassword(u1.id, 'same');
repo.setLocalPassword(u2.id, 'same');
const db = repo.getDb();
const r1 = db.prepare('SELECT password_hash, salt FROM local_credentials WHERE user_id=?').get(u1.id) as { password_hash: string; salt: string };
const r2 = db.prepare('SELECT password_hash, salt FROM local_credentials WHERE user_id=?').get(u2.id) as { password_hash: string; salt: string };
expect(r1.salt).not.toBe(r2.salt);
expect(r1.password_hash).not.toBe(r2.password_hash);
});
// ── createLocalUser ───────────────────────────────────────────
it('createLocalUser creates a pending user with a local identity + password', () => {
const u = repo.createLocalUser({ email: 'new@x.com', password: 'pw12345', role: 'user', status: 'pending' });
expect(u.email).toBe('new@x.com');
expect(u.status).toBe('pending');
expect(repo.verifyLocalPassword(u.id, 'pw12345')).toBe(true);
const idn = repo.getDb().prepare("SELECT provider, provider_id FROM oauth_accounts WHERE user_id=? AND provider='local'").get(u.id) as { provider: string; provider_id: string };
expect(idn.provider).toBe('local');
expect(idn.provider_id).toBe('new@x.com');
});
it('createLocalUser REJECTS an email that already exists (no account takeover)', () => {
repo.createUser({ email: 'taken@x.com', name: 'T', role: 'user', status: 'active' });
expect(() => repo.createLocalUser({ email: 'taken@x.com', password: 'pw', role: 'user', status: 'pending' }))
.toThrow(/exist/i);
});
// ── upsertLocalSystemAdmin (shared 'local' identity) ──────────
it('upsertLocalSystemAdmin creates the shared id=local admin (active)', () => {
const u = repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'adminpw' });
expect(u.id).toBe('local');
expect(u.role).toBe('admin');
expect(u.status).toBe('active');
expect(repo.verifyLocalPassword('local', 'adminpw')).toBe(true);
});
it('upsertLocalSystemAdmin is idempotent and keeps id=local across re-seeds', () => {
repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'pw1' });
const again = repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'pw2' });
expect(again.id).toBe('local');
// password updated on re-seed
expect(repo.verifyLocalPassword('local', 'pw2')).toBe(true);
// exactly one users row with id=local
const cnt = repo.getDb().prepare("SELECT COUNT(*) c FROM users WHERE id='local'").get() as { c: number };
expect(cnt.c).toBe(1);
});
it('local-owned data survives because the admin IS the local owner (id=local)', () => {
// Simulate no-auth data owned by 'local', then seed the admin onto it.
repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'pw' });
expect(repo.getUserById('local')?.role).toBe('admin');
});
// ── deleteUser guards ─────────────────────────────────────────
it('deleteUser REFUSES to delete the local/system user', () => {
repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'pw' });
expect(() => repo.deleteUser('local')).toThrow(/local|system/i);
expect(repo.getUserById('local')).not.toBeNull();
});
it('deleteUser cascades local_credentials (FK ON DELETE CASCADE)', () => {
const u = repo.createLocalUser({ email: 'z@x.com', password: 'pw', role: 'user', status: 'active' });
expect(repo.verifyLocalPassword(u.id, 'pw')).toBe(true);
repo.deleteUser(u.id);
const row = repo.getDb().prepare('SELECT 1 FROM local_credentials WHERE user_id=?').get(u.id);
expect(row).toBeUndefined();
});
});

View File

@ -2,7 +2,7 @@ import Database from 'better-sqlite3';
import { readFileSync, rmSync, existsSync } from 'fs'; import { readFileSync, rmSync, existsSync } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { randomUUID } from 'crypto'; import { randomUUID, scryptSync, randomBytes, timingSafeEqual } from 'crypto';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { buildVisibilityWhere } from '../bridge/visibility.js'; import { buildVisibilityWhere } from '../bridge/visibility.js';
@ -572,6 +572,14 @@ export interface FindOrCreateByOAuthParams {
avatarUrl?: string; avatarUrl?: string;
} }
export interface CreateLocalUserParams {
email: string;
password: string;
role: 'admin' | 'user';
status: 'active' | 'pending' | 'disabled';
name?: string;
}
export interface GiteaOrgInput { export interface GiteaOrgInput {
orgId: string; orgId: string;
orgName: string; orgName: string;
@ -2477,6 +2485,99 @@ export class Repository {
.run({ now }); .run({ now });
} }
// ── Local auth (email + password) ─────────────────────────────────────
/** scrypt hash with a fresh per-user salt. Overwrites any existing credential. */
setLocalPassword(userId: string, plainPassword: string): void {
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(plainPassword, salt, 64).toString('hex');
const now = new Date().toISOString();
this.db
.prepare(
`INSERT INTO local_credentials (user_id, password_hash, salt, updated_at)
VALUES (@userId, @hash, @salt, @now)
ON CONFLICT(user_id) DO UPDATE SET password_hash=@hash, salt=@salt, updated_at=@now`,
)
.run({ userId, hash, salt, now });
}
/** Constant-time verify. False when the user has no local credential. */
verifyLocalPassword(userId: string, plainPassword: string): boolean {
const row = this.db
.prepare('SELECT password_hash, salt FROM local_credentials WHERE user_id = ?')
.get(userId) as { password_hash: string; salt: string } | undefined;
if (!row) return false;
const expected = Buffer.from(row.password_hash, 'hex');
const actual = scryptSync(plainPassword, row.salt, expected.length);
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
hasLocalCredential(userId: string): boolean {
return !!this.db.prepare('SELECT 1 FROM local_credentials WHERE user_id = ?').get(userId);
}
/**
* Create a brand-new local account (self-signup or admin-created). The email
* MUST be unused: attaching a password to an existing account would be an
* account-takeover vector, so we reject instead of linking. Linking a local
* credential to an existing OAuth account is a separate, authenticated action
* (not v1 signup).
*/
createLocalUser(params: CreateLocalUserParams): User {
if (this.getUserByEmail(params.email)) {
throw new Error(`createLocalUser: a user with email ${params.email} already exists`);
}
const user = this.createUser({
email: params.email,
name: params.name ?? params.email,
role: params.role,
status: params.status,
});
this.db
.prepare(
`INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at)
VALUES (@id, @userId, 'local', @providerId, @now)`,
)
.run({ id: uuidv4(), userId: user.id, providerId: params.email, now: new Date().toISOString() });
this.setLocalPassword(user.id, params.password);
return user;
}
/**
* Idempotently seed the shared system admin under the fixed id `local` the
* same owner the no-auth path synthesizes. This makes all pre-existing
* `local`-owned data belong to the logged-in admin once local auth is turned
* on, and lets an existing no-auth deployment gain a login mid-stream.
* Re-running updates the password and keeps role=admin/status=active.
*/
upsertLocalSystemAdmin(params: { email: string; password: string; name?: string }): User {
const LOCAL_ID = 'local';
const now = new Date().toISOString();
const existing = this.getUserById(LOCAL_ID);
if (!existing) {
this.db
.prepare(
`INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at)
VALUES (@id, @email, @name, NULL, 'admin', 'active', @now, @now)`,
)
.run({ id: LOCAL_ID, email: params.email, name: params.name ?? 'Local Admin', now });
} else {
this.db
.prepare(`UPDATE users SET email=@email, role='admin', status='active', updated_at=@now WHERE id=@id`)
.run({ id: LOCAL_ID, email: params.email, now });
}
this.db
.prepare(
`INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at)
VALUES (@id, @userId, 'local', @providerId, @now)`,
)
.run({ id: uuidv4(), userId: LOCAL_ID, providerId: params.email, now });
this.setLocalPassword(LOCAL_ID, params.password);
const user = this.getUserById(LOCAL_ID);
if (!user) throw new Error('upsertLocalSystemAdmin: failed to retrieve local admin');
return user;
}
getUserById(id: string): User | null { getUserById(id: string): User | null {
const row = this.db const row = this.db
.prepare('SELECT * FROM users WHERE id = ?') .prepare('SELECT * FROM users WHERE id = ?')
@ -2609,6 +2710,12 @@ export class Repository {
} }
deleteUser(id: string): void { deleteUser(id: string): void {
// Never delete the shared `local` system/admin user: it is the no-auth
// fallback owner and owns all single-user-mode data. Deleting it would
// break no-auth mode and orphan every `local`-owned task/job/folder.
if (id === 'local') {
throw new Error('cannot delete the local/system user');
}
this.db.prepare('DELETE FROM users WHERE id = ?').run(id); this.db.prepare('DELETE FROM users WHERE id = ?').run(id);
} }

View File

@ -163,6 +163,16 @@ CREATE TABLE IF NOT EXISTS oauth_accounts (
UNIQUE(provider, provider_id) UNIQUE(provider, provider_id)
); );
-- Auth: local-account credentials (email + password). One row per user that
-- has a local password; OAuth-only users have none. scrypt hash + per-user
-- salt. Cascades with the user so deletion leaves no orphaned credential.
CREATE TABLE IF NOT EXISTS local_credentials (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Auth: sessions (express-session) -- Auth: sessions (express-session)
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY, sid TEXT PRIMARY KEY,

View File

@ -0,0 +1,45 @@
/**
* deleteUserFolder removes a user's filesystem folder on account deletion.
*
* The DB cascade-deletes owned rows, but the user folder
* ({root}/{ownerId}/...) is on disk and was previously orphaned on delete.
* See docs/superpowers/plans/2026-06-09-local-auth.md.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { deleteUserFolder, ensureUserFolder } from './paths.js';
describe('deleteUserFolder', () => {
let root = '';
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'maestro-uf-')); });
afterEach(() => { if (root) { rmSync(root, { recursive: true, force: true }); root = ''; } });
it('removes the entire user folder', () => {
const dir = ensureUserFolder(root, 'alice');
writeFileSync(join(dir, 'notes', 'x.md'), 'hi');
expect(existsSync(dir)).toBe(true);
deleteUserFolder(root, 'alice');
expect(existsSync(dir)).toBe(false);
});
it('NEVER removes the local/system folder (shared no-auth owner)', () => {
const dir = ensureUserFolder(root, 'local');
deleteUserFolder(root, 'local');
expect(existsSync(dir)).toBe(true); // preserved
});
it('is a no-op when the folder does not exist', () => {
expect(() => deleteUserFolder(root, 'ghost')).not.toThrow();
});
it('refuses path-traversal ids (no escape outside root)', () => {
// userRoot rejects these; deleteUserFolder must not delete anything outside.
const sibling = mkdtempSync(join(tmpdir(), 'maestro-sibling-'));
writeFileSync(join(sibling, 'keep.txt'), 'keep');
expect(() => deleteUserFolder(root, '../../etc')).not.toThrow();
expect(existsSync(join(sibling, 'keep.txt'))).toBe(true);
rmSync(sibling, { recursive: true, force: true });
});
});

View File

@ -1,6 +1,9 @@
import { mkdirSync, chmodSync, existsSync, readFileSync, writeFileSync, unlinkSync, statSync, openSync, readSync, closeSync } from 'fs'; import { mkdirSync, chmodSync, existsSync, readFileSync, writeFileSync, unlinkSync, statSync, openSync, readSync, closeSync, rmSync } from 'fs';
import { resolve, join, relative, isAbsolute } from 'path'; import { resolve, join, relative, isAbsolute } from 'path';
/** The shared no-auth / local-auth system owner. Its folder is never deleted. */
export const LOCAL_SYSTEM_OWNER_ID = 'local';
const USER_AGENTS_MAX_BYTES = 64 * 1024; const USER_AGENTS_MAX_BYTES = 64 * 1024;
export const USER_SUBDIRS = ['scripts', 'browser-macros', 'templates', 'recordings', 'trash', 'memory', 'pets', 'notes'] as const; export const USER_SUBDIRS = ['scripts', 'browser-macros', 'templates', 'recordings', 'trash', 'memory', 'pets', 'notes'] as const;
@ -29,6 +32,29 @@ export function ensureUserFolder(rootDir: string, ownerId: string): string {
return root; return root;
} }
/**
* Recursively remove a user's entire folder ({root}/{ownerId}). Called when an
* account is deleted so its scripts/macros/recordings/notes/memory/pets don't
* orphan on disk (the DB cascade-deletes rows but not files).
*
* Guards:
* - the `local` system owner is NEVER removed (shared no-auth / local-auth
* admin; deleting it would wipe the single-user deployment's data)
* - an invalid / traversal ownerId resolves to nothing and is a silent no-op
* (userRoot throws on '/', '\\', '\0', absolute paths)
* - missing folder is a no-op (rmSync force)
*/
export function deleteUserFolder(rootDir: string, ownerId: string): void {
if (ownerId === LOCAL_SYSTEM_OWNER_ID) return;
let root: string;
try {
root = userRoot(rootDir, ownerId);
} catch {
return; // invalid ownerId — never delete anything outside a valid user root
}
rmSync(root, { recursive: true, force: true });
}
export function resolveUserSubdir( export function resolveUserSubdir(
rootDir: string, rootDir: string,
ownerId: string, ownerId: string,

View File

@ -0,0 +1,186 @@
import { useState, type FormEvent } from 'react';
/**
* Local-account dialogs:
* - CreateLocalUserDialog admin creates an email+password account (active)
* - ResetPasswordDialog admin resets another user's local password
* - ChangePasswordDialog the signed-in user changes their own password
*
* Self-contained (own fetch + state); callers pass close/success callbacks.
* See docs/superpowers/plans/2026-06-09-local-auth.md.
*/
const overlay = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4';
const panel = 'w-full max-w-sm bg-canvas border border-hairline rounded-lg shadow-xl p-5';
const inputCls = 'w-full h-9 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-canvas';
const labelCls = 'block text-2xs font-medium text-slate-500 mb-1';
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return <div className="mb-3"><span className={labelCls}>{label}</span>{children}</div>;
}
function ErrorNote({ msg }: { msg: string | null }) {
if (!msg) return null;
return <p className="text-2xs text-red-600 bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 rounded px-2 py-1 mb-3">{msg}</p>;
}
function Actions({ onClose, busy, submitLabel }: { onClose: () => void; busy: boolean; submitLabel: string }) {
return (
<div className="flex justify-end gap-2 mt-1">
<button type="button" onClick={onClose} className="px-3 h-8 rounded-md text-xs font-medium border border-hairline text-slate-700 hover:bg-surface">
</button>
<button type="submit" disabled={busy} className="px-3 h-8 rounded-md text-xs font-semibold bg-accent text-white disabled:opacity-50 hover:opacity-90">
{busy ? '...' : submitLabel}
</button>
</div>
);
}
export function CreateLocalUserDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<'user' | 'admin'>('user');
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setErr(null);
if (password.length < 8) { setErr('パスワードは8文字以上にしてください'); return; }
setBusy(true);
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password, role }),
});
if (res.status === 409) { setErr('そのメールアドレスは既に登録されています'); return; }
if (!res.ok) { setErr('作成に失敗しました'); return; }
onCreated();
onClose();
} finally {
setBusy(false);
}
};
return (
<div className={overlay} onClick={onClose}>
<form className={panel} onClick={e => e.stopPropagation()} onSubmit={submit}>
<h3 className="text-sm font-semibold text-slate-900 mb-4"></h3>
<ErrorNote msg={err} />
<Field label="メールアドレス">
<input type="email" required value={email} onChange={e => setEmail(e.target.value)} className={inputCls} autoComplete="off" />
</Field>
<Field label="初期パスワード8文字以上">
<input type="password" required minLength={8} value={password} onChange={e => setPassword(e.target.value)} className={inputCls} autoComplete="new-password" />
</Field>
<Field label="ロール">
<select value={role} onChange={e => setRole(e.target.value as 'user' | 'admin')} className={inputCls}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</Field>
<p className="text-2xs text-slate-500 mb-3">active</p>
<Actions onClose={onClose} busy={busy} submitLabel="作成" />
</form>
</div>
);
}
export function ResetPasswordDialog({ userId, email, onClose }: { userId: string; email: string; onClose: () => void }) {
const [password, setPassword] = useState('');
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setErr(null);
if (password.length < 8) { setErr('パスワードは8文字以上にしてください'); return; }
setBusy(true);
try {
const res = await fetch(`/api/admin/users/${userId}/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
if (!res.ok) { setErr('リセットに失敗しました'); return; }
setDone(true);
setTimeout(onClose, 900);
} finally {
setBusy(false);
}
};
return (
<div className={overlay} onClick={onClose}>
<form className={panel} onClick={e => e.stopPropagation()} onSubmit={submit}>
<h3 className="text-sm font-semibold text-slate-900 mb-1"></h3>
<p className="text-2xs text-slate-500 mb-4 truncate">{email}</p>
{done ? (
<p className="text-xs text-emerald-700 dark:text-emerald-300"></p>
) : (
<>
<ErrorNote msg={err} />
<Field label="新しいパスワード8文字以上">
<input type="password" required minLength={8} value={password} onChange={e => setPassword(e.target.value)} className={inputCls} autoComplete="new-password" />
</Field>
<Actions onClose={onClose} busy={busy} submitLabel="リセット" />
</>
)}
</form>
</div>
);
}
export function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setErr(null);
if (next.length < 8) { setErr('新しいパスワードは8文字以上にしてください'); return; }
setBusy(true);
try {
const res = await fetch('/api/auth/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: current, newPassword: next }),
});
if (res.status === 403) { setErr('現在のパスワードが正しくありません'); return; }
if (res.status === 400) { setErr('このアカウントにはローカルパスワードがありません'); return; }
if (!res.ok) { setErr('変更に失敗しました'); return; }
setDone(true);
setTimeout(onClose, 900);
} finally {
setBusy(false);
}
};
return (
<div className={overlay} onClick={onClose}>
<form className={panel} onClick={e => e.stopPropagation()} onSubmit={submit}>
<h3 className="text-sm font-semibold text-slate-900 mb-4"></h3>
{done ? (
<p className="text-xs text-emerald-700 dark:text-emerald-300"></p>
) : (
<>
<ErrorNote msg={err} />
<Field label="現在のパスワード">
<input type="password" required value={current} onChange={e => setCurrent(e.target.value)} className={inputCls} autoComplete="current-password" />
</Field>
<Field label="新しいパスワード8文字以上">
<input type="password" required minLength={8} value={next} onChange={e => setNext(e.target.value)} className={inputCls} autoComplete="new-password" />
</Field>
<Actions onClose={onClose} busy={busy} submitLabel="変更" />
</>
)}
</form>
</div>
);
}

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import type { PageId } from '../../lib/urlState'; import type { PageId } from '../../lib/urlState';
import type { AuthUser } from '../../App'; import type { AuthUser } from '../../App';
import { ThemeToggle } from './ThemeToggle'; import { ThemeToggle } from './ThemeToggle';
import { ChangePasswordDialog } from '../admin/LocalUserDialogs';
interface TopBarProps { interface TopBarProps {
currentPage: PageId; currentPage: PageId;
@ -74,6 +75,7 @@ export function TopBar({
}: TopBarProps) { }: TopBarProps) {
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled); const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length)); const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
const [showPwChange, setShowPwChange] = useState(false);
return ( return (
<div <div
@ -170,6 +172,13 @@ export function TopBar({
{user.name ?? user.email} {user.name ?? user.email}
</span> </span>
</div> </div>
<button
type="button"
onClick={() => setShowPwChange(true)}
className="px-2 py-1 rounded-md text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
>
</button>
<a <a
href="/auth/logout" href="/auth/logout"
className="px-2 py-1 rounded-md text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors" className="px-2 py-1 rounded-md text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
@ -178,6 +187,7 @@ export function TopBar({
</a> </a>
</div> </div>
)} )}
{showPwChange && <ChangePasswordDialog onClose={() => setShowPwChange(false)} />}
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { EmptyState } from '../components/shared/EmptyState'; import { EmptyState } from '../components/shared/EmptyState';
import { StatChip } from '../components/shared/StatChip'; import { StatChip } from '../components/shared/StatChip';
import { CreateLocalUserDialog, ResetPasswordDialog } from '../components/admin/LocalUserDialogs';
interface UserOrg { interface UserOrg {
orgId: string; orgId: string;
@ -125,6 +126,8 @@ export function UsersPage() {
const [filter, setFilter] = useState<UserFilter>('all'); const [filter, setFilter] = useState<UserFilter>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [resetUser, setResetUser] = useState<UserRecord | null>(null);
// Mobile single-column flow: list ↔ detail toggle. On sm+ both panes // Mobile single-column flow: list ↔ detail toggle. On sm+ both panes
// are visible side-by-side and this flag is ignored. // are visible side-by-side and this flag is ignored.
const [mobileShowDetail, setMobileShowDetail] = useState(false); const [mobileShowDetail, setMobileShowDetail] = useState(false);
@ -199,6 +202,7 @@ export function UsersPage() {
onSelect={handleSelect} onSelect={handleSelect}
onClearFilters={() => { setSearch(''); setFilter('all'); }} onClearFilters={() => { setSearch(''); setFilter('all'); }}
isLoading={isLoading} isLoading={isLoading}
onCreate={() => setShowCreate(true)}
/> />
</div> </div>
<div className={`${mobileShowDetail ? 'flex' : 'hidden sm:flex'} flex-1 min-w-0 bg-canvas flex-col`}> <div className={`${mobileShowDetail ? 'flex' : 'hidden sm:flex'} flex-1 min-w-0 bg-canvas flex-col`}>
@ -206,6 +210,7 @@ export function UsersPage() {
user={active} user={active}
onMobileBack={handleMobileBack} onMobileBack={handleMobileBack}
onPatch={(id, body) => patchMutation.mutate({ id, body })} onPatch={(id, body) => patchMutation.mutate({ id, body })}
onResetPassword={(u) => setResetUser(u)}
onDelete={(id) => { onDelete={(id) => {
if (confirm('本当にこのユーザーを削除しますか?')) { if (confirm('本当にこのユーザーを削除しますか?')) {
deleteMutation.mutate(id); deleteMutation.mutate(id);
@ -215,6 +220,20 @@ export function UsersPage() {
}} }}
/> />
</div> </div>
{showCreate && (
<CreateLocalUserDialog
onClose={() => setShowCreate(false)}
onCreated={() => qc.invalidateQueries({ queryKey: ['admin', 'users'] })}
/>
)}
{resetUser && (
<ResetPasswordDialog
userId={resetUser.id}
email={resetUser.email}
onClose={() => setResetUser(null)}
/>
)}
</div> </div>
); );
} }
@ -230,11 +249,12 @@ interface UserListPaneProps {
onSelect: (id: string) => void; onSelect: (id: string) => void;
onClearFilters: () => void; onClearFilters: () => void;
isLoading: boolean; isLoading: boolean;
onCreate: () => void;
} }
function UserListPane({ function UserListPane({
users, activeId, counts, filter, setFilter, search, setSearch, users, activeId, counts, filter, setFilter, search, setSearch,
onSelect, onClearFilters, isLoading, onSelect, onClearFilters, isLoading, onCreate,
}: UserListPaneProps) { }: UserListPaneProps) {
const hasFilters = !!search || filter !== 'all'; const hasFilters = !!search || filter !== 'all';
@ -255,6 +275,13 @@ function UserListPane({
<span><span className="font-semibold text-amber-600">{counts.pending}</span> </span> <span><span className="font-semibold text-amber-600">{counts.pending}</span> </span>
</> </>
)} )}
<button
type="button"
onClick={onCreate}
className="ml-auto px-2 h-6 rounded-md text-2xs font-semibold text-accent border border-accent/60 hover:bg-accent-soft transition-colors font-sans"
>
+
</button>
</div> </div>
<div className="flex flex-col gap-2 pb-3 border-b border-hairline"> <div className="flex flex-col gap-2 pb-3 border-b border-hairline">
@ -312,7 +339,7 @@ function UserListPane({
<EmptyState <EmptyState
compact compact
title="ユーザーがいません" title="ユーザーがいません"
hint="OAuth ログインを行うとここに表示されます。" hint="OAuth ログイン、または「+ ローカルユーザー」で作成するとここに表示されます。"
/> />
) )
)} )}
@ -365,11 +392,12 @@ interface UserDetailPaneProps {
user: UserRecord | null; user: UserRecord | null;
onPatch: (id: string, body: Record<string, unknown>) => void; onPatch: (id: string, body: Record<string, unknown>) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onResetPassword: (user: UserRecord) => void;
/** Mobile-only callback to return to the list pane. Hidden on sm+. */ /** Mobile-only callback to return to the list pane. Hidden on sm+. */
onMobileBack?: () => void; onMobileBack?: () => void;
} }
function UserDetailPane({ user, onPatch, onDelete, onMobileBack }: UserDetailPaneProps) { function UserDetailPane({ user, onPatch, onDelete, onResetPassword, onMobileBack }: UserDetailPaneProps) {
if (!user) { if (!user) {
return ( return (
<div className="h-full flex items-center justify-center p-10"> <div className="h-full flex items-center justify-center p-10">
@ -441,6 +469,14 @@ function UserDetailPane({ user, onPatch, onDelete, onMobileBack }: UserDetailPan
</button> </button>
)} )}
<button
type="button"
onClick={() => onResetPassword(user)}
className="px-3 h-7 rounded-md text-xs font-medium bg-canvas border border-hairline text-slate-700 hover:bg-surface transition-colors whitespace-nowrap"
>
</button>
{user.id !== 'local' && (
<button <button
type="button" type="button"
onClick={() => onDelete(user.id)} onClick={() => onDelete(user.id)}
@ -448,6 +484,7 @@ function UserDetailPane({ user, onPatch, onDelete, onMobileBack }: UserDetailPan
> >
</button> </button>
)}
</div> </div>
</div> </div>