This commit is contained in:
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+20
-1
@@ -309,7 +309,24 @@ export interface AuthProviderConfig {
|
||||
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 {
|
||||
sessionSecret: string;
|
||||
@@ -321,6 +338,8 @@ export interface AuthConfig {
|
||||
google?: AuthProviderConfig;
|
||||
gitea?: AuthProviderConfig;
|
||||
};
|
||||
/** Local email+password accounts. Undefined = disabled. */
|
||||
local?: LocalAuthConfig;
|
||||
}
|
||||
|
||||
export interface BrandingConfig {
|
||||
|
||||
@@ -546,5 +546,14 @@ function migratePushNotificationsTables(db: Database.Database): void {
|
||||
v1_migrated INTEGER NOT NULL DEFAULT 0,
|
||||
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'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -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: '[email protected]', 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: '[email protected]', 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: '[email protected]', 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: '[email protected]', name: 'D', role: 'user', status: 'active' });
|
||||
const u2 = repo.createUser({ email: '[email protected]', 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: '[email protected]', password: 'pw12345', role: 'user', status: 'pending' });
|
||||
expect(u.email).toBe('[email protected]');
|
||||
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('[email protected]');
|
||||
});
|
||||
|
||||
it('createLocalUser REJECTS an email that already exists (no account takeover)', () => {
|
||||
repo.createUser({ email: '[email protected]', name: 'T', role: 'user', status: 'active' });
|
||||
expect(() => repo.createLocalUser({ email: '[email protected]', 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: '[email protected]', 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: '[email protected]', password: 'pw1' });
|
||||
const again = repo.upsertLocalSystemAdmin({ email: '[email protected]', 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: '[email protected]', password: 'pw' });
|
||||
expect(repo.getUserById('local')?.role).toBe('admin');
|
||||
});
|
||||
|
||||
// ── deleteUser guards ─────────────────────────────────────────
|
||||
|
||||
it('deleteUser REFUSES to delete the local/system user', () => {
|
||||
repo.upsertLocalSystemAdmin({ email: '[email protected]', 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: '[email protected]', 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();
|
||||
});
|
||||
});
|
||||
+108
-1
@@ -2,7 +2,7 @@ import Database from 'better-sqlite3';
|
||||
import { readFileSync, rmSync, existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { randomUUID, scryptSync, randomBytes, timingSafeEqual } from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { logger } from '../logger.js';
|
||||
import { buildVisibilityWhere } from '../bridge/visibility.js';
|
||||
@@ -572,6 +572,14 @@ export interface FindOrCreateByOAuthParams {
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface CreateLocalUserParams {
|
||||
email: string;
|
||||
password: string;
|
||||
role: 'admin' | 'user';
|
||||
status: 'active' | 'pending' | 'disabled';
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface GiteaOrgInput {
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
@@ -2477,6 +2485,99 @@ export class Repository {
|
||||
.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 {
|
||||
const row = this.db
|
||||
.prepare('SELECT * FROM users WHERE id = ?')
|
||||
@@ -2609,6 +2710,12 @@ export class Repository {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,16 @@ CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||
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)
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
/** 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
rootDir: string,
|
||||
ownerId: string,
|
||||
|
||||
Reference in New Issue
Block a user