Merge pull request 'Sign in through an authenticating reverse proxy' (#3) from trusted-header-login into main

Reviewed-on: branderson/flashpoint-web#3
Reviewed-by: branderson <brad@branderson.io>
This commit is contained in:
2026-10-12 02:09:00 -07:00
22 changed files with 807 additions and 24 deletions
+6
View File
@@ -109,6 +109,12 @@ ENABLE_CACHE_PREWARM=true
# Off unless both are set. See docs/09-deployment/environment-variables.md.
# STREAMING_API_URL=http://flashpoint-session-manager:8080
# STREAMING_API_TOKEN=
# Sign in users an authenticating reverse proxy (e.g. Authentik forward auth)
# names in a header. Off unless TRUSTED_HEADER_USERNAME and TRUSTED_PROXIES are
# both set. See docs/09-deployment/environment-variables.md.
# TRUSTED_HEADER_USERNAME=X-authentik-username
# TRUSTED_HEADER_EMAIL=X-authentik-email
# TRUSTED_PROXIES=192.0.2.10
# Serve the built frontend from the backend (on by default in production)
# SERVE_FRONTEND=true
+11
View File
@@ -174,6 +174,17 @@ export const config = {
streamingApiUrl: process.env.STREAMING_API_URL || null,
streamingApiToken: process.env.STREAMING_API_TOKEN || null,
// Sign users in from a header set by an authenticating reverse proxy, such as
// Authentik forward auth. Off unless the header name and the proxy addresses
// are both set; the header is only believed on connections from those
// addresses, since anyone else could send it.
trustedHeaderUsername: process.env.TRUSTED_HEADER_USERNAME || null,
trustedHeaderEmail: process.env.TRUSTED_HEADER_EMAIL || null,
trustedProxies: (process.env.TRUSTED_PROXIES ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean),
// When enabled, copies flashpoint.sqlite to local storage for faster network access
enableLocalDbCopy: process.env.ENABLE_LOCAL_DB_COPY === 'true',
localDbPath: '/app/data/flashpoint.sqlite',
+42
View File
@@ -8,6 +8,7 @@ import { asyncHandler } from '../middleware/asyncHandler';
import { softAuth } from '../middleware/auth';
import { logActivity } from '../middleware/activityLogger';
import { logger } from '../utils/logger';
import { getTrustedIdentity, isTrustedHeaderEnabled } from '../utils/trustedHeader';
import {
setRefreshTokenCookie,
clearRefreshTokenCookie,
@@ -245,6 +246,47 @@ router.post(
})
);
// Signs in the user an authenticating reverse proxy vouches for (TRUSTED_HEADER_USERNAME)
router.post(
'/trusted-login',
refreshLimiter,
asyncHandler(async (req, res) => {
if (!isTrustedHeaderEnabled()) {
throw new AppError(404, 'Trusted header login is not enabled');
}
const identity = getTrustedIdentity(req);
if (!identity) {
throw new AppError(403, 'No trusted identity on this request');
}
const ipAddress = req.ip ?? '';
const result = await authService.trustedLogin(identity.username, identity.email);
await activityService.log({
userId: result.user.id,
username: result.user.username,
action: 'login',
resource: 'auth',
details: { method: 'trusted-header', accountCreated: result.created },
ipAddress,
userAgent: req.headers['user-agent'],
});
logger.info(
`[Auth] Trusted header login for user: ${result.user.username}${result.created ? ' (account created)' : ''}`
);
setRefreshTokenCookie(req, res, result.tokens.refreshToken);
setAccessTokenCookie(req, res, result.tokens.accessToken);
res.json({
user: result.user,
tokens: {
expiresIn: result.tokens.expiresIn,
},
});
})
);
router.post(
'/logout',
softAuth,
+159
View File
@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import express, { Express } from 'express';
import cookieParser from 'cookie-parser';
import request from 'supertest';
import fs from 'fs';
vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
// UserDatabaseService creates the directory
const tempDir = vi.hoisted(
() => `${process.env.TMPDIR ?? '/tmp'}/trusted-login-${process.pid}-${Date.now()}`
);
vi.mock('../config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../config')>();
return {
config: {
...actual.config,
userDbPath: `${tempDir}/user.db`,
trustedHeaderUsername: 'X-authentik-username',
trustedHeaderEmail: 'X-authentik-email',
// supertest connects over loopback, standing in for the proxy
trustedProxies: ['127.0.0.1', '::1'],
},
};
});
import authRouter from './auth';
import usersRouter from './users';
import { errorHandler } from '../middleware/errorHandler';
import { UserDatabaseService } from '../services/UserDatabaseService';
import { AuthService } from '../services/AuthService';
function createApp(): Express {
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', authRouter);
app.use('/api/users', usersRouter);
app.use(errorHandler);
return app;
}
function trustedLogin(app: Express, username?: string, email?: string) {
const req = request(app).post('/api/auth/trusted-login');
if (username) req.set('X-authentik-username', username);
if (email) req.set('X-authentik-email', email);
return req;
}
function cookiesOf(res: request.Response): string[] {
const header = res.headers['set-cookie'] as unknown as string[] | undefined;
return (header ?? []).map((c) => c.split(';')[0]);
}
describe('trusted header login', () => {
let app: Express;
beforeAll(async () => {
await UserDatabaseService.initialize();
app = createApp();
});
afterAll(() => {
UserDatabaseService.close();
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('creates the first trusted user as the administrator, then regular users', async () => {
const first = await trustedLogin(app, 'brad', 'brad@example.com');
expect(first.status).toBe(200);
expect(first.body.user).toMatchObject({
username: 'brad',
email: 'brad@example.com',
role: 'admin',
});
expect(cookiesOf(first).some((c) => c.startsWith('fp_refresh='))).toBe(true);
const second = await trustedLogin(app, 'friend');
expect(second.body.user).toMatchObject({
username: 'friend',
email: 'friend@trusted-header.invalid',
role: 'user',
});
});
it('signs in once when a misconfigured proxy repeats the header', async () => {
// superagent sends an array as repeated header lines; its types only allow that for Cookie
const twice = (value: string) => [value, value] as unknown as string;
const res = await request(app)
.post('/api/auth/trusted-login')
.set('X-authentik-username', twice('brad'))
.set('X-authentik-email', twice('brad@example.com'));
expect(res.status).toBe(200);
expect(res.body.user).toMatchObject({ username: 'brad', email: 'brad@example.com' });
});
it('signs an existing user back in, matching the username case-insensitively', async () => {
const again = await trustedLogin(app, 'BRAD', 'someone-else@example.com');
expect(again.status).toBe(200);
expect(again.body.user).toMatchObject({
username: 'brad',
email: 'brad@example.com',
role: 'admin',
});
});
it('adopts an account created with a password', async () => {
const authService = new AuthService();
const db = UserDatabaseService.getDatabase();
db.prepare(
"INSERT INTO users (username, email, password_hash, role_id) VALUES ('local', 'local@example.com', 'x', 2)"
).run();
const { user } = await authService.trustedLogin('local', null);
expect(user).toMatchObject({ username: 'local', email: 'local@example.com' });
});
it('refuses disabled accounts, missing identities and bad usernames', async () => {
UserDatabaseService.run("UPDATE users SET is_active = 0 WHERE username = 'friend'", []);
expect((await trustedLogin(app, 'friend')).status).toBe(403);
expect((await trustedLogin(app)).status).toBe(403);
expect((await trustedLogin(app, 'ab')).status).toBe(400);
});
it('lets a trusted user set a password without the current one, then log in with it', async () => {
const login = await trustedLogin(app, 'brad');
const cookies = cookiesOf(login);
const withoutProxy = await request(app)
.post(`/api/users/${login.body.user.id}/change-password`)
.set('Cookie', cookies)
.send({ newPassword: 'lan-password' });
expect(withoutProxy.status).toBe(400);
const viaProxy = await request(app)
.post(`/api/users/${login.body.user.id}/change-password`)
.set('Cookie', cookies)
.set('X-authentik-username', 'brad')
.send({ newPassword: 'lan-password' });
expect(viaProxy.status).toBe(200);
const passwordLogin = await request(app)
.post('/api/auth/login')
.send({ username: 'brad', password: 'lan-password' });
expect(passwordLogin.status).toBe(200);
});
it("can't set another user's password by naming yourself in the header", async () => {
const friend = await trustedLogin(app, 'local');
const res = await request(app)
.post(`/api/users/1/change-password`)
.set('Cookie', cookiesOf(friend))
.set('X-authentik-username', 'local')
.send({ newPassword: 'stolen-password' });
expect(res.status).toBe(403);
});
});
+8 -2
View File
@@ -4,6 +4,7 @@ import { authenticate } from '../middleware/auth';
import { requirePermission } from '../middleware/rbac';
import { logActivity } from '../middleware/activityLogger';
import { AppError } from '../middleware/errorHandler';
import { getTrustedIdentity } from '../utils/trustedHeader';
import { asyncHandler } from '../middleware/asyncHandler';
import { z } from 'zod';
@@ -257,9 +258,14 @@ router.post(
}
const isAdminReset = hasAdminPermission && !isOwnPassword;
// A trusted proxy vouching for this user on this request proves identity as
// well as the current password would, and accounts it created have none
const trustedIdentity = getTrustedIdentity(req);
const isTrustedSelf =
isOwnPassword && trustedIdentity?.username.toLowerCase() === req.user?.username.toLowerCase();
// Require current password for self-change (non-admin reset)
if (!isAdminReset && !data.currentPassword) {
if (!isAdminReset && !isTrustedSelf && !data.currentPassword) {
throw new AppError(400, 'Current password is required');
}
@@ -267,7 +273,7 @@ router.post(
id,
data.currentPassword || '',
data.newPassword,
isAdminReset
isAdminReset || isTrustedSelf
);
res.json({ success: true, message: 'Password changed successfully' });
+106 -15
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'crypto';
import { UserDatabaseService } from './UserDatabaseService';
import { SystemSettingsService } from './SystemSettingsService';
import { CachedSystemSettingsService } from './CachedSystemSettingsService';
@@ -153,21 +154,7 @@ export class AuthService {
});
const userId = registerTransaction();
const appSettings = this.cachedSystemSettings.getCategory('app');
const defaultTheme = appSettings.defaultTheme || 'dark';
const defaultPrimaryColor = appSettings.defaultPrimaryColor || 'blue';
UserDatabaseService.run(
`INSERT INTO user_settings (user_id, setting_key, setting_value) VALUES
(?, 'theme_mode', ?),
(?, 'primary_color', ?)`,
[userId, defaultTheme, userId, defaultPrimaryColor]
);
logger.info(
`[AuthService] Created user ${data.username} with default theme: ${defaultTheme}, primary color: ${defaultPrimaryColor}`
);
this.createDefaultSettings(userId, data.username);
const user = UserDatabaseService.get<UserTokenData & { email: string }>(
`SELECT u.id, u.username, u.email, r.name as role_name
@@ -195,6 +182,110 @@ export class AuthService {
return { user: authUser, tokens };
}
/**
* Sign in a user a trusted proxy has authenticated, creating the account on
* first sight. Matching by username adopts an existing account of that name.
* A new account gets a random password; its owner can set a real one while
* signed in through the proxy, to log in without it.
*/
async trustedLogin(
username: string,
email: string | null
): Promise<{ user: AuthUser; tokens: AuthTokens; created: boolean }> {
if (username.length < 3 || username.length > 50) {
throw new AppError(400, 'Trusted username must be 3 to 50 characters');
}
let created = false;
let user = this.getTokenUserByUsername(username);
if (!user) {
const passwordHash = await hashPassword(randomBytes(32).toString('base64url'));
const db = UserDatabaseService.getDatabase();
const userId = db.transaction(() => {
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username) as
| { id: number }
| undefined;
if (existing) {
return existing.id;
}
const emailTaken = email && db.prepare('SELECT id FROM users WHERE email = ?').get(email);
const count = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number };
// Same rule as registration: the first account administers the instance
const roleId = count.count === 0 ? 1 : 2;
const result = db
.prepare(
'INSERT INTO users (username, email, password_hash, role_id) VALUES (?, ?, ?, ?)'
)
.run(
username,
email && !emailTaken ? email : `${username}@trusted-header.invalid`,
passwordHash,
roleId
);
created = true;
return result.lastInsertRowid as number;
})();
if (created) {
this.createDefaultSettings(userId, username);
}
user = this.getTokenUserByUsername(username);
}
if (!user) {
throw new AppError(500, 'Failed to retrieve trusted user');
}
if (!user.is_active) {
throw new AppError(403, 'This account is disabled');
}
UserDatabaseService.run('UPDATE users SET last_login_at = ? WHERE id = ?', [
new Date().toISOString(),
user.id,
]);
const tokens = await this.generateTokens(user);
return {
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role_name,
permissions: this.getUserPermissions(user.id),
},
tokens,
created,
};
}
private getTokenUserByUsername(
username: string
): (UserTokenData & { email: string; is_active: number }) | undefined {
return UserDatabaseService.get<UserTokenData & { email: string; is_active: number }>(
`SELECT u.id, u.username, u.email, u.is_active, r.name as role_name
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.username = ?`,
[username]
);
}
private createDefaultSettings(userId: number, username: string): void {
const appSettings = this.cachedSystemSettings.getCategory('app');
const defaultTheme = appSettings.defaultTheme || 'dark';
const defaultPrimaryColor = appSettings.defaultPrimaryColor || 'blue';
UserDatabaseService.run(
`INSERT INTO user_settings (user_id, setting_key, setting_value) VALUES
(?, 'theme_mode', ?),
(?, 'primary_color', ?)`,
[userId, defaultTheme, userId, defaultPrimaryColor]
);
logger.info(
`[AuthService] Created user ${username} with default theme: ${defaultTheme}, primary color: ${defaultPrimaryColor}`
);
}
async logout(refreshToken: string): Promise<void> {
UserDatabaseService.run('UPDATE refresh_tokens SET revoked_at = ? WHERE token = ?', [
new Date().toISOString(),
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request } from 'express';
vi.mock('./logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
const config = vi.hoisted(() => ({
trustedHeaderUsername: 'X-authentik-username' as string | null,
trustedHeaderEmail: 'X-authentik-email' as string | null,
trustedProxies: ['192.0.2.10', '198.51.100.0/24', '2001:db8::/32'],
}));
vi.mock('../config', () => ({ config }));
function request(remoteAddress: string, headers: Record<string, string | string[]> = {}): Request {
const headersDistinct = Object.fromEntries(
Object.entries(headers).map(([name, value]) => [name, Array.isArray(value) ? value : [value]])
);
return { socket: { remoteAddress }, headersDistinct } as unknown as Request;
}
describe('trusted header identity', () => {
beforeEach(() => {
vi.resetModules();
config.trustedHeaderUsername = 'X-authentik-username';
});
async function load() {
return import('./trustedHeader');
}
it('believes the header only on connections from a trusted proxy', async () => {
const { getTrustedIdentity } = await load();
const headers = { 'x-authentik-username': 'brad', 'x-authentik-email': 'brad@example.com' };
expect(getTrustedIdentity(request('192.0.2.10', headers))).toEqual({
username: 'brad',
email: 'brad@example.com',
});
expect(getTrustedIdentity(request('::ffff:198.51.100.7', headers))?.username).toBe('brad');
expect(getTrustedIdentity(request('2001:db8::5', headers))?.username).toBe('brad');
expect(getTrustedIdentity(request('192.0.2.11', headers))).toBeNull();
expect(getTrustedIdentity(request('10.0.0.5', headers))).toBeNull();
});
it('needs a username, and makes the email optional', async () => {
const { getTrustedIdentity } = await load();
expect(getTrustedIdentity(request('192.0.2.10'))).toBeNull();
expect(getTrustedIdentity(request('192.0.2.10', { 'x-authentik-username': ' ' }))).toBeNull();
expect(getTrustedIdentity(request('192.0.2.10', { 'x-authentik-username': 'brad' }))).toEqual({
username: 'brad',
email: null,
});
});
it('accepts a header repeated with the same value, and refuses conflicting repeats', async () => {
const { getTrustedIdentity } = await load();
expect(
getTrustedIdentity(
request('192.0.2.10', {
'x-authentik-username': ['brad', 'brad'],
'x-authentik-email': ['brad@example.com', 'brad@example.com'],
})
)
).toEqual({ username: 'brad', email: 'brad@example.com' });
expect(
getTrustedIdentity(request('192.0.2.10', { 'x-authentik-username': ['brad', 'mallory'] }))
).toBeNull();
});
it('is off without a header name', async () => {
config.trustedHeaderUsername = null;
const { getTrustedIdentity, isTrustedHeaderEnabled } = await load();
expect(isTrustedHeaderEnabled()).toBe(false);
expect(
getTrustedIdentity(request('192.0.2.10', { 'x-authentik-username': 'brad' }))
).toBeNull();
});
it('skips invalid proxy entries', async () => {
const { buildBlockList } = await load();
const list = buildBlockList(['not-an-ip', '192.0.2.0/30']);
expect(list.check('192.0.2.1', 'ipv4')).toBe(true);
expect(list.check('192.0.2.9', 'ipv4')).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { BlockList, isIP } from 'net';
import type { Request } from 'express';
import { config } from '../config';
import { logger } from './logger';
let proxies: BlockList | null = null;
/** Parses TRUSTED_PROXIES entries: addresses or CIDR ranges, IPv4 or IPv6. */
export function buildBlockList(entries: readonly string[]): BlockList {
const list = new BlockList();
for (const entry of entries) {
const [address, prefix] = entry.split('/');
const family = isIP(address);
if (!family) {
logger.warn(`[TrustedHeader] Ignoring invalid TRUSTED_PROXIES entry: ${entry}`);
continue;
}
const type = family === 4 ? 'ipv4' : 'ipv6';
if (prefix === undefined) {
list.addAddress(address, type);
} else {
list.addSubnet(address, parseInt(prefix, 10), type);
}
}
return list;
}
export function isTrustedHeaderEnabled(): boolean {
return Boolean(config.trustedHeaderUsername && config.trustedProxies.length > 0);
}
/** Whether the connection itself, not a forwarded-for header, comes from a trusted proxy. */
function fromTrustedProxy(req: Request): boolean {
proxies ??= buildBlockList(config.trustedProxies);
let address = req.socket.remoteAddress ?? '';
// IPv4 clients on a dual-stack socket appear as ::ffff:a.b.c.d
if (address.startsWith('::ffff:') && isIP(address.slice(7)) === 4) {
address = address.slice(7);
}
const family = isIP(address);
return family !== 0 && proxies.check(address, family === 4 ? 'ipv4' : 'ipv6');
}
function header(req: Request, name: string | null): string | null {
if (!name) return null;
// Read each occurrence: Node joins repeats into one value with ", ", which a
// proxy setting the header twice would turn into a different username
const values = (req.headersDistinct[name.toLowerCase()] ?? [])
.map((value) => value.trim())
.filter(Boolean);
if (values.some((value) => value !== values[0])) {
logger.warn(`[TrustedHeader] Ignoring ${name}: the proxy sent conflicting values`);
return null;
}
return values[0] ?? null;
}
/**
* The identity a trusted proxy vouches for on this request, or null when the
* feature is off, the connection isn't from a trusted proxy, or no user is set.
*/
export function getTrustedIdentity(
req: Request
): { username: string; email: string | null } | null {
if (!isTrustedHeaderEnabled() || !fromTrustedProxy(req)) {
return null;
}
const username = header(req, config.trustedHeaderUsername);
return username ? { username, email: header(req, config.trustedHeaderEmail) } : null;
}
+4
View File
@@ -67,6 +67,10 @@ services:
# Streamed play for games that can't run in a browser (off unless both are set)
- STREAMING_API_URL=${STREAMING_API_URL:-}
- STREAMING_API_TOKEN=${STREAMING_API_TOKEN:-}
# Sign in users named by an authenticating reverse proxy (off unless both are set)
- TRUSTED_HEADER_USERNAME=${TRUSTED_HEADER_USERNAME:-}
- TRUSTED_HEADER_EMAIL=${TRUSTED_HEADER_EMAIL:-}
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
# OpenTelemetry
- OTEL_ENABLED=${OTEL_ENABLED:-false}
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
+4
View File
@@ -59,6 +59,10 @@ services:
# Streamed play for games that can't run in a browser (off unless both are set)
- STREAMING_API_URL=${STREAMING_API_URL:-}
- STREAMING_API_TOKEN=${STREAMING_API_TOKEN:-}
# Sign in users named by an authenticating reverse proxy (off unless both are set)
- TRUSTED_HEADER_USERNAME=${TRUSTED_HEADER_USERNAME:-}
- TRUSTED_HEADER_EMAIL=${TRUSTED_HEADER_EMAIL:-}
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
# OpenTelemetry
- OTEL_ENABLED=${OTEL_ENABLED:-false}
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
@@ -131,6 +131,40 @@ browser's IndexedDB.
| `STREAMING_API_URL` | - | Session manager's internal API, e.g. `http://flashpoint-session-manager:8080` |
| `STREAMING_API_TOKEN` | - | Token the session manager expects. Streaming is off unless both are set |
**Trusted Header Login:**
When a reverse proxy such as Authentik (forward auth) authenticates users in
front of flashpoint-web, it can pass the signed-in username in a header so
users don't log in twice. The backend believes the header only on connections
from `TRUSTED_PROXIES`, checked against the connection's own address, not
`X-Forwarded-For`. The proxy must always set the header, overwriting anything a
client sends, and flashpoint-web must not be reachable from untrusted networks
except through it.
Users are matched by username, so an existing account of that name is signed
in; otherwise an account is created (the first one as admin, as with
registration). New accounts get a random password. While signed in through the
proxy, users can set a password from Settings without knowing the current one,
then log in with it when reaching flashpoint-web without the proxy, such as on a
LAN.
| Variable | Default | Description |
| ------------------------- | ------- | ------------------------------------------------------------------------ |
| `TRUSTED_HEADER_USERNAME` | - | Header holding the username, e.g. `X-authentik-username` |
| `TRUSTED_HEADER_EMAIL` | - | Optional header holding the email used for new accounts |
| `TRUSTED_PROXIES` | - | Comma-separated proxy addresses or CIDR ranges allowed to set the header |
With nginx and Authentik's forward auth, each protected `location` needs these
lines once. Authentik's generated nginx snippet already includes them; adding
them again sends each header twice:
```nginx
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
proxy_set_header X-authentik-username $authentik_username;
proxy_set_header X-authentik-email $authentik_email;
```
**OpenTelemetry:**
| Variable | Default | Description |
+10 -1
View File
@@ -21,6 +21,7 @@ import { useAuthStore } from './store/auth';
import { useServerStatusStore } from './store/serverStatus';
import { authSettingsApi, authApi } from './lib/api';
import { usePublicSettings } from './hooks/usePublicSettings';
import { useTrustedLogin } from './hooks/useTrustedLogin';
import { Toaster } from '@/components/ui/sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
@@ -99,6 +100,8 @@ function App() {
const location = useLocation();
const { data: publicSettings } = usePublicSettings();
const trustedLoginChecked = useTrustedLogin();
// Check if system needs initial setup (no users exist)
const { data: setupStatus } = useQuery({
queryKey: ['setupStatus'],
@@ -137,6 +140,8 @@ function App() {
location.pathname === '/register' ||
location.pathname === '/setup';
if (!trustedLoginChecked) return;
if (isAuthPage && isGuest && !isAuthenticated) {
// Clear guest mode when navigating to auth pages
clearAuth();
@@ -144,7 +149,7 @@ function App() {
// Initialize guest mode for non-auth pages
setGuestMode();
}
}, [isAuthenticated, isGuest, location.pathname, setGuestMode, clearAuth]);
}, [isAuthenticated, isGuest, location.pathname, setGuestMode, clearAuth, trustedLoginChecked]);
// Update browser title from site name in public settings
useEffect(() => {
@@ -190,6 +195,10 @@ function App() {
);
}
if (!trustedLoginChecked) {
return <RouteLoadingFallback />;
}
return (
<ErrorBoundary>
<TooltipProvider delayDuration={300}>
@@ -0,0 +1,39 @@
import { useState } from 'react';
import { KeyRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ChangePasswordDialog } from '@/components/users/ChangePasswordDialog';
import { useAuthStore } from '@/store/auth';
/** Lets a signed-in user change their password, or set one to sign in without single sign-on. */
export function AccountPasswordCard() {
const user = useAuthStore((state) => state.user);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isTrustedSession = useAuthStore((state) => state.isTrustedSession);
const [isOpen, setIsOpen] = useState(false);
if (!user || !isAuthenticated) {
return null;
}
return (
<div className="bg-card rounded-lg p-6 border border-border shadow-md">
<h2 className="text-xl font-semibold mb-2">Password</h2>
<p className="text-sm text-muted-foreground mb-4">
{isTrustedSession
? "You're signed in through single sign-on. Set a password to also sign in without it, such as on your local network."
: 'Change the password you sign in with.'}
</p>
<Button onClick={() => setIsOpen(true)}>
<KeyRound size={16} aria-hidden="true" />
{isTrustedSession ? 'Set Password' : 'Change Password'}
</Button>
<ChangePasswordDialog
isOpen={isOpen}
user={user}
currentPasswordOptional={isTrustedSession}
onClose={() => setIsOpen(false)}
onSuccess={() => setIsOpen(false)}
/>
</div>
);
}
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ChangePasswordDialog } from './ChangePasswordDialog';
const mutateAsync = vi.hoisted(() => vi.fn());
vi.mock('@/hooks/useUsers', () => ({
useChangePassword: () => ({ mutateAsync, isError: false, isPending: false, error: null }),
}));
const user = { id: 7, username: 'brad' };
async function submit(newPassword: string) {
await userEvent.type(screen.getByLabelText('New Password'), newPassword);
await userEvent.type(screen.getByLabelText('Confirm New Password'), newPassword);
await userEvent.click(screen.getByRole('button', { name: 'Change Password' }));
}
describe('ChangePasswordDialog', () => {
beforeEach(() => {
mutateAsync.mockReset().mockResolvedValue(undefined);
});
it('requires the current password by default', async () => {
render(<ChangePasswordDialog isOpen user={user} onClose={vi.fn()} onSuccess={vi.fn()} />);
await submit('new-password');
expect(await screen.findByText('Current password is required')).toBeInTheDocument();
expect(mutateAsync).not.toHaveBeenCalled();
});
it('lets a single sign-on session set a password without the current one', async () => {
render(
<ChangePasswordDialog
isOpen
user={user}
currentPasswordOptional
onClose={vi.fn()}
onSuccess={vi.fn()}
/>
);
expect(screen.getByLabelText('Current Password (optional)')).toBeInTheDocument();
await submit('new-password');
await waitFor(() =>
expect(mutateAsync).toHaveBeenCalledWith({
id: 7,
passwordData: { currentPassword: undefined, newPassword: 'new-password' },
})
);
});
});
@@ -10,6 +10,7 @@ import {
FormControl,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from '@/components/ui/form';
@@ -45,8 +46,10 @@ type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;
interface ChangePasswordDialogProps {
isOpen: boolean;
user: UserDetails;
user: Pick<UserDetails, 'id' | 'username'>;
isAdminReset?: boolean;
/** Signed in through a trusted proxy, which vouches for the user in place of the current password */
currentPasswordOptional?: boolean;
onClose: () => void;
onSuccess: () => void;
}
@@ -55,6 +58,7 @@ export function ChangePasswordDialog({
isOpen,
user,
isAdminReset,
currentPasswordOptional,
onClose,
onSuccess,
}: ChangePasswordDialogProps) {
@@ -70,7 +74,7 @@ export function ChangePasswordDialog({
});
const onSubmit = async (values: ChangePasswordFormValues) => {
if (!isAdminReset && !values.currentPassword) {
if (!isAdminReset && !currentPasswordOptional && !values.currentPassword) {
form.setError('currentPassword', { message: 'Current password is required' });
return;
}
@@ -78,7 +82,8 @@ export function ChangePasswordDialog({
await changePasswordMutation.mutateAsync({
id: user.id,
passwordData: {
currentPassword: isAdminReset ? undefined : values.currentPassword,
currentPassword:
isAdminReset || !values.currentPassword ? undefined : values.currentPassword,
newPassword: values.newPassword,
},
});
@@ -113,10 +118,17 @@ export function ChangePasswordDialog({
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormLabel>
Current Password{currentPasswordOptional ? ' (optional)' : ''}
</FormLabel>
<FormControl>
<Input type="password" placeholder="******************" {...field} />
</FormControl>
{currentPasswordOptional ? (
<FormDescription>
Not needed while you&apos;re signed in through single sign-on.
</FormDescription>
) : null}
<FormMessage />
</FormItem>
)}
+3
View File
@@ -7,6 +7,7 @@ import { apiClient } from '../lib/api/client';
import { LoginCredentials, RegisterData } from '../types/auth';
import { PublicSettings } from '../types/settings';
import { logger } from '../lib/logger';
import { SKIP_TRUSTED_LOGIN_KEY } from '../lib/trustedLogin';
/** Validate redirect path to prevent open redirects */
function sanitizeRedirectPath(path: string | undefined): string {
@@ -64,6 +65,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
async (credentials: LoginCredentials, redirectPath?: string) => {
try {
const result = await authApi.login(credentials);
sessionStorage.removeItem(SKIP_TRUSTED_LOGIN_KEY);
// Clear user-specific cached queries, but keep public settings (not user-specific)
queryClient.removeQueries({
@@ -114,6 +116,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
);
const logout = useCallback(async () => {
sessionStorage.setItem(SKIP_TRUSTED_LOGIN_KEY, '1');
try {
await authApi.logout();
} catch (error) {
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useTrustedLogin } from './useTrustedLogin';
import { useAuthStore } from '@/store/auth';
import { SKIP_TRUSTED_LOGIN_KEY } from '@/lib/trustedLogin';
const trustedLogin = vi.hoisted(() => vi.fn());
vi.mock('@/lib/api', () => ({ authApi: { trustedLogin } }));
vi.mock('@/store/theme', () => ({
useThemeStore: { getState: () => ({ loadThemeFromServer: () => Promise.resolve() }) },
}));
const user = {
id: 7,
username: 'brad',
email: 'brad@example.com',
role: 'user',
permissions: ['games.read'],
};
function render(path: string) {
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={[path]}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</MemoryRouter>
);
return renderHook(() => ({ checked: useTrustedLogin(), location: useLocation() }), { wrapper });
}
describe('useTrustedLogin', () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
useAuthStore.getState().clearAuth();
});
it('signs in the user the proxy vouches for and leaves the login page', async () => {
trustedLogin.mockResolvedValue({ user, tokens: { expiresIn: 3600 } });
const { result } = render('/login');
expect(result.current.checked).toBe(false);
await waitFor(() => expect(result.current.checked).toBe(true));
expect(useAuthStore.getState()).toMatchObject({
user,
isAuthenticated: true,
isTrustedSession: true,
});
expect(result.current.location.pathname).toBe('/');
});
it('continues signed out when there is no trusted identity', async () => {
trustedLogin.mockRejectedValue(new Error('403'));
const { result } = render('/games/abc');
await waitFor(() => expect(result.current.checked).toBe(true));
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(result.current.location.pathname).toBe('/games/abc');
});
it('does not ask after an explicit logout or when already signed in', () => {
sessionStorage.setItem(SKIP_TRUSTED_LOGIN_KEY, '1');
expect(render('/').result.current.checked).toBe(true);
sessionStorage.clear();
useAuthStore.getState().setAuth(user);
expect(render('/').result.current.checked).toBe(true);
expect(trustedLogin).not.toHaveBeenCalled();
});
});
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { authApi } from '@/lib/api';
import { logger } from '@/lib/logger';
import { SKIP_TRUSTED_LOGIN_KEY } from '@/lib/trustedLogin';
import { useAuthStore } from '@/store/auth';
const AUTH_PAGES = ['/login', '/register', '/setup'];
/**
* A signed-out visitor may be vouched for by an authenticating reverse proxy.
* Asks once on load, before guest mode or a login redirect takes over, and
* returns whether that check is done.
*/
export function useTrustedLogin(): boolean {
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const [checked, setChecked] = useState(
() =>
useAuthStore.getState().isAuthenticated ||
sessionStorage.getItem(SKIP_TRUSTED_LOGIN_KEY) !== null
);
useEffect(() => {
if (checked) return;
let cancelled = false;
authApi
.trustedLogin()
.then((result) => {
if (cancelled) return;
useAuthStore.getState().setAuth(result.user, true);
queryClient.invalidateQueries({ queryKey: ['setupStatus'] });
if (AUTH_PAGES.includes(location.pathname)) {
navigate('/', { replace: true });
}
})
.catch(() => {
logger.debug('[useTrustedLogin] No trusted proxy identity; continuing signed out');
})
.finally(() => {
if (!cancelled) setChecked(true);
});
return () => {
cancelled = true;
};
// Runs once on load; later navigation must not repeat it
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return checked;
}
+6
View File
@@ -18,6 +18,12 @@ export const authApi = {
return data;
},
/** Sign in as the user an authenticating reverse proxy vouches for, if any */
trustedLogin: async (): Promise<LoginResponse> => {
const { data } = await apiClient.post<LoginResponse>('/auth/trusted-login');
return data;
},
logout: async (): Promise<{ success: boolean; message: string }> => {
const { data } = await apiClient.post('/auth/logout');
return data;
+5
View File
@@ -0,0 +1,5 @@
/**
* Set for the rest of the tab's session after an explicit logout, so the app
* doesn't sign straight back in through the trusted proxy header.
*/
export const SKIP_TRUSTED_LOGIN_KEY = 'flashpoint-skip-trusted-login';
+8 -2
View File
@@ -9,8 +9,10 @@ interface AuthState {
isAuthenticated: boolean;
isGuest: boolean;
isMaintenanceMode: boolean;
/** Signed in through a trusted proxy header rather than a password */
isTrustedSession: boolean;
setAuth: (user: User) => void;
setAuth: (user: User, isTrustedSession?: boolean) => void;
setGuestMode: () => void;
clearAuth: () => void;
updateUser: (user: User) => void;
@@ -29,12 +31,14 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: false,
isGuest: false,
isMaintenanceMode: false,
isTrustedSession: false,
setAuth: (user: User) => {
setAuth: (user: User, isTrustedSession = false) => {
set({
user,
isAuthenticated: true,
isGuest: false,
isTrustedSession,
});
if (user.role !== 'guest') {
@@ -66,6 +70,7 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: false,
isGuest: false,
isMaintenanceMode: false,
isTrustedSession: false,
});
},
@@ -141,6 +146,7 @@ export const useAuthStore = create<AuthState>()(
user: state.user,
isAuthenticated: state.isAuthenticated,
isGuest: state.isGuest,
isTrustedSession: state.isTrustedSession,
}),
}
)
+3
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Settings } from 'lucide-react';
import { AnimatePresence } from 'framer-motion';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AccountPasswordCard } from '@/components/settings/AccountPasswordCard';
import { useAuthStore } from '@/store/auth';
import { useQuery } from '@tanstack/react-query';
import { ruffleApi } from '@/lib/api';
@@ -62,6 +63,8 @@ export function SettingsView() {
<h1 className="text-3xl font-bold">Settings</h1>
</div>
<AccountPasswordCard />
{isAdmin ? (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-4 border-2 border-primary/60 h-full">