339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
import { afterEach, describe, it, expect, vi } from 'vitest';
|
|
import { mkdtempSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { Repository } from '../db/repository.js';
|
|
import { requireAuth, requireAdmin, fetchGiteaOrgsForUser, isProviderConfigured, isProviderActive, safeReturnTo, isTopLevelNavigation, consumeReturnTo } from './auth.js';
|
|
import type { AuthConfig } from '../config.js';
|
|
import type { Request, Response, NextFunction } from 'express';
|
|
|
|
function mockReqRes(overrides: Partial<Request> = {}) {
|
|
const req = {
|
|
isAuthenticated: () => false,
|
|
user: undefined,
|
|
originalUrl: '/api/test',
|
|
headers: { accept: 'application/json' },
|
|
...overrides,
|
|
} as unknown as Request;
|
|
const res = {
|
|
status: vi.fn().mockReturnThis(),
|
|
json: vi.fn().mockReturnThis(),
|
|
redirect: vi.fn().mockReturnThis(),
|
|
} as unknown as Response;
|
|
const next = vi.fn() as NextFunction;
|
|
return { req, res, next };
|
|
}
|
|
|
|
describe('requireAuth', () => {
|
|
it('calls next() for authenticated active user', () => {
|
|
const { req, res, next } = mockReqRes({
|
|
isAuthenticated: () => true,
|
|
user: { id: '1', role: 'user', status: 'active' },
|
|
} as Partial<Request>);
|
|
requireAuth(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 401 for unauthenticated API request', () => {
|
|
const { req, res, next } = mockReqRes();
|
|
requireAuth(req, res, next);
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('redirects to /auth/login for unauthenticated non-API request', () => {
|
|
const { req, res, next } = mockReqRes({
|
|
originalUrl: '/ui',
|
|
headers: { accept: 'text/html' },
|
|
} as Partial<Request>);
|
|
requireAuth(req, res, next);
|
|
expect(res.redirect).toHaveBeenCalledWith('/auth/login');
|
|
});
|
|
|
|
it('redirects a top-level navigation to /auth/login?returnTo=<url> (even under /api/)', () => {
|
|
const { req, res, next } = mockReqRes({
|
|
method: 'GET',
|
|
originalUrl: '/api/local/tasks/1/files/raw?trusted=1&path=report.html',
|
|
headers: { 'sec-fetch-mode': 'navigate', accept: 'text/html' },
|
|
} as Partial<Request>);
|
|
requireAuth(req, res, next);
|
|
expect(res.redirect).toHaveBeenCalledWith(
|
|
'/auth/login?returnTo=' +
|
|
encodeURIComponent('/api/local/tasks/1/files/raw?trusted=1&path=report.html'),
|
|
);
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 401 JSON for an unauthenticated SPA fetch (sec-fetch-mode=cors), not a redirect', () => {
|
|
const { req, res, next } = mockReqRes({
|
|
method: 'GET',
|
|
originalUrl: '/api/local/tasks',
|
|
headers: { 'sec-fetch-mode': 'cors', accept: 'application/json' },
|
|
} as Partial<Request>);
|
|
requireAuth(req, res, next);
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(res.redirect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 401 JSON for an unauthenticated POST even with an HTML Accept header', () => {
|
|
const { req, res, next } = mockReqRes({
|
|
method: 'POST',
|
|
originalUrl: '/api/local/tasks',
|
|
headers: { accept: 'text/html' },
|
|
} as Partial<Request>);
|
|
requireAuth(req, res, next);
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(res.redirect).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('safeReturnTo', () => {
|
|
it('accepts a same-origin absolute path', () => {
|
|
expect(safeReturnTo('/spaces/abc')).toBe('/spaces/abc');
|
|
expect(safeReturnTo('/api/local/tasks/1/files/raw?trusted=1&path=a.html')).toBe(
|
|
'/api/local/tasks/1/files/raw?trusted=1&path=a.html',
|
|
);
|
|
expect(safeReturnTo('/')).toBe('/');
|
|
});
|
|
|
|
it('rejects open-redirect and non-path inputs', () => {
|
|
expect(safeReturnTo('//evil.com')).toBeNull(); // protocol-relative
|
|
expect(safeReturnTo('/\\evil.com')).toBeNull(); // backslash smuggling
|
|
expect(safeReturnTo('https://evil.com')).toBeNull(); // absolute URL
|
|
expect(safeReturnTo('http://evil.com')).toBeNull();
|
|
expect(safeReturnTo('javascript:alert(1)')).toBeNull();
|
|
expect(safeReturnTo('spaces/abc')).toBeNull(); // not rooted
|
|
expect(safeReturnTo('/foo\nbar')).toBeNull(); // control char
|
|
expect(safeReturnTo('')).toBeNull();
|
|
expect(safeReturnTo(undefined)).toBeNull();
|
|
expect(safeReturnTo(123 as unknown)).toBeNull();
|
|
expect(safeReturnTo('/' + 'a'.repeat(3000))).toBeNull(); // too long
|
|
});
|
|
});
|
|
|
|
describe('consumeReturnTo', () => {
|
|
it('returns the saved value and clears it (single use)', () => {
|
|
const session = { returnTo: '/spaces/once' } as { returnTo?: string };
|
|
const req = { session } as unknown as Request;
|
|
expect(consumeReturnTo(req)).toBe('/spaces/once');
|
|
expect(session.returnTo).toBeUndefined();
|
|
expect(consumeReturnTo(req)).toBeNull(); // already consumed
|
|
});
|
|
|
|
it('re-validates the stored value and rejects unsafe paths', () => {
|
|
const req = { session: { returnTo: '//evil.com' } } as unknown as Request;
|
|
expect(consumeReturnTo(req)).toBeNull();
|
|
});
|
|
|
|
it('returns null when there is no session', () => {
|
|
expect(consumeReturnTo({} as unknown as Request)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('isTopLevelNavigation', () => {
|
|
const mk = (over: Partial<Request>) =>
|
|
({ method: 'GET', headers: {}, ...over } as unknown as Request);
|
|
|
|
it('treats GET with Sec-Fetch-Mode: navigate as a navigation', () => {
|
|
expect(isTopLevelNavigation(mk({ headers: { 'sec-fetch-mode': 'navigate' } }))).toBe(true);
|
|
});
|
|
|
|
it('falls back to Accept: text/html when Sec-Fetch-Mode is absent', () => {
|
|
expect(isTopLevelNavigation(mk({ headers: { accept: 'text/html,*/*' } }))).toBe(true);
|
|
});
|
|
|
|
it('does not treat a CORS fetch as a navigation', () => {
|
|
expect(
|
|
isTopLevelNavigation(mk({ headers: { 'sec-fetch-mode': 'cors', accept: 'text/html' } })),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('does not treat non-GET as a navigation', () => {
|
|
expect(isTopLevelNavigation(mk({ method: 'POST', headers: { accept: 'text/html' } }))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('requireAdmin', () => {
|
|
it('calls next() for admin user', () => {
|
|
const { req, res, next } = mockReqRes({
|
|
isAuthenticated: () => true,
|
|
user: { id: '1', role: 'admin', status: 'active' },
|
|
} as Partial<Request>);
|
|
requireAdmin(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 403 for non-admin user', () => {
|
|
const { req, res, next } = mockReqRes({
|
|
isAuthenticated: () => true,
|
|
user: { id: '1', role: 'user', status: 'active' },
|
|
} as Partial<Request>);
|
|
requireAdmin(req, res, next);
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
});
|
|
|
|
it('returns 401 for unauthenticated request', () => {
|
|
const { req, res, next } = mockReqRes();
|
|
requireAdmin(req, res, next);
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
});
|
|
});
|
|
|
|
describe('fetchGiteaOrgsForUser', () => {
|
|
let tempDir = '';
|
|
afterEach(() => {
|
|
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
|
|
vi.restoreAllMocks();
|
|
});
|
|
function makeRepo(): Repository {
|
|
tempDir = mkdtempSync(join(tmpdir(), 'auth-test-'));
|
|
return new Repository(join(tempDir, 'db.sqlite'));
|
|
}
|
|
|
|
it('fetches and persists user orgs from Gitea API', async () => {
|
|
const repo = makeRepo();
|
|
try {
|
|
const user = repo.createUser({
|
|
email: 'a@gitea.local', name: 'alice', role: 'user', status: 'active',
|
|
});
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => [
|
|
{ id: 10, username: 'marketing' },
|
|
{ id: 20, username: 'platform' },
|
|
],
|
|
} as Response);
|
|
|
|
const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token-xyz');
|
|
|
|
expect(orgIds.sort()).toEqual(['10', '20']);
|
|
expect(repo.listUserGiteaOrgs(user.id).map(o => o.orgName).sort())
|
|
.toEqual(['marketing', 'platform']);
|
|
} finally {
|
|
repo.close();
|
|
}
|
|
});
|
|
|
|
it('returns empty array on !res.ok', async () => {
|
|
const repo = makeRepo();
|
|
try {
|
|
const user = repo.createUser({
|
|
email: 'a@gitea.local', name: 'alice', role: 'user', status: 'active',
|
|
});
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
|
ok: false, status: 401,
|
|
} as Response);
|
|
|
|
const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'bad-token');
|
|
|
|
expect(orgIds).toEqual([]);
|
|
expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
|
|
} finally {
|
|
repo.close();
|
|
}
|
|
});
|
|
|
|
it('returns empty array on fetch rejection (network error)', async () => {
|
|
const repo = makeRepo();
|
|
try {
|
|
const user = repo.createUser({
|
|
email: 'a@gitea.local', name: 'alice', role: 'user', status: 'active',
|
|
});
|
|
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
|
|
|
const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token');
|
|
|
|
expect(orgIds).toEqual([]);
|
|
expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
|
|
} finally {
|
|
repo.close();
|
|
}
|
|
});
|
|
|
|
it('clears stale cached orgs when fetch fails (prevents permission lag after org removal)', async () => {
|
|
const repo = makeRepo();
|
|
try {
|
|
const user = repo.createUser({
|
|
email: 'a@gitea.local', name: 'alice', role: 'user', status: 'active',
|
|
});
|
|
repo.replaceUserGiteaOrgs(user.id, [
|
|
{ orgId: '10', orgName: 'marketing' },
|
|
{ orgId: '20', orgName: 'platform' },
|
|
]);
|
|
expect(repo.listUserGiteaOrgs(user.id)).toHaveLength(2);
|
|
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ ok: false, status: 503 } as Response);
|
|
await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token');
|
|
expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
|
|
|
|
repo.replaceUserGiteaOrgs(user.id, [{ orgId: '10', orgName: 'marketing' }]);
|
|
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
|
await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token');
|
|
expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
|
|
} finally {
|
|
repo.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Audit 2026-06-09: provider completeness + primaryProvider gating. These pin
|
|
// the security-critical fail-closed / restriction semantics behind the new
|
|
// AuthForm (config editable from Settings UI).
|
|
const COMPLETE = { clientId: 'id', clientSecret: 'sec', callbackUrl: 'https://x/cb' };
|
|
const COMPLETE_GITEA = { ...COMPLETE, baseUrl: 'https://gitea' };
|
|
function authCfg(
|
|
providers: Partial<AuthConfig['providers']>,
|
|
primaryProvider?: 'google' | 'gitea',
|
|
): AuthConfig {
|
|
return {
|
|
sessionSecret: 's', sessionMaxAge: 1, secureCookie: false, adminEmails: [],
|
|
primaryProvider, providers,
|
|
} as AuthConfig;
|
|
}
|
|
|
|
describe('isProviderConfigured', () => {
|
|
it('true only when all required fields are present', () => {
|
|
expect(isProviderConfigured(COMPLETE, 'google')).toBe(true);
|
|
expect(isProviderConfigured(COMPLETE_GITEA, 'gitea')).toBe(true);
|
|
});
|
|
it('gitea requires baseUrl', () => {
|
|
expect(isProviderConfigured(COMPLETE, 'gitea')).toBe(false);
|
|
});
|
|
it('false when any field is missing or provider undefined', () => {
|
|
expect(isProviderConfigured({ ...COMPLETE, clientSecret: '' }, 'google')).toBe(false);
|
|
expect(isProviderConfigured({ ...COMPLETE, callbackUrl: undefined }, 'google')).toBe(false);
|
|
expect(isProviderConfigured(undefined, 'google')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isProviderActive (primaryProvider restriction)', () => {
|
|
it('both active when both complete and no primary', () => {
|
|
const c = authCfg({ google: COMPLETE, gitea: COMPLETE_GITEA });
|
|
expect(isProviderActive(c, 'google')).toBe(true);
|
|
expect(isProviderActive(c, 'gitea')).toBe(true);
|
|
});
|
|
it('a valid primary restricts to that provider only', () => {
|
|
const c = authCfg({ google: COMPLETE, gitea: COMPLETE_GITEA }, 'google');
|
|
expect(isProviderActive(c, 'google')).toBe(true);
|
|
expect(isProviderActive(c, 'gitea')).toBe(false);
|
|
});
|
|
it('an invalid primary (unconfigured provider) is ignored', () => {
|
|
const c = authCfg({ gitea: COMPLETE_GITEA }, 'google');
|
|
expect(isProviderActive(c, 'gitea')).toBe(true); // gitea still usable
|
|
expect(isProviderActive(c, 'google')).toBe(false); // google not configured
|
|
});
|
|
it('does not throw when providers is entirely absent (local-only config)', () => {
|
|
// Saving "local only" from the Settings UI can persist an auth block with
|
|
// no providers key at all. The OAuth helpers + strategy registration must
|
|
// treat that as "no OAuth provider configured", not crash.
|
|
const c = {
|
|
sessionSecret: 's', sessionMaxAge: 1, secureCookie: false, adminEmails: [],
|
|
} as AuthConfig;
|
|
expect(() => isProviderActive(c, 'google')).not.toThrow();
|
|
expect(isProviderActive(c, 'google')).toBe(false);
|
|
expect(isProviderActive(c, 'gitea')).toBe(false);
|
|
});
|
|
});
|