refactor(server): fold webauthn, account erasure, oauth and the wiki out of services/ (#1840)

* refactor(server): move the webauthn config and the account erasure into nest/auth

webauthnConfig resolved the RP ID through its own getSetting() on the db
singleton; it is a WebauthnConfigService over DatabaseService now, injected into
AuthService (passkey_configured) and PasskeyService (four ceremonies). readEnv()
is still read per call, so an operator flipping the RP ID at runtime takes effect
on the next ceremony instead of after a restart.

userCleanupService follows as UserCleanupService. It keeps taking
removeUserFromBudgetItems from budget.bridge rather than injecting BudgetService:
BudgetModule imports AuthModule for the demo guard in BudgetMcp, so AuthModule
cannot import BudgetModule back, and breaking that edge the other way drags
BudgetController, BudgetMcp and its trips.bridge chain into every graph that
imports AuthModule.

The legacy module had no tests of its own - the erasure only ever ran through
AdminService.deleteUser and TripsService.deleteGuest - so USER-CLEANUP-001..009
pin both halves, including the orphan data dir that no permissions row can
describe and the all-or-nothing deletion transaction.

* refactor(server): fold the oauth 2.1 server out of services/

694 lines move into OauthService over DatabaseService, AddonsService and
AuditService. The thin wrapper that only forwarded is gone, and so is the second
path to the MCP addon flag - validateAuthorizeRequest read it through
addons.bridge while the wrapper read it through the injected service, which would
have let the two drift.

The pending authorization codes stay a module singleton in oauth.pending-codes.ts
instead of becoming instance state. The SDK still serves /oauth/authorize,
/oauth/register and /oauth/consent from its own router, mounted on Express before
app.init(), so it reaches the domain through the new oauth.bridge.ts - two maps
would break the authorization-code flow silently, since the SDK only ever answers
"Authorization grant is invalid.". The import-time sweep keeps its .unref() and is
a named function now so a test can drive it.

scopes and sessionManager are imported deep, never through the src/mcp barrel:
the barrel re-exports revokeUserSessionsForClient and pulls the domain bridges
back in, which is the nest -> mcp -> nest cycle. Same comment as admin.service and
auth.service carry.

The 70 cases of the legacy suite moved over unchanged, bound to a hand-built
service. Fifteen more cover what free functions never let anyone reach: the code
store at capacity, an expired code, an expired access token, refresh against a
public and an unknown client, the RFC 8707 resource checks, and that a code
written through the container is read back through the bridge.

* refactor(server): move the wiki reader into the help module

wikiService had exactly one consumer, HelpController, and touches no database -
it stays a plain module rather than becoming a provider, the way
auth/ephemeral-tokens.ts does, so the text and asset caches keep being one per
process without any ceremony around it.

The WIKI_DIR anchor needed one more level: src/services sits three below the repo
root, src/nest/help sits four. The existing path-resolution case caught it, which
is what it is there for - a wrong anchor makes help degrade to the GitHub fallback
without anybody noticing.

Also brings the nest README back in line: services/ is down to five top-level
files, and the passkey and budget entries no longer point at paths that were
deleted since they were written.
This commit is contained in:
Maurice
2026-08-08 11:14:24 +02:00
committed by GitHub
parent 5eab8128bd
commit 03493a2edd
39 changed files with 2528 additions and 2049 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
import { User } from '../types';
import { verifyMcpToken, verifyJwtToken } from '../nest/auth/auth.bridge';
import { getUserByAccessToken } from '../services/oauthService';
import { getUserByAccessToken } from '../nest/oauth/oauth.bridge';
import { isAddonEnabled } from '../nest/addons/addons.bridge';
import { ADDON_IDS } from '../addons';
import { registerResources } from './resources';
+1 -1
View File
@@ -14,7 +14,7 @@ import {
revokeToken as serviceRevokeToken,
verifyPKCE,
getUserByAccessToken,
} from '../services/oauthService';
} from '../nest/oauth/oauth.bridge';
import { ALL_SCOPES } from './scopes';
import { getMcpSafeUrl } from '../app-config';
import { writeAudit } from '../nest/audit/audit.bridge';
+13 -6
View File
@@ -29,7 +29,8 @@ remains as the platform underneath `@nestjs/platform-express`.
todo, packing, day-notes, trip-invite, assignments, share, settings, files,
collab, vacay, reservations, day, permissions, audit, budget, trip, maps,
transit, place, transit-itinerary, collections, atlas, auth, oidc, passkey,
notifications, admin — see the migration recipe below.
notifications, admin, webauthn-config, user-cleanup, oauth, wiki — see the
migration recipe below.
- **Foundation (BE-Phase 1, complete):** the eight stateless helpers moved to
`common/`; every trip-access check routes through `DatabaseService`
(`services/tripAccess.ts` and the dead `middleware/tripAccess.ts` are gone);
@@ -40,7 +41,9 @@ remains as the platform underneath `@nestjs/platform-express`.
addon guards are one `AddonGuard` + `@RequireAddon`; the demo-mode block is one
condition. Still open: `TripAccessGuard`, default-deny, the MFA policy.
`src/services/` is down to 19 top-level files. Note that the trip-access and
`src/services/` is down to five top-level files (journey, journeyShare,
notifications, notificationPreferences, backup) plus the `airtrail/`,
`memories/` and `notifications/` directories. Note that the trip-access and
`canEdit` methods on the domain services are **not** dead weight waiting for a
guard: their callers are overwhelmingly the `*.mcp.ts` tools, which never pass
through an HTTP guard. In the five domains piloted for `TripAccessGuard`, 40 of
@@ -266,8 +269,11 @@ composites kept their wrapper names while the raw settlement writes became
freeze; the 11-tool `mcp/tools/budget.ts` registrar + 3 `resources.ts` budget
resources moved to `budget.mcp.ts`; TripsService, ReservationsService (+ its
MCP class) and BookingImportService inject `BudgetService`; a 4-export
`budget.bridge.ts` serves the legacy tripService/userCleanupService and the
trips/transports registrars; `exchange-rates.bridge.ts` was deleted with its
`budget.bridge.ts` served the legacy tripService/userCleanupService and the
trips/transports registrars (down to two exports since; `UserCleanupService`
still takes `removeUserFromBudgetItems` from it, because BudgetModule imports
AuthModule for BudgetMcp's demo guard and AuthModule therefore cannot import
BudgetModule back); `exchange-rates.bridge.ts` was deleted with its
last consumers, and the controller adopted `budget.dto.ts` — all nine
allow-list entries removed).
tripService followed (the 1121-line Wave-4 hub — the biggest fold — moved into
@@ -463,8 +469,9 @@ passkeyService followed (the last frontier member of the auth stack: the
`PasskeyService` over DatabaseService + injected AuthService — its three
`auth.bridge` imports resolved to `this.auth.generateToken` plus plain
`stripUserForClient`/`avatarUrl` helper imports, and `resolveWebauthnConfig`
stays a plain `services/webauthnConfig` import (the helper also feeds
auth.service's `isPasskeyConfigured`). No MCP registrar, no plugin-host
stayed a plain `services/webauthnConfig` import at the time (it has since
become the injected `WebauthnConfigService`, which also feeds auth.service's
`passkey_configured`). No MCP registrar, no plugin-host
import, and **no bridge at all** — both consumers were already in-container:
`PasskeyController` swapped its `import *` shim for the injection, and
`AdminService` swapped its `adminResetPasskeys` function import for the
+7 -2
View File
@@ -6,6 +6,8 @@ import { SettingsService } from '../settings/settings.service';
import { AuthService } from '../auth/auth.service';
import { PasskeyService } from '../auth/passkey.service';
import { AtlasService } from '../atlas/atlas.service';
import { UserCleanupService } from '../auth/user-cleanup.service';
import { WebauthnConfigService } from '../auth/webauthn-config.service';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
import { PackingService } from '../packing/packing.service';
import { PermissionsService } from '../permissions/permissions.service';
@@ -31,16 +33,19 @@ import { AdminService } from './admin.service';
const dbs = new DatabaseService(db);
const realtime = new RealtimeService();
const permissions = new PermissionsService(dbs);
const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs));
const webauthn = new WebauthnConfigService(dbs);
const userCleanup = new UserCleanupService(dbs);
const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup);
const admin = new AdminService(
dbs,
new SettingsService(dbs),
new AddonsService(dbs),
new PasskeyService(dbs, auth),
new PasskeyService(dbs, auth, webauthn),
new PackingService(dbs, permissions, realtime),
auth,
permissions,
new NotificationsService(dbs, realtime),
userCleanup,
);
export function checkAndNotifyVersion(): Promise<void> {
+3 -2
View File
@@ -20,7 +20,7 @@ import { avatarUrl } from '../common/avatarUrl';
import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from '../llm-parse/llm-config';
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
import { validatePassword } from '../common/passwordPolicy';
import { deleteUserCompletely } from '../../services/userCleanupService';
import { UserCleanupService } from '../auth/user-cleanup.service';
import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService';
import { DatabaseService } from '../database/database.service';
import { AddonsService } from '../addons/addons.service';
@@ -77,6 +77,7 @@ export class AdminService {
private readonly auth: AuthService,
private readonly permissions: PermissionsService,
private readonly notifications: NotificationsService,
private readonly userCleanup: UserCleanupService,
) {}
// ── User CRUD ──────────────────────────────────────────────────────────────
@@ -231,7 +232,7 @@ export class AdminService {
const userToDel = this.db.get<{ id: number; email: string }>('SELECT id, email FROM users WHERE id = ?', id);
if (!userToDel) return { error: 'User not found', status: 404 };
deleteUserCompletely(userToDel.id);
this.userCleanup.deleteUserCompletely(userToDel.id);
emitUserDeleted(userToDel.id); // let plugins erase their own per-user data
return { email: userToDel.email };
}
+12 -4
View File
@@ -4,6 +4,8 @@ import { PermissionsService } from '../permissions/permissions.service';
import { AtlasService } from '../atlas/atlas.service';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
import { AuthService } from './auth.service';
import { UserCleanupService } from './user-cleanup.service';
import { WebauthnConfigService } from './webauthn-config.service';
import { User } from '../../types';
/**
@@ -28,12 +30,18 @@ import { User } from '../../types';
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton (same pattern as permissions.bridge.ts).
* WebauthnConfigService and UserCleanupService are constructed only because
* AuthService takes them — nothing this file exports reaches either one.
*/
const dbs = new DatabaseService(db);
const permissions = new PermissionsService(dbs);
const auth = new AuthService(
new DatabaseService(db),
new PermissionsService(new DatabaseService(db)),
new AtlasService(new DatabaseService(db)),
new TripMembershipService(new DatabaseService(db)),
dbs,
permissions,
new AtlasService(dbs),
new TripMembershipService(dbs),
new WebauthnConfigService(dbs),
new UserCleanupService(dbs),
);
export { stripUserForClient } from './auth.helpers';
+10 -6
View File
@@ -5,6 +5,8 @@ import { AuthController } from './auth.controller';
import { PasskeyController } from './passkey.controller';
import { AuthService } from './auth.service';
import { PasskeyService } from './passkey.service';
import { UserCleanupService } from './user-cleanup.service';
import { WebauthnConfigService } from './webauthn-config.service';
import { AppConfigModule } from '../app-config/app-config.module';
import { AuditModule } from '../audit/audit.module';
import { PermissionsModule } from '../permissions/permissions.module';
@@ -18,15 +20,17 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module'
* sub-paths explicitly rather than claiming all of /api/auth.
*
* PermissionsModule feeds getAppConfig's permissions block; AtlasModule feeds
* getTravelStats' hidden-countries subtraction. AuthService is exported for
* the in-container consumers (the domain *.mcp.ts demo guards, OidcService,
* PasskeyEnabledGuard); PasskeyService for AdminService's passkey reset;
* everything outside the container goes through auth.bridge.ts.
* getTravelStats' hidden-countries subtraction. AuthService is
* exported for the in-container consumers (the domain *.mcp.ts demo guards,
* OidcService, PasskeyEnabledGuard); PasskeyService for AdminService's passkey
* reset; UserCleanupService for the two account-deletion paths (AdminService)
* and the guest deletion in TripsService; everything outside the container goes
* through auth.bridge.ts.
*/
@Module({
imports: [RateLimitModule, AuditModule, PermissionsModule, AtlasModule, TripMembershipModule, AppConfigModule],
controllers: [AuthPublicController, AuthController, PasskeyController],
providers: [AuthService, PasskeyService],
exports: [AuthService, PasskeyService],
providers: [AuthService, PasskeyService, UserCleanupService, WebauthnConfigService],
exports: [AuthService, PasskeyService, UserCleanupService],
})
export class AuthModule {}
+6 -4
View File
@@ -22,7 +22,7 @@ import { createEphemeralToken, startTokenCleanup, stopTokenCleanup } from './eph
// every consumer of this module — a nest→mcp→nest module cycle.
import { revokeUserSessions } from '../../mcp/sessionManager';
import { startTripReminders } from '../../scheduler';
import { deleteUserCompletely } from '../../services/userCleanupService';
import { UserCleanupService } from './user-cleanup.service';
import { emitUserDeleted } from '../../plugin-user-lifecycle';
import { haversineKm } from '../common/geo';
import { verifyJwtAndLoadUser } from './jwt-verify';
@@ -30,7 +30,7 @@ import { User } from '../../types';
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from '../common/demo';
import { avatarUrl } from '../common/avatarUrl';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
import { isPasskeyConfigured } from '../../services/webauthnConfig';
import { WebauthnConfigService } from './webauthn-config.service';
import { setAuthCookie, clearAuthCookie } from '../common/cookie';
import { sendPasswordResetEmail } from '../../services/notifications';
import { getAppUrl } from '../../app-config';
@@ -138,6 +138,8 @@ export class AuthService implements OnModuleInit, OnModuleDestroy {
private readonly permissions: PermissionsService,
private readonly atlas: AtlasService,
private readonly membership: TripMembershipService,
private readonly webauthn: WebauthnConfigService,
private readonly userCleanup: UserCleanupService,
) {}
// Cookie
@@ -279,7 +281,7 @@ export class AuthService implements OnModuleInit, OnModuleDestroy {
// are true. `passkey_configured` stays a pure boolean — it never leaks the
// resolved RP ID / origin / APP_URL on this unauthenticated endpoint.
passkey_login: toggles.passkey_login,
passkey_configured: isPasskeyConfigured(),
passkey_configured: this.webauthn.isConfigured(),
env_override_oidc_only: readEnv().oidc.only,
has_users: userCount > 0,
setup_complete: setupComplete,
@@ -566,7 +568,7 @@ export class AuthService implements OnModuleInit, OnModuleDestroy {
return { error: 'Cannot delete the last admin account', status: 400 };
}
}
deleteUserCompletely(userId);
this.userCleanup.deleteUserCompletely(userId);
emitUserDeleted(userId); // let plugins erase their own per-user data
return { success: true };
}
+6 -5
View File
@@ -7,7 +7,7 @@ import {
verifyAuthenticationResponse,
type AuthenticatorTransportFuture,
} from '@simplewebauthn/server';
import { resolveWebauthnConfig } from '../../services/webauthnConfig';
import { WebauthnConfigService } from './webauthn-config.service';
import { avatarUrl } from '../common/avatarUrl';
import { stripUserForClient } from './auth.helpers';
import { AuthService } from './auth.service';
@@ -94,6 +94,7 @@ export class PasskeyService {
constructor(
private readonly db: DatabaseService,
private readonly auth: AuthService,
private readonly webauthn: WebauthnConfigService,
) {}
// -------------------------------------------------------------------------
@@ -133,7 +134,7 @@ export class PasskeyService {
userId: number,
password: string | undefined,
): Promise<{ error?: string; status?: number; options?: Awaited<ReturnType<typeof generateRegistrationOptions>> }> {
const cfg = resolveWebauthnConfig();
const cfg = this.webauthn.resolve();
if (!cfg) return { ...NOT_CONFIGURED };
const user = this.db.get<User>('SELECT * FROM users WHERE id = ?', userId);
@@ -174,7 +175,7 @@ export class PasskeyService {
userId: number,
body: { attestationResponse?: unknown; name?: unknown },
): Promise<{ error?: string; status?: number; success?: boolean; credential?: unknown }> {
const cfg = resolveWebauthnConfig();
const cfg = this.webauthn.resolve();
if (!cfg) return { ...NOT_CONFIGURED };
const resp = body?.attestationResponse;
@@ -257,7 +258,7 @@ export class PasskeyService {
status?: number;
options?: Awaited<ReturnType<typeof generateAuthenticationOptions>>;
}> {
const cfg = resolveWebauthnConfig();
const cfg = this.webauthn.resolve();
if (!cfg) return { ...NOT_CONFIGURED };
const now = Date.now();
@@ -282,7 +283,7 @@ export class PasskeyService {
auditUserId?: number | null;
auditAction?: string;
}> {
const cfg = resolveWebauthnConfig();
const cfg = this.webauthn.resolve();
if (!cfg) return { ...NOT_CONFIGURED };
const resp = body?.assertionResponse;
@@ -0,0 +1,88 @@
import fs from 'node:fs';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
// budget.bridge, not an injected BudgetService: BudgetModule imports AuthModule
// (BudgetMcp's demo guard), so AuthModule importing BudgetModule back would
// close a module cycle — and breaking it the other way would drag
// BudgetController, BudgetMcp and its trips.bridge/MCP `_shared` chain into
// every graph that imports AuthModule, including the single-domain e2e
// TestingModules. Same documented in-container exception as atlas.mcp.ts.
// The bridge instance runs its SQL over the shared db Proxy, and the re-split
// keeps no state, so the second BudgetService is behaviourally identical.
import { removeUserFromBudgetItems } from '../budget/budget.bridge';
import { pluginsDataRoot } from '../plugins/paths';
/**
* Account erasure — everything that has to happen around `DELETE FROM users`
* and is not covered by a foreign key.
*
* Lives in the auth domain because it owns the `users` table; both deletion
* paths (a user deleting their own account, an admin deleting someone else's)
* go through here, and `TripsService.deleteGuest` reuses the plugin half for a
* guest id. The plugin erasure runs in the CORE deletion path, not via the
* plugin runtime, so it works even when TREK_PLUGINS_ENABLED=false or before
* the runtime has booted — otherwise a deletion in those windows would leave
* the user's plugin data behind forever.
*/
@Injectable()
export class UserCleanupService {
constructor(private readonly db: DatabaseService) {}
/**
* Erase a user's PLUGIN-held data on account deletion. Two parts:
* 1. Host-side per-user plugin tables (encrypted config values, OAuth access/refresh
* tokens, in-flight OAuth state) — deleted directly; these live in trek.db, not in
* a plugin's own db, so nothing else ever removes them.
* 2. A durable erasure row per plugin that holds `hook:user-data`, so its OWN db is
* purged of the user (drained to the plugin when it is next active).
*
* Best-effort per table so a slimmed-down schema (some tests) can't fail the user
* deletion itself.
*/
erasePluginUserData(userId: number): void {
for (const table of ['plugin_user_config', 'plugin_oauth_tokens', 'plugin_oauth_state']) {
try { this.db.run(`DELETE FROM ${table} WHERE user_id = ?`, userId); } catch { /* table absent (slim schema) */ }
}
try {
const rows = this.db.all<{ id: string; permissions: string | null }>('SELECT id, permissions FROM plugins');
const installed = new Set(rows.map((r) => r.id));
const insert = this.db.prepare('INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)');
for (const r of rows) {
let perms: unknown;
try { perms = JSON.parse(r.permissions ?? '[]'); } catch { perms = []; }
if (Array.isArray(perms) && perms.includes('hook:user-data')) insert.run(r.id, userId);
}
// Also enqueue for plugins UNINSTALLED with their data retained (deleteData=false):
// their data dir still holds the user's rows and a same-id reinstall would re-adopt
// them. No permissions record survives uninstall, so we can't check hook:user-data —
// enqueue for every orphan data dir; the row sits inert (no FK) and drains only if
// that id is reinstalled + active (erasure delivery is a duty, not grant-gated).
try {
for (const entry of fs.readdirSync(pluginsDataRoot(), { withFileTypes: true })) {
if (entry.isDirectory() && !installed.has(entry.name)) insert.run(entry.name, userId);
}
} catch { /* no plugin data root yet */ }
} catch { /* plugins / queue table absent (slim schema) */ }
}
private cleanupUserReferences(userId: number): void {
this.db.run('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?', userId);
this.db.run('UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?', userId);
removeUserFromBudgetItems(userId);
this.db.run('DELETE FROM share_tokens WHERE created_by = ?', userId);
this.db.run('DELETE FROM journey_share_tokens WHERE created_by = ?', userId);
// Owned journeys cascade-delete their entries/contributors/share_tokens/photos via journey_id FKs
this.db.run('DELETE FROM journeys WHERE user_id = ?', userId);
// Entries authored on other users' journeys (not covered by the cascade above)
this.db.run('DELETE FROM journey_entries WHERE author_id = ?', userId);
this.db.run('DELETE FROM journey_contributors WHERE user_id = ?', userId);
}
deleteUserCompletely(userId: number): void {
this.db.transaction(() => {
this.cleanupUserReferences(userId);
this.erasePluginUserData(userId);
this.db.run('DELETE FROM users WHERE id = ?', userId);
});
}
}
@@ -0,0 +1,95 @@
import { Injectable } from '@nestjs/common';
import { getAppUrl, readEnv } from '../../app-config';
import { DatabaseService } from '../database/database.service';
function hostOf(url: string): string | null {
try {
return new URL(url).hostname || null;
} catch {
return null;
}
}
/** WebAuthn RP IDs must be registrable domains — never bare IP literals. */
function isIpHost(host: string): boolean {
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true; // IPv4
if (host.includes(':')) return true; // IPv6 (hostname keeps the colons)
return false;
}
export interface WebauthnConfig {
rpID: string;
rpName: string;
/** Exact allowed origins (scheme + host + port). One in prod; localhost dev adds the Vite/API ports. */
origins: string[];
}
/**
* Resolves the WebAuthn Relying Party ID + allowed origins for this deployment.
*
* SECURITY: the RP ID and the allowed origins are derived ONLY from server-side
* configuration — the `webauthn_rp_id` / `webauthn_origins` admin settings (or
* the matching env vars), falling back to APP_URL. They are NEVER taken from the
* request `Host` / `X-Forwarded-Host` header: a forged forwarded host would
* otherwise let an attacker bind credentials to a domain they control, or brick
* every enrolled user. This mirrors how OIDC derives its redirect URI from
* APP_URL (oidc.controller.ts) rather than from request input.
*
* Nothing is cached: `readEnv()` and the app-settings row are read on every
* resolve so an operator flipping the RP ID at runtime takes effect on the next
* ceremony instead of after a restart.
*/
@Injectable()
export class WebauthnConfigService {
constructor(private readonly db: DatabaseService) {}
private setting(key: string): string | null {
const raw = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value;
const trimmed = raw?.trim();
return trimmed ? trimmed : null;
}
/**
* Returns null when no usable RP ID can be resolved (bare IP host, or nothing
* configured) — the feature then reports itself as "not configured" and stays
* disabled so nobody can enrol a credential bound to the wrong origin.
*/
resolve(): WebauthnConfig | null {
// 1. Explicit operator config always wins.
const explicitRpId = (readEnv().webauthn.rpId || this.setting('webauthn_rp_id'))?.trim() || null;
const explicitOrigins = (readEnv().webauthn.origins || this.setting('webauthn_origins') || '')
.split(',')
.map((o) => o.trim().replace(/\/+$/, ''))
.filter(Boolean);
const appUrl = getAppUrl();
const appHost = hostOf(appUrl);
// 2. Derive the RP ID from APP_URL when not explicitly set.
let rpID = explicitRpId;
if (!rpID && appHost && !isIpHost(appHost)) {
rpID = appHost; // a real domain, or "localhost"
}
if (!rpID) return null; // bare IP / unresolved → WebAuthn cannot be used here
// 3. Resolve the allowed origins. Explicit list wins verbatim (operator's
// responsibility). Otherwise derive a SINGLE origin from APP_URL — we never
// silently union dev localhost origins into a production allow-list.
let origins = explicitOrigins;
if (origins.length === 0) {
if (appHost) origins = [appUrl.replace(/\/+$/, '')];
if (rpID === 'localhost') {
// Dev: the browser origin is the Vite dev server (:5173), not the API port.
origins = Array.from(new Set([...origins, 'http://localhost:5173', 'http://localhost:3001']));
}
}
if (origins.length === 0) return null;
return { rpID, rpName: 'TREK', origins };
}
/** True when a usable RP ID resolves for this deployment (exposed as a pure boolean on app-config). */
isConfigured(): boolean {
return this.resolve() !== null;
}
}
+7 -5
View File
@@ -6,17 +6,19 @@ import { PermissionsService } from '../permissions/permissions.service';
import { ExchangeRatesService } from './exchange-rates.service';
/**
* Non-Nest entry point for the budget domain — for code running OUTSIDE the
* Nest container (services/userCleanupService.ts and the legacy
* create_transport registrar in src/mcp/tools/transports.ts; the budget MCP
* Non-Nest entry point for the budget domain — for the legacy create_transport
* registrar in src/mcp/tools/transports.ts, plus one in-container exception:
* UserCleanupService (nest/auth) takes removeUserFromBudgetItems from here
* because BudgetModule imports AuthModule for BudgetMcp's demo guard, so
* AuthModule cannot import BudgetModule back — see the note there. (The budget MCP
* tools and resources moved to the DI-discovered budget.mcp.ts, the plugin
* RPC host injects BudgetService via PluginHostDepsFactory, and the trip
* domain folded into the DI-native TripsService, which injects BudgetService —
* its bridge exports were pruned with it). Exports only the legacy
* services/budgetService names still consumed outside the container, 1:1, so
* repointing a consumer is an import-path-only diff. Inside the container,
* inject BudgetService instead. Delete this file when
* userCleanupService and the transports registrar migrate.
* inject BudgetService instead. Delete this file when the transports registrar
* migrates and BudgetMcp stops needing AuthService.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton, and ExchangeRatesService keeps its FX
+2 -2
View File
@@ -8,11 +8,11 @@ import {
WikiNotFound,
type WikiPage,
type WikiNavSection,
} from '../../services/wikiService';
} from './wiki';
/**
* /api/help — embedded TREK wiki, served from the `wiki/` directory that ships
* with the app (see wikiService for the GitHub fallback). Content is public docs,
* with the app (see wiki.ts for the GitHub fallback). Content is public docs,
* so these endpoints are unauthenticated; that also lets <img> tags load the
* proxied assets without sending credentials.
*/
+1 -1
View File
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { HelpController } from './help.controller';
/** /api/help — the bundled `wiki/` directory, read via wikiService. */
/** /api/help — the bundled `wiki/` directory, read via ./wiki. */
@Module({
controllers: [HelpController],
})
@@ -1,6 +1,6 @@
import { existsSync, promises as fs } from 'fs';
import path from 'path';
import { readEnv } from '../app-config';
import { readEnv } from '../../app-config';
/**
* In-app Help/Wiki content, sourced from the `wiki/**` directory that ships with
@@ -20,12 +20,13 @@ const TTL_MS = 60 * 60 * 1000; // remote fallback only: refresh from GitHub at m
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
/**
* `server/{src,dist}/services` both sit three levels under the repo root, so this
* `server/{src,dist}/nest/help` both sit four levels under the repo root, so this
* one anchor resolves in dev, a built source install, vitest, and Docker (where
* the Dockerfile copies `wiki/` to /app/wiki). `process.cwd()` would not — Docker
* runs the server from /app/server.
* runs the server from /app/server. The depth is counted from THIS file: it was
* three while the module lived in `src/services`.
*/
const WIKI_DIR = readEnv().paths.wikiDir ?? path.join(__dirname, '..', '..', '..', 'wiki');
const WIKI_DIR = readEnv().paths.wikiDir ?? path.join(__dirname, '..', '..', '..', '..', 'wiki');
/**
* Probe for the sidebar rather than the bare directory: an empty or half-copied
@@ -8,7 +8,7 @@ import { OptionalJwtGuard } from '../auth/optional-jwt.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../audit/client-ip';
import type { User } from '../../types';
import type { AuthorizeParams } from '../../services/oauthService';
import type { AuthorizeParams } from './oauth.service';
const MIN = 60_000;
+59
View File
@@ -0,0 +1,59 @@
import { db } from '../../db/database';
import { AddonsService } from '../addons/addons.service';
import { AuditService } from '../audit/audit.service';
import { DatabaseService } from '../database/database.service';
import { OauthService } from './oauth.service';
/**
* Non-Nest entry point for the OAuth domain — for code running OUTSIDE the
* Nest container: the MCP transport (src/mcp/index.ts token verification) and
* the SDK provider adapter (src/mcp/oauthProvider.ts), which
* platform.routes.ts mounts on the Express app BEFORE app.init(), so
* `app.get(OauthService)` is not available to it. Exports the legacy
* services/oauthService names 1:1 so repointing a consumer is an
* import-path-only diff. Inside the container, inject OauthService instead.
* Delete this file when the SDK mount moves behind the container.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton. The one piece of shared state — the
* pending authorization codes — is module-scoped in oauth.pending-codes.ts, so
* this instance and the container singleton see the same map. That is load-
* bearing: the consent controller writes the code through the container and
* the SDK reads it back through here.
*/
const dbs = new DatabaseService(db);
const oauth = new OauthService(dbs, new AddonsService(dbs), new AuditService(dbs));
export type { OAuthTokenInfo, PendingCode } from './oauth.service';
export function getUserByAccessToken(rawToken: string) {
return oauth.getUserByAccessToken(rawToken);
}
export function createOAuthClient(...args: Parameters<OauthService['createOAuthClient']>) {
return oauth.createOAuthClient(...args);
}
export function createAuthCode(params: Parameters<OauthService['createAuthCode']>[0]) {
return oauth.createAuthCode(params);
}
export function consumeAuthCode(code: string) {
return oauth.consumeAuthCode(code);
}
export function issueTokens(...args: Parameters<OauthService['issueTokens']>) {
return oauth.issueTokens(...args);
}
export function refreshTokens(...args: Parameters<OauthService['refreshTokens']>) {
return oauth.refreshTokens(...args);
}
export function revokeToken(...args: Parameters<OauthService['revokeToken']>) {
return oauth.revokeToken(...args);
}
export function verifyPKCE(codeVerifier: string, codeChallenge: string): boolean {
return oauth.verifyPKCE(codeVerifier, codeChallenge);
}
+70
View File
@@ -0,0 +1,70 @@
import crypto, { createHash, randomBytes } from 'crypto';
/** Pure helpers and row shapes for the OAuth 2.1 domain — no DB, no DI. */
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const ACCESS_TOKEN_TTL_S = 60 * 60; // 1 hour
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days rolling
// PKCE format (RFC 7636)
export const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/;
export const CODE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
// ---------------------------------------------------------------------------
// DB row types
// ---------------------------------------------------------------------------
export interface OAuthClientRow {
id: string;
user_id: number;
name: string;
client_id: string;
client_secret_hash: string;
redirect_uris: string; // JSON array
allowed_scopes: string; // JSON array
created_at: string;
is_public: number; // 0 | 1 (SQLite boolean)
created_via: string; // 'settings_ui' | 'browser-registration'
allows_client_credentials: number; // 0 | 1
}
export interface OAuthTokenRow {
id: number;
client_id: string;
user_id: number;
access_token_hash: string;
refresh_token_hash: string;
scopes: string; // JSON array
audience: string | null;
access_token_expires_at: string;
refresh_token_expires_at: string;
revoked_at: string | null;
parent_token_id: number | null;
}
// ---------------------------------------------------------------------------
// Token helpers
// ---------------------------------------------------------------------------
export function hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
/** Constant-time comparison of two hex-encoded SHA-256 hashes. */
export function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
} catch { return false; }
}
export function generateAccessToken(): string {
return 'trekoa_' + randomBytes(32).toString('hex');
}
export function generateRefreshToken(): string {
return 'trekrf_' + randomBytes(32).toString('hex');
}
+7 -3
View File
@@ -8,9 +8,13 @@ import { AddonsModule } from '../addons/addons.module';
/**
* OAuth 2.1 server (MCP). Public token/userinfo/revoke endpoints + the SPA's
* authenticated consent/client/session management. The SDK-mounted
* /oauth/authorize, /oauth/register and /oauth/consent stay on Express, so the
* strangler lists /oauth/token, /oauth/userinfo, /oauth/revoke explicitly.
* authenticated consent/client/session management. /oauth/authorize,
* /oauth/register and /oauth/consent are still served by the MCP SDK's own
* router, mounted on the Express platform before app.init() — that path
* reaches the domain through oauth.bridge.ts, which is why the pending
* authorization codes live in a module, not on the provider.
*
* No `exports`: nothing inside the container injects this domain.
*/
@Module({
imports: [RateLimitModule, AuditModule, AddonsModule],
@@ -0,0 +1,55 @@
/**
* The short-lived authorization-code store.
*
* Module-scoped + import-time interval on purpose (legacy parity, shared
* between bridge and container instances — atlas-geo interval precedent).
* This is not a stylistic choice: the SDK-mounted /oauth/authorize path reads
* a code through oauth.bridge (mcp/oauthProvider.ts) that the Nest consent
* controller wrote through the container singleton. Two instances would mean
* two maps, and the authorization-code flow would fail *silently* — the SDK
* only ever answers "Authorization grant is invalid."
*
* No DB persistence: codes live two minutes and a restart invalidating them
* is the correct behaviour.
*/
export interface PendingCode {
clientId: string;
userId: number;
redirectUri: string;
scopes: string[];
resource: string | null;
codeChallenge: string;
codeChallengeMethod: 'S256';
expiresAt: number;
}
export const MAX_PENDING_CODES = 500;
export const AUTH_CODE_TTL_MS = 2 * 60 * 1000; // 2 minutes
const pendingCodes = new Map<string, PendingCode>();
/** Drop everything past its TTL. Named so the interval body is reachable from a test. */
export function sweepPendingCodes(now = Date.now()): void {
for (const [key, entry] of pendingCodes) {
if (now > entry.expiresAt) pendingCodes.delete(key);
}
}
setInterval(() => sweepPendingCodes(), 60_000).unref();
/** Returns false when the store is at capacity — the caller answers with a null code. */
export function putPendingCode(code: string, entry: PendingCode): boolean {
if (pendingCodes.size >= MAX_PENDING_CODES) return false;
pendingCodes.set(code, entry);
return true;
}
/** Single-use: the entry is removed even when it turns out to be expired. */
export function takePendingCode(code: string): PendingCode | null {
const entry = pendingCodes.get(code);
if (!entry) return null;
pendingCodes.delete(code);
if (Date.now() > entry.expiresAt) return null;
return entry;
}
+629 -23
View File
@@ -1,38 +1,644 @@
import crypto, { randomBytes, randomUUID } from 'crypto';
import { Injectable } from '@nestjs/common';
import * as oauth from '../../services/oauthService';
import { AddonsService } from '../addons/addons.service';
import { ADDON_IDS } from '../../addons';
import { getMcpSafeUrl } from '../../app-config';
// Import from scopes/sessionManager directly, NOT the ../../mcp barrel: the
// barrel pulls the whole tools fan-out (and via the domain bridges, the Nest
// services) into every consumer of this module — a nest→mcp→nest module cycle.
// Both files themselves are back-edge free (scopes has no imports at all,
// sessionManager only the MCP SDK).
import { validateScopes } from '../../mcp/scopes';
import { revokeUserSessionsForClient } from '../../mcp/sessionManager';
import { User } from '../../types';
import { AddonsService } from '../addons/addons.service';
import { AuditService } from '../audit/audit.service';
import { logWarn } from '../audit/audit-log.logger';
import { DatabaseService } from '../database/database.service';
import {
ACCESS_TOKEN_TTL_S,
CODE_CHALLENGE_RE,
CODE_VERIFIER_RE,
REFRESH_TOKEN_TTL_MS,
generateAccessToken,
generateRefreshToken,
hashToken,
timingSafeEqualHex,
type OAuthClientRow,
type OAuthTokenRow,
} from './oauth.helpers';
import { AUTH_CODE_TTL_MS, putPendingCode, takePendingCode, type PendingCode } from './oauth.pending-codes';
export type { OAuthClientRow, OAuthTokenRow } from './oauth.helpers';
export type { PendingCode } from './oauth.pending-codes';
export interface OAuthTokenInfo {
user: User;
scopes: string[];
clientId: string;
audience: string | null;
}
export interface AuthorizeParams {
response_type: string;
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
code_challenge: string;
code_challenge_method: string;
resource?: string;
}
export interface ValidateAuthorizeResult {
valid: boolean;
error?: string;
error_description?: string;
client?: { name: string; allowed_scopes: string[] };
scopes?: string[];
resource?: string | null;
/** true when user is logged in but consent UI must be shown */
consentRequired?: boolean;
/** true when the request is valid but user is not authenticated */
loginRequired?: boolean;
/** true when the client was registered via machine DCR — user may adjust scopes on the consent screen */
scopeSelectable?: boolean;
}
/**
* Thin Nest wrapper around the existing OAuth 2.1 service. The grant handling,
* PKCE, client auth, consent storage, token issue/refresh/revoke and the
* client/session CRUD all reuse the legacy code unchanged.
* OAuth 2.1 server for MCP: client management, the authorization-code grant
* with PKCE, consent storage, token issue/refresh/revoke with rotation and
* replay detection, and the client_credentials grant.
*
* The authorization-code store lives in oauth.pending-codes.ts rather than on
* this instance — the SDK-mounted /oauth/authorize path reaches it through
* oauth.bridge.ts, outside the container, and must see the same map.
*/
@Injectable()
export class OauthService {
constructor(private readonly addons: AddonsService) {}
constructor(
private readonly db: DatabaseService,
private readonly addons: AddonsService,
private readonly audit: AuditService,
) {}
mcpEnabled(): boolean { return this.addons.isAddonEnabled(ADDON_IDS.MCP); }
mcpSafeUrl(): string { return getMcpSafeUrl(); }
consumeAuthCode(code: string) { return oauth.consumeAuthCode(code); }
authenticateClient(clientId: string, clientSecret?: string) { return oauth.authenticateClient(clientId, clientSecret); }
verifyPKCE(verifier: string, challenge: string) { return oauth.verifyPKCE(verifier, challenge); }
issueTokens(...args: Parameters<typeof oauth.issueTokens>) { return oauth.issueTokens(...args); }
issueClientCredentialsToken(...args: Parameters<typeof oauth.issueClientCredentialsToken>) { return oauth.issueClientCredentialsToken(...args); }
refreshTokens(...args: Parameters<typeof oauth.refreshTokens>) { return oauth.refreshTokens(...args); }
revokeToken(...args: Parameters<typeof oauth.revokeToken>) { return oauth.revokeToken(...args); }
getUserByAccessToken(token: string) { return oauth.getUserByAccessToken(token); }
// -------------------------------------------------------------------------
// Client management (self-service, gated by MCP addon)
// -------------------------------------------------------------------------
validateAuthorizeRequest(params: oauth.AuthorizeParams, userId: number | null) { return oauth.validateAuthorizeRequest(params, userId); }
saveConsent(...args: Parameters<typeof oauth.saveConsent>) { return oauth.saveConsent(...args); }
createAuthCode(...args: Parameters<typeof oauth.createAuthCode>) { return oauth.createAuthCode(...args); }
listOAuthClients(userId: number): Record<string, unknown>[] {
const rows = this.db.all<OAuthClientRow>(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC',
userId,
);
return rows.map(r => ({
...r,
is_public: Boolean(r.is_public),
allows_client_credentials: Boolean(r.allows_client_credentials),
redirect_uris: JSON.parse(r.redirect_uris),
allowed_scopes: JSON.parse(r.allowed_scopes),
}));
}
listOAuthClients(userId: number) { return oauth.listOAuthClients(userId); }
createOAuthClient(...args: Parameters<typeof oauth.createOAuthClient>) { return oauth.createOAuthClient(...args); }
rotateOAuthClientSecret(userId: number, id: string, ip: string | undefined) { return oauth.rotateOAuthClientSecret(userId, id, ip); }
deleteOAuthClient(userId: number, id: string, ip: string | undefined) { return oauth.deleteOAuthClient(userId, id, ip); }
listOAuthSessions(userId: number) { return oauth.listOAuthSessions(userId); }
revokeSession(userId: number, id: number, ip: string | undefined) { return oauth.revokeSession(userId, id, ip); }
createOAuthClient(
userId: number | null,
name: string,
redirectUris: string[],
allowedScopes: string[],
ip?: string | null,
options?: { isPublic?: boolean; createdVia?: string; allowsClientCredentials?: boolean },
): { error?: string; status?: number; client?: Record<string, unknown> } {
if (!name?.trim()) return { error: 'Name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
const isMachineClient = Boolean(options?.allowsClientCredentials);
if (!isMachineClient && (!redirectUris || redirectUris.length === 0)) return { error: 'At least one redirect URI is required', status: 400 };
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
for (const uri of redirectUris) {
let parsed: URL;
try {
parsed = new URL(uri);
} catch {
return { error: `Invalid redirect URI: ${uri}`, status: 400 };
}
if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
return { error: `Redirect URI must use HTTPS (localhost exempt): ${uri}`, status: 400 };
}
}
if (!allowedScopes || allowedScopes.length === 0) return { error: 'At least one scope is required', status: 400 };
const { valid, invalid } = validateScopes(allowedScopes);
if (!valid) return { error: `Invalid scopes: ${invalid.join(', ')}`, status: 400 };
if (userId !== null) {
const count = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id = ?', userId)!.count;
if (count >= 10) return { error: 'Maximum of 10 OAuth clients per user', status: 400 };
} else {
// Anonymous DCR clients: enforce a global cap to prevent unbounded registration abuse
const count = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id IS NULL')!.count;
if (count >= 500) return { error: 'server_error', status: 503 };
}
// Machine clients (client_credentials) must always be confidential — ignore isPublic for them.
const isPublic = isMachineClient ? false : (options?.isPublic ?? false);
const createdVia = options?.createdVia ?? 'settings_ui';
const id = randomUUID();
const clientId = randomUUID();
// Public clients have no usable secret; store an opaque random value to satisfy NOT NULL.
const rawSecret = isPublic ? null : 'trekcs_' + randomBytes(24).toString('hex');
const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex');
this.db.run(
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via, allows_client_credentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia, isMachineClient ? 1 : 0,
);
const row = this.db.get<OAuthClientRow>(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE id = ?',
id,
)!;
this.audit.writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip });
return {
client: {
id: row.id,
user_id: row.user_id,
name: row.name,
client_id: row.client_id,
redirect_uris: JSON.parse(row.redirect_uris),
allowed_scopes: JSON.parse(row.allowed_scopes),
created_at: row.created_at,
is_public: Boolean(row.is_public),
allows_client_credentials: Boolean(row.allows_client_credentials),
created_via: row.created_via,
// client_secret only present for confidential clients — shown once, not stored in plain text
...(rawSecret ? { client_secret: rawSecret } : {}),
},
};
}
rotateOAuthClientSecret(
userId: number,
clientRowId: string,
ip?: string | null,
): { error?: string; status?: number; client_secret?: string } {
const row = this.db.get<OAuthClientRow>('SELECT id, client_id, is_public FROM oauth_clients WHERE id = ? AND user_id = ?', clientRowId, userId);
if (!row) return { error: 'Client not found', status: 404 };
if (row.is_public) return { error: 'Public clients do not use a client secret', status: 400 };
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex');
const secretHash = hashToken(rawSecret);
this.db.run('UPDATE oauth_clients SET client_secret_hash = ? WHERE id = ?', secretHash, clientRowId);
// Revoke all existing tokens for this client so old sessions are invalidated
this.db.run("UPDATE oauth_tokens SET revoked_at = datetime('now') WHERE client_id = ? AND revoked_at IS NULL", row.client_id);
// Terminate active MCP sessions for this (user, client) pair
revokeUserSessionsForClient(userId, row.client_id);
this.audit.writeAudit({ userId, action: 'oauth.client.rotate_secret', details: { client_id: row.client_id }, ip });
return { client_secret: rawSecret };
}
deleteOAuthClient(
userId: number,
clientRowId: string,
ip?: string | null,
): { error?: string; status?: number; success?: boolean } {
const row = this.db.get<OAuthClientRow>('SELECT id, client_id FROM oauth_clients WHERE id = ? AND user_id = ?', clientRowId, userId);
if (!row) return { error: 'Client not found', status: 404 };
this.db.run('DELETE FROM oauth_clients WHERE id = ?', clientRowId);
this.audit.writeAudit({ userId, action: 'oauth.client.delete', details: { client_id: row.client_id }, ip });
return { success: true };
}
// -------------------------------------------------------------------------
// Auth code (in-memory, 2-minute TTL)
// -------------------------------------------------------------------------
createAuthCode(params: {
clientId: string;
userId: number;
redirectUri: string;
scopes: string[];
resource: string | null;
codeChallenge: string;
codeChallengeMethod: 'S256';
}): string | null {
const rawCode = randomBytes(32).toString('hex');
const stored = putPendingCode(rawCode, { ...params, expiresAt: Date.now() + AUTH_CODE_TTL_MS });
return stored ? rawCode : null;
}
consumeAuthCode(code: string): PendingCode | null {
return takePendingCode(code);
}
// -------------------------------------------------------------------------
// Consent management
// -------------------------------------------------------------------------
getConsent(clientId: string, userId: number): string[] | null {
const row = this.db.get<{ scopes: string }>(
'SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?', clientId, userId,
);
return row ? JSON.parse(row.scopes) : null;
}
saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): void {
// Union existing consent with newly approved scopes (M5: never narrow stored consent)
const existing = this.getConsent(clientId, userId) ?? [];
const merged = Array.from(new Set([...existing, ...scopes]));
this.db.run(
'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)',
clientId, userId, JSON.stringify(merged),
);
this.audit.writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip });
}
isConsentSufficient(existingScopes: string[], requestedScopes: string[]): boolean {
return requestedScopes.every(s => existingScopes.includes(s));
}
// -------------------------------------------------------------------------
// Token issuance
// -------------------------------------------------------------------------
issueTokens(
clientId: string,
userId: number,
scopes: string[],
parentTokenId: number | null = null,
audience: string | null = null,
): {
access_token: string;
refresh_token: string;
token_type: 'Bearer';
expires_in: number;
scope: string;
} {
const rawAccess = generateAccessToken();
const rawRefresh = generateRefreshToken();
const accessHash = hashToken(rawAccess);
const refreshHash = hashToken(rawRefresh);
const now = new Date();
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
const refreshExpiry = new Date(now.getTime() + REFRESH_TOKEN_TTL_MS);
this.db.run(`
INSERT INTO oauth_tokens
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId);
return {
access_token: rawAccess,
refresh_token: rawRefresh,
token_type: 'Bearer',
expires_in: ACCESS_TOKEN_TTL_S,
scope: scopes.join(' '),
};
}
/**
* Issues an access token only — no refresh token (RFC 6749 §4.4.3).
* Used exclusively for the client_credentials grant. A random opaque hash is
* stored in refresh_token_hash to satisfy the NOT NULL/UNIQUE constraint; it
* can never be presented as a valid refresh token (same precedent as public
* client secret hashes stored in client_secret_hash).
*/
issueClientCredentialsToken(
clientId: string,
userId: number,
scopes: string[],
audience: string,
): {
access_token: string;
token_type: 'Bearer';
expires_in: number;
scope: string;
} {
const rawAccess = generateAccessToken();
const accessHash = hashToken(rawAccess);
const placeholderHash = randomBytes(32).toString('hex');
const now = new Date();
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
this.db.run(`
INSERT INTO oauth_tokens
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, clientId, userId, accessHash, placeholderHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), now.toISOString(), null);
return {
access_token: rawAccess,
token_type: 'Bearer',
expires_in: ACCESS_TOKEN_TTL_S,
scope: scopes.join(' '),
};
}
// -------------------------------------------------------------------------
// Token verification (used by MCP handler on every request)
// -------------------------------------------------------------------------
getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
const hash = hashToken(rawToken);
const row = this.db.get<OAuthTokenRow & { username: string; email: string; role: string }>(`
SELECT ot.scopes, ot.audience, ot.revoked_at, ot.access_token_expires_at,
ot.user_id, ot.client_id, u.username, u.email, u.role
FROM oauth_tokens ot
JOIN users u ON ot.user_id = u.id
WHERE ot.access_token_hash = ?
`, hash);
if (!row) return null;
if (row.revoked_at) return null;
if (new Date(row.access_token_expires_at) < new Date()) return null;
return {
user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' },
scopes: JSON.parse(row.scopes),
clientId: row.client_id,
audience: row.audience ?? null,
};
}
// -------------------------------------------------------------------------
// Token refresh (rotation + replay detection)
// -------------------------------------------------------------------------
/** Walk parent_token_id upward to find the root token id of this rotation chain. */
private findChainRoot(tokenId: number): number {
let current = tokenId;
for (let i = 0; i < 100; i++) {
const row = this.db.get<{ id: number; parent_token_id: number | null }>('SELECT id, parent_token_id FROM oauth_tokens WHERE id = ?', current);
if (!row || row.parent_token_id === null) return current;
current = row.parent_token_id;
}
return current;
}
/** Revoke all tokens in the rotation chain rooted at rootId. Returns affected ids. */
private revokeChain(rootId: number): number[] {
const rows = this.db.all<{ id: number }>(`
WITH RECURSIVE chain(id) AS (
SELECT id FROM oauth_tokens WHERE id = ?
UNION ALL
SELECT t.id FROM oauth_tokens t JOIN chain c ON t.parent_token_id = c.id
)
SELECT id FROM chain
`, rootId);
const ids = rows.map(r => r.id);
if (ids.length > 0) {
this.db.run(
`UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id IN (${ids.map(() => '?').join(',')}) AND revoked_at IS NULL`,
...ids,
);
}
return ids;
}
refreshTokens(
rawRefreshToken: string,
clientId: string,
clientSecret: string | undefined,
ip?: string | null,
): { error?: string; status?: number; tokens?: ReturnType<OauthService['issueTokens']> } {
const client = this.db.get<OAuthClientRow>('SELECT client_id, client_secret_hash, is_public FROM oauth_clients WHERE client_id = ?', clientId);
if (!client) return { error: 'invalid_client', status: 401 };
if (!client.is_public) {
if (!clientSecret || !timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) {
return { error: 'invalid_client', status: 401 };
}
}
const hash = hashToken(rawRefreshToken);
const row = this.db.get<OAuthTokenRow>(`
SELECT id, client_id, user_id, scopes, audience, refresh_token_expires_at, revoked_at, parent_token_id
FROM oauth_tokens WHERE refresh_token_hash = ?
`, hash);
if (!row) return { error: 'invalid_grant', status: 400 };
if (row.client_id !== clientId) return { error: 'invalid_grant', status: 400 };
// ---- Replay detection (C3) ----
if (row.revoked_at) {
// A revoked refresh token was replayed — assume token theft. Cascade-revoke the chain.
const rootId = this.findChainRoot(row.id);
this.revokeChain(rootId);
revokeUserSessionsForClient(row.user_id, clientId);
this.audit.writeAudit({
userId: row.user_id,
action: 'oauth.token.replay_detected',
details: { client_id: clientId },
ip,
});
logWarn(`[OAuth] Refresh token replay detected for user=${row.user_id} client=${clientId} ip=${ip ?? '-'}`);
return { error: 'invalid_grant', status: 400 };
}
if (new Date(row.refresh_token_expires_at) < new Date()) return { error: 'invalid_grant', status: 400 };
// Revoke old pair immediately (rotation) and issue new pair linked to old row.
// Do NOT revoke active MCP sessions here: a legitimate refresh isn't a security
// event (that's handled above, in the replay-detection branch), and mcpHandler
// already re-validates session.userId/clientId against the new token on every
// request. Killing the session on every routine hourly refresh broke long-lived
// MCP connections (#1475).
this.db.run('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?', row.id);
const tokens = this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
this.audit.writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
return { tokens };
}
// -------------------------------------------------------------------------
// Token revocation
// -------------------------------------------------------------------------
revokeToken(rawToken: string, clientId: string, userId?: number, ip?: string | null): void {
const hash = hashToken(rawToken);
// Get the user_id for the token so we can revoke its MCP sessions
const row = this.db.get<{ user_id: number }>(
'SELECT user_id FROM oauth_tokens WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?',
hash, hash, clientId,
);
this.db.run(`
UPDATE oauth_tokens
SET revoked_at = CURRENT_TIMESTAMP
WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?
`, hash, hash, clientId);
const affectedUserId = row?.user_id ?? userId;
if (affectedUserId) {
revokeUserSessionsForClient(affectedUserId, clientId);
this.audit.writeAudit({ userId: affectedUserId, action: 'oauth.token.revoke', details: { client_id: clientId, method: 'token' }, ip });
}
}
// -------------------------------------------------------------------------
// Active session listing (for user settings page)
// -------------------------------------------------------------------------
listOAuthSessions(userId: number): Record<string, unknown>[] {
const rows = this.db.all<Record<string, unknown>>(`
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.scopes,
ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
FROM oauth_tokens ot
JOIN oauth_clients oc ON ot.client_id = oc.client_id
WHERE ot.user_id = ?
AND ot.revoked_at IS NULL
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
ORDER BY ot.created_at DESC
`, userId);
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes as string) }));
}
revokeSession(
userId: number,
sessionId: number,
ip?: string | null,
): { error?: string; status?: number; success?: boolean } {
const row = this.db.get<{ id: number; client_id: string }>('SELECT id, client_id FROM oauth_tokens WHERE id = ? AND user_id = ?', sessionId, userId);
if (!row) return { error: 'Session not found', status: 404 };
this.db.run('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?', sessionId);
revokeUserSessionsForClient(userId, row.client_id);
this.audit.writeAudit({ userId, action: 'oauth.token.revoke', details: { client_id: row.client_id, method: 'session' }, ip });
return { success: true };
}
// -------------------------------------------------------------------------
// Authorize request validation (option A: called by SPA via GET /api/oauth/authorize/validate)
// -------------------------------------------------------------------------
validateAuthorizeRequest(
params: AuthorizeParams,
userId: number | null,
): ValidateAuthorizeResult {
if (!this.addons.isAddonEnabled(ADDON_IDS.MCP)) {
return { valid: false, error: 'mcp_disabled', error_description: 'MCP is not enabled on this server' };
}
if (params.response_type !== 'code') {
return { valid: false, error: 'unsupported_response_type', error_description: 'Only response_type=code is supported' };
}
if (!params.code_challenge || params.code_challenge_method !== 'S256') {
return { valid: false, error: 'invalid_request', error_description: 'PKCE with code_challenge_method=S256 is required (OAuth 2.1)' };
}
// H1: Enforce code_challenge format (RFC 7636 §4.2)
if (!CODE_CHALLENGE_RE.test(params.code_challenge)) {
return { valid: false, error: 'invalid_request', error_description: 'code_challenge must be 43 base64url characters (S256)' };
}
if (!params.client_id) {
return { valid: false, error: 'invalid_request', error_description: 'client_id is required' };
}
const client = this.db.get<OAuthClientRow>('SELECT * FROM oauth_clients WHERE client_id = ?', params.client_id);
if (!client) {
return { valid: false, error: 'invalid_client', error_description: 'Unknown client_id' };
}
const allowedUris: string[] = JSON.parse(client.redirect_uris);
if (!params.redirect_uri || !allowedUris.includes(params.redirect_uri)) {
return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' };
}
// RFC 8707 resource indicator: if provided, must identify the TREK
// MCP endpoint exactly. If the client didn't supply `resource`, we
// bind the token to the MCP endpoint by default — previously this
// left `audience = null`, and the audience-bind check on MCP requests
// then treated a null audience as "valid for any resource".
const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
const resource = params.resource
? params.resource.replace(/\/+$/, '')
: mcpResource;
if (resource !== mcpResource) {
return { valid: false, error: 'invalid_target', error_description: 'Requested resource must be the TREK MCP endpoint' };
}
const requestedScopes = (params.scope || '').split(' ').filter(Boolean);
if (requestedScopes.length === 0) {
return { valid: false, error: 'invalid_scope', error_description: 'At least one scope is required' };
}
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
// Narrow to the intersection: drop scopes the client isn't permitted for rather
// than rejecting the whole request (per OAuth 2.0 §3.3 scope narrowing).
const grantedScopes = requestedScopes.filter(s => allowedScopes.includes(s));
if (grantedScopes.length === 0) {
return { valid: false, error: 'invalid_scope', error_description: 'None of the requested scopes are permitted for this client' };
}
if (userId === null) {
// H3: return only the minimum required fields — do NOT expose scopes, client.name, or
// allowed_scopes to unauthenticated callers to prevent client enumeration.
return { valid: true, loginRequired: true };
}
const existingConsent = this.getConsent(params.client_id, userId);
const consentRequired = !existingConsent || !this.isConsentSufficient(existingConsent, grantedScopes);
return {
valid: true,
client: { name: client.name, allowed_scopes: allowedScopes },
scopes: grantedScopes,
resource: resource ?? mcpResource,
consentRequired,
scopeSelectable: client.created_via === 'dcr',
};
}
// -------------------------------------------------------------------------
// PKCE verification
// -------------------------------------------------------------------------
verifyPKCE(codeVerifier: string, codeChallenge: string): boolean {
// H1: validate code_verifier format before hashing
if (!CODE_VERIFIER_RE.test(codeVerifier)) return false;
const expected = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
// Constant-time compare (both are base64url strings of equal length for S256)
if (expected.length !== codeChallenge.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(codeChallenge));
} catch { return false; }
}
// -------------------------------------------------------------------------
// Client authentication (for token endpoint)
// -------------------------------------------------------------------------
authenticateClient(clientId: string, clientSecret: string | undefined): OAuthClientRow | null {
const client = this.db.get<OAuthClientRow>('SELECT * FROM oauth_clients WHERE client_id = ?', clientId);
if (!client) return null;
if (client.is_public) {
// Public clients are identified by client_id alone — PKCE provides the security guarantee.
return client;
}
// H4: constant-time comparison to prevent timing side-channel
if (!clientSecret) return null;
if (!timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) return null;
return client;
}
}
+2
View File
@@ -18,6 +18,7 @@ import { CollabService } from '../collab/collab.service';
import { VacayService } from '../vacay/vacay.service';
import { PlacesService } from '../places/places.service';
import { MapsService } from '../maps/maps.service';
import { UserCleanupService } from '../auth/user-cleanup.service';
/**
* Non-Nest entry point for the trip domain — for the two consumers that cannot
@@ -53,6 +54,7 @@ const trips = new TripsService(
new RealtimeService(),
new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()), new UnsplashService(dbs(), new RuntimeEnvService()), photoCache),
new UnsplashService(dbs(), new RuntimeEnvService()),
new UserCleanupService(dbs()),
);
export function getTripOwner(tripId: string | number) {
+3 -2
View File
@@ -8,7 +8,7 @@ import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import type { Trip, User } from '../../types';
import { avatarUrl } from '../common/avatarUrl';
import { erasePluginUserData } from '../../services/userCleanupService';
import { UserCleanupService } from '../auth/user-cleanup.service';
import { emitUserDeleted } from '../../plugin-user-lifecycle';
import { resolveTimeZone } from '../common/timezoneService';
import { PlacesService } from '../places/places.service';
@@ -228,6 +228,7 @@ export class TripsService {
private readonly realtime: RealtimeService,
private readonly places: PlacesService,
private readonly unsplash: UnsplashService,
private readonly userCleanup: UserCleanupService,
) {}
private get db() {
@@ -897,7 +898,7 @@ export class TripsService {
// A guest is still a user id a plugin may hold data for, so erase that too — the
// host-side per-user tables + a durable own-db erasure per granted plugin — exactly
// like a full account deletion (otherwise a deleted guest's plugin data lingers).
erasePluginUserData(guestUserId);
this.userCleanup.erasePluginUserData(guestUserId);
// Quirk fix on top of the 1:1 move: the budget re-split and the user delete
// run in one transaction, so a failure mid-flow can't leave the expense
// divisors re-derived for a guest that still exists (or vice versa). The
-694
View File
@@ -1,694 +0,0 @@
import crypto, { randomBytes, createHash, randomUUID } from 'crypto';
import { db } from '../db/database';
import { isAddonEnabled } from '../nest/addons/addons.bridge';
import { validateScopes } from '../mcp/scopes';
import { ADDON_IDS } from '../addons';
import { User } from '../types';
import { writeAudit, logWarn } from '../nest/audit/audit.bridge';
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
import { getMcpSafeUrl } from '../app-config';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ACCESS_TOKEN_TTL_S = 60 * 60; // 1 hour
const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days rolling
const AUTH_CODE_TTL_MS = 2 * 60 * 1000; // 2 minutes
// PKCE format (RFC 7636)
const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/;
const CODE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
// ---------------------------------------------------------------------------
// In-memory auth code store (short-lived, no need for DB persistence)
// ---------------------------------------------------------------------------
interface PendingCode {
clientId: string;
userId: number;
redirectUri: string;
scopes: string[];
resource: string | null;
codeChallenge: string;
codeChallengeMethod: 'S256';
expiresAt: number;
}
const MAX_PENDING_CODES = 500;
const pendingCodes = new Map<string, PendingCode>();
setInterval(() => {
const now = Date.now();
for (const [key, entry] of pendingCodes) {
if (now > entry.expiresAt) pendingCodes.delete(key);
}
}, 60_000).unref();
// ---------------------------------------------------------------------------
// DB row types
// ---------------------------------------------------------------------------
interface OAuthClientRow {
id: string;
user_id: number;
name: string;
client_id: string;
client_secret_hash: string;
redirect_uris: string; // JSON array
allowed_scopes: string; // JSON array
created_at: string;
is_public: number; // 0 | 1 (SQLite boolean)
created_via: string; // 'settings_ui' | 'browser-registration'
allows_client_credentials: number; // 0 | 1
}
interface OAuthTokenRow {
id: number;
client_id: string;
user_id: number;
access_token_hash: string;
refresh_token_hash: string;
scopes: string; // JSON array
audience: string | null;
access_token_expires_at: string;
refresh_token_expires_at: string;
revoked_at: string | null;
parent_token_id: number | null;
}
// ---------------------------------------------------------------------------
// Token helpers
// ---------------------------------------------------------------------------
function hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
/** Constant-time comparison of two hex-encoded SHA-256 hashes. */
function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
} catch { return false; }
}
function generateAccessToken(): string {
return 'trekoa_' + randomBytes(32).toString('hex');
}
function generateRefreshToken(): string {
return 'trekrf_' + randomBytes(32).toString('hex');
}
// ---------------------------------------------------------------------------
// Client management (self-service, gated by MCP addon)
// ---------------------------------------------------------------------------
export function listOAuthClients(userId: number): Record<string, unknown>[] {
const rows = db.prepare(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
).all(userId) as OAuthClientRow[];
return rows.map(r => ({
...r,
is_public: Boolean(r.is_public),
allows_client_credentials: Boolean(r.allows_client_credentials),
redirect_uris: JSON.parse(r.redirect_uris),
allowed_scopes: JSON.parse(r.allowed_scopes),
}));
}
export function createOAuthClient(
userId: number | null,
name: string,
redirectUris: string[],
allowedScopes: string[],
ip?: string | null,
options?: { isPublic?: boolean; createdVia?: string; allowsClientCredentials?: boolean },
): { error?: string; status?: number; client?: Record<string, unknown> } {
if (!name?.trim()) return { error: 'Name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
const isMachineClient = Boolean(options?.allowsClientCredentials);
if (!isMachineClient && (!redirectUris || redirectUris.length === 0)) return { error: 'At least one redirect URI is required', status: 400 };
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
for (const uri of redirectUris) {
let parsed: URL;
try {
parsed = new URL(uri);
} catch {
return { error: `Invalid redirect URI: ${uri}`, status: 400 };
}
if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
return { error: `Redirect URI must use HTTPS (localhost exempt): ${uri}`, status: 400 };
}
}
if (!allowedScopes || allowedScopes.length === 0) return { error: 'At least one scope is required', status: 400 };
const { valid, invalid } = validateScopes(allowedScopes);
if (!valid) return { error: `Invalid scopes: ${invalid.join(', ')}`, status: 400 };
if (userId !== null) {
const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id = ?').get(userId) as { count: number }).count;
if (count >= 10) return { error: 'Maximum of 10 OAuth clients per user', status: 400 };
} else {
// Anonymous DCR clients: enforce a global cap to prevent unbounded registration abuse
const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id IS NULL').get() as { count: number }).count;
if (count >= 500) return { error: 'server_error', status: 503 };
}
// Machine clients (client_credentials) must always be confidential — ignore isPublic for them.
const isPublic = isMachineClient ? false : (options?.isPublic ?? false);
const createdVia = options?.createdVia ?? 'settings_ui';
const id = randomUUID();
const clientId = randomUUID();
// Public clients have no usable secret; store an opaque random value to satisfy NOT NULL.
const rawSecret = isPublic ? null : 'trekcs_' + randomBytes(24).toString('hex');
const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex');
db.prepare(
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via, allows_client_credentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia, isMachineClient ? 1 : 0);
const row = db.prepare(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE id = ?'
).get(id) as OAuthClientRow;
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip });
return {
client: {
id: row.id,
user_id: row.user_id,
name: row.name,
client_id: row.client_id,
redirect_uris: JSON.parse(row.redirect_uris),
allowed_scopes: JSON.parse(row.allowed_scopes),
created_at: row.created_at,
is_public: Boolean(row.is_public),
allows_client_credentials: Boolean(row.allows_client_credentials),
created_via: row.created_via,
// client_secret only present for confidential clients — shown once, not stored in plain text
...(rawSecret ? { client_secret: rawSecret } : {}),
},
};
}
export function rotateOAuthClientSecret(
userId: number,
clientRowId: string,
ip?: string | null,
): { error?: string; status?: number; client_secret?: string } {
const row = db.prepare('SELECT id, client_id, is_public FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
if (!row) return { error: 'Client not found', status: 404 };
if (row.is_public) return { error: 'Public clients do not use a client secret', status: 400 };
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex');
const secretHash = hashToken(rawSecret);
db.prepare('UPDATE oauth_clients SET client_secret_hash = ? WHERE id = ?').run(secretHash, clientRowId);
// Revoke all existing tokens for this client so old sessions are invalidated
db.prepare("UPDATE oauth_tokens SET revoked_at = datetime('now') WHERE client_id = ? AND revoked_at IS NULL").run(row.client_id);
// Terminate active MCP sessions for this (user, client) pair
revokeUserSessionsForClient(userId, row.client_id);
writeAudit({ userId, action: 'oauth.client.rotate_secret', details: { client_id: row.client_id }, ip });
return { client_secret: rawSecret };
}
export function deleteOAuthClient(
userId: number,
clientRowId: string,
ip?: string | null,
): { error?: string; status?: number; success?: boolean } {
const row = db.prepare('SELECT id, client_id FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
if (!row) return { error: 'Client not found', status: 404 };
db.prepare('DELETE FROM oauth_clients WHERE id = ?').run(clientRowId);
writeAudit({ userId, action: 'oauth.client.delete', details: { client_id: row.client_id }, ip });
return { success: true };
}
// ---------------------------------------------------------------------------
// Auth code (in-memory, 2-minute TTL)
// ---------------------------------------------------------------------------
export function createAuthCode(params: {
clientId: string;
userId: number;
redirectUri: string;
scopes: string[];
resource: string | null;
codeChallenge: string;
codeChallengeMethod: 'S256';
}): string | null {
if (pendingCodes.size >= MAX_PENDING_CODES) return null;
const rawCode = randomBytes(32).toString('hex');
pendingCodes.set(rawCode, { ...params, expiresAt: Date.now() + AUTH_CODE_TTL_MS });
return rawCode;
}
export function consumeAuthCode(code: string): PendingCode | null {
const entry = pendingCodes.get(code);
if (!entry) return null;
pendingCodes.delete(code);
if (Date.now() > entry.expiresAt) return null;
return entry;
}
// ---------------------------------------------------------------------------
// Consent management
// ---------------------------------------------------------------------------
export function getConsent(clientId: string, userId: number): string[] | null {
const row = db.prepare(
'SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?'
).get(clientId, userId) as { scopes: string } | undefined;
return row ? JSON.parse(row.scopes) : null;
}
export function saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): void {
// Union existing consent with newly approved scopes (M5: never narrow stored consent)
const existing = getConsent(clientId, userId) ?? [];
const merged = Array.from(new Set([...existing, ...scopes]));
db.prepare(
'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)'
).run(clientId, userId, JSON.stringify(merged));
writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip });
}
export function isConsentSufficient(existingScopes: string[], requestedScopes: string[]): boolean {
return requestedScopes.every(s => existingScopes.includes(s));
}
// ---------------------------------------------------------------------------
// Token issuance
// ---------------------------------------------------------------------------
export function issueTokens(
clientId: string,
userId: number,
scopes: string[],
parentTokenId: number | null = null,
audience: string | null = null,
): {
access_token: string;
refresh_token: string;
token_type: 'Bearer';
expires_in: number;
scope: string;
} {
const rawAccess = generateAccessToken();
const rawRefresh = generateRefreshToken();
const accessHash = hashToken(rawAccess);
const refreshHash = hashToken(rawRefresh);
const now = new Date();
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
const refreshExpiry = new Date(now.getTime() + REFRESH_TOKEN_TTL_MS);
db.prepare(`
INSERT INTO oauth_tokens
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId);
return {
access_token: rawAccess,
refresh_token: rawRefresh,
token_type: 'Bearer',
expires_in: ACCESS_TOKEN_TTL_S,
scope: scopes.join(' '),
};
}
// Issues an access token only — no refresh token (RFC 6749 §4.4.3).
// Used exclusively for the client_credentials grant. A random opaque hash is
// stored in refresh_token_hash to satisfy the NOT NULL/UNIQUE constraint; it
// can never be presented as a valid refresh token (same precedent as public
// client secret hashes stored in client_secret_hash).
export function issueClientCredentialsToken(
clientId: string,
userId: number,
scopes: string[],
audience: string,
): {
access_token: string;
token_type: 'Bearer';
expires_in: number;
scope: string;
} {
const rawAccess = generateAccessToken();
const accessHash = hashToken(rawAccess);
const placeholderHash = randomBytes(32).toString('hex');
const now = new Date();
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
db.prepare(`
INSERT INTO oauth_tokens
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(clientId, userId, accessHash, placeholderHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), now.toISOString(), null);
return {
access_token: rawAccess,
token_type: 'Bearer',
expires_in: ACCESS_TOKEN_TTL_S,
scope: scopes.join(' '),
};
}
// ---------------------------------------------------------------------------
// Token verification (used by MCP handler on every request)
// ---------------------------------------------------------------------------
export interface OAuthTokenInfo {
user: User;
scopes: string[];
clientId: string;
audience: string | null;
}
export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
const hash = hashToken(rawToken);
const row = db.prepare(`
SELECT ot.scopes, ot.audience, ot.revoked_at, ot.access_token_expires_at,
ot.user_id, ot.client_id, u.username, u.email, u.role
FROM oauth_tokens ot
JOIN users u ON ot.user_id = u.id
WHERE ot.access_token_hash = ?
`).get(hash) as (OAuthTokenRow & { username: string; email: string; role: string }) | undefined;
if (!row) return null;
if (row.revoked_at) return null;
if (new Date(row.access_token_expires_at) < new Date()) return null;
return {
user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' },
scopes: JSON.parse(row.scopes),
clientId: row.client_id,
audience: row.audience ?? null,
};
}
// ---------------------------------------------------------------------------
// Token refresh (rotation + replay detection)
// ---------------------------------------------------------------------------
/** Walk parent_token_id upward to find the root token id of this rotation chain. */
function findChainRoot(tokenId: number): number {
let current = tokenId;
for (let i = 0; i < 100; i++) {
const row = db.prepare('SELECT id, parent_token_id FROM oauth_tokens WHERE id = ?').get(current) as { id: number; parent_token_id: number | null } | undefined;
if (!row || row.parent_token_id === null) return current;
current = row.parent_token_id;
}
return current;
}
/** Revoke all tokens in the rotation chain rooted at rootId. Returns affected ids. */
function revokeChain(rootId: number): number[] {
const rows = db.prepare(`
WITH RECURSIVE chain(id) AS (
SELECT id FROM oauth_tokens WHERE id = ?
UNION ALL
SELECT t.id FROM oauth_tokens t JOIN chain c ON t.parent_token_id = c.id
)
SELECT id FROM chain
`).all(rootId) as { id: number }[];
const ids = rows.map(r => r.id);
if (ids.length > 0) {
db.prepare(
`UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id IN (${ids.map(() => '?').join(',')}) AND revoked_at IS NULL`
).run(...ids);
}
return ids;
}
export function refreshTokens(
rawRefreshToken: string,
clientId: string,
clientSecret: string | undefined,
ip?: string | null,
): { error?: string; status?: number; tokens?: ReturnType<typeof issueTokens> } {
const client = db.prepare('SELECT client_id, client_secret_hash, is_public FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
if (!client) return { error: 'invalid_client', status: 401 };
if (!client.is_public) {
if (!clientSecret || !timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) {
return { error: 'invalid_client', status: 401 };
}
}
const hash = hashToken(rawRefreshToken);
const row = db.prepare(`
SELECT id, client_id, user_id, scopes, audience, refresh_token_expires_at, revoked_at, parent_token_id
FROM oauth_tokens WHERE refresh_token_hash = ?
`).get(hash) as OAuthTokenRow | undefined;
if (!row) return { error: 'invalid_grant', status: 400 };
if (row.client_id !== clientId) return { error: 'invalid_grant', status: 400 };
// ---- Replay detection (C3) ----
if (row.revoked_at) {
// A revoked refresh token was replayed — assume token theft. Cascade-revoke the chain.
const rootId = findChainRoot(row.id);
revokeChain(rootId);
revokeUserSessionsForClient(row.user_id, clientId);
writeAudit({
userId: row.user_id,
action: 'oauth.token.replay_detected',
details: { client_id: clientId },
ip,
});
logWarn(`[OAuth] Refresh token replay detected for user=${row.user_id} client=${clientId} ip=${ip ?? '-'}`);
return { error: 'invalid_grant', status: 400 };
}
if (new Date(row.refresh_token_expires_at) < new Date()) return { error: 'invalid_grant', status: 400 };
// Revoke old pair immediately (rotation) and issue new pair linked to old row.
// Do NOT revoke active MCP sessions here: a legitimate refresh isn't a security
// event (that's handled above, in the replay-detection branch), and mcpHandler
// already re-validates session.userId/clientId against the new token on every
// request. Killing the session on every routine hourly refresh broke long-lived
// MCP connections (#1475).
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id);
const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
return { tokens };
}
// ---------------------------------------------------------------------------
// Token revocation
// ---------------------------------------------------------------------------
export function revokeToken(rawToken: string, clientId: string, userId?: number, ip?: string | null): void {
const hash = hashToken(rawToken);
// Get the user_id for the token so we can revoke its MCP sessions
const row = db.prepare(
'SELECT user_id FROM oauth_tokens WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?'
).get(hash, hash, clientId) as { user_id: number } | undefined;
db.prepare(`
UPDATE oauth_tokens
SET revoked_at = CURRENT_TIMESTAMP
WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?
`).run(hash, hash, clientId);
const affectedUserId = row?.user_id ?? userId;
if (affectedUserId) {
revokeUserSessionsForClient(affectedUserId, clientId);
writeAudit({ userId: affectedUserId, action: 'oauth.token.revoke', details: { client_id: clientId, method: 'token' }, ip });
}
}
// ---------------------------------------------------------------------------
// Active session listing (for user settings page)
// ---------------------------------------------------------------------------
export function listOAuthSessions(userId: number): Record<string, unknown>[] {
const rows = db.prepare(`
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.scopes,
ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
FROM oauth_tokens ot
JOIN oauth_clients oc ON ot.client_id = oc.client_id
WHERE ot.user_id = ?
AND ot.revoked_at IS NULL
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
ORDER BY ot.created_at DESC
`).all(userId) as Record<string, unknown>[];
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes as string) }));
}
export function revokeSession(
userId: number,
sessionId: number,
ip?: string | null,
): { error?: string; status?: number; success?: boolean } {
const row = db.prepare('SELECT id, client_id FROM oauth_tokens WHERE id = ? AND user_id = ?').get(sessionId, userId) as { id: number; client_id: string } | undefined;
if (!row) return { error: 'Session not found', status: 404 };
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(sessionId);
revokeUserSessionsForClient(userId, row.client_id);
writeAudit({ userId, action: 'oauth.token.revoke', details: { client_id: row.client_id, method: 'session' }, ip });
return { success: true };
}
// ---------------------------------------------------------------------------
// Authorize request validation (option A: called by SPA via GET /api/oauth/authorize/validate)
// ---------------------------------------------------------------------------
export interface AuthorizeParams {
response_type: string;
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
code_challenge: string;
code_challenge_method: string;
resource?: string;
}
export interface ValidateAuthorizeResult {
valid: boolean;
error?: string;
error_description?: string;
client?: { name: string; allowed_scopes: string[] };
scopes?: string[];
resource?: string | null;
/** true when user is logged in but consent UI must be shown */
consentRequired?: boolean;
/** true when the request is valid but user is not authenticated */
loginRequired?: boolean;
/** true when the client was registered via machine DCR — user may adjust scopes on the consent screen */
scopeSelectable?: boolean;
}
export function validateAuthorizeRequest(
params: AuthorizeParams,
userId: number | null,
): ValidateAuthorizeResult {
if (!isAddonEnabled(ADDON_IDS.MCP)) {
return { valid: false, error: 'mcp_disabled', error_description: 'MCP is not enabled on this server' };
}
if (params.response_type !== 'code') {
return { valid: false, error: 'unsupported_response_type', error_description: 'Only response_type=code is supported' };
}
if (!params.code_challenge || params.code_challenge_method !== 'S256') {
return { valid: false, error: 'invalid_request', error_description: 'PKCE with code_challenge_method=S256 is required (OAuth 2.1)' };
}
// H1: Enforce code_challenge format (RFC 7636 §4.2)
if (!CODE_CHALLENGE_RE.test(params.code_challenge)) {
return { valid: false, error: 'invalid_request', error_description: 'code_challenge must be 43 base64url characters (S256)' };
}
if (!params.client_id) {
return { valid: false, error: 'invalid_request', error_description: 'client_id is required' };
}
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(params.client_id) as OAuthClientRow | undefined;
if (!client) {
return { valid: false, error: 'invalid_client', error_description: 'Unknown client_id' };
}
const allowedUris: string[] = JSON.parse(client.redirect_uris);
if (!params.redirect_uri || !allowedUris.includes(params.redirect_uri)) {
return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' };
}
// RFC 8707 resource indicator: if provided, must identify the TREK
// MCP endpoint exactly. If the client didn't supply `resource`, we
// bind the token to the MCP endpoint by default — previously this
// left `audience = null`, and the audience-bind check on MCP requests
// then treated a null audience as "valid for any resource".
const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
const resource = params.resource
? params.resource.replace(/\/+$/, '')
: mcpResource;
if (resource !== mcpResource) {
return { valid: false, error: 'invalid_target', error_description: 'Requested resource must be the TREK MCP endpoint' };
}
const requestedScopes = (params.scope || '').split(' ').filter(Boolean);
if (requestedScopes.length === 0) {
return { valid: false, error: 'invalid_scope', error_description: 'At least one scope is required' };
}
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
// Narrow to the intersection: drop scopes the client isn't permitted for rather
// than rejecting the whole request (per OAuth 2.0 §3.3 scope narrowing).
const grantedScopes = requestedScopes.filter(s => allowedScopes.includes(s));
if (grantedScopes.length === 0) {
return { valid: false, error: 'invalid_scope', error_description: 'None of the requested scopes are permitted for this client' };
}
if (userId === null) {
// H3: return only the minimum required fields — do NOT expose scopes, client.name, or
// allowed_scopes to unauthenticated callers to prevent client enumeration.
return { valid: true, loginRequired: true };
}
const existingConsent = getConsent(params.client_id, userId);
const consentRequired = !existingConsent || !isConsentSufficient(existingConsent, grantedScopes);
return {
valid: true,
client: { name: client.name, allowed_scopes: allowedScopes },
scopes: grantedScopes,
resource: resource ?? mcpResource,
consentRequired,
scopeSelectable: client.created_via === 'dcr',
};
}
// ---------------------------------------------------------------------------
// PKCE verification
// ---------------------------------------------------------------------------
export function verifyPKCE(codeVerifier: string, codeChallenge: string): boolean {
// H1: validate code_verifier format before hashing
if (!CODE_VERIFIER_RE.test(codeVerifier)) return false;
const expected = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
// Constant-time compare (both are base64url strings of equal length for S256)
if (expected.length !== codeChallenge.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(codeChallenge));
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Client authentication (for token endpoint)
// ---------------------------------------------------------------------------
export function authenticateClient(clientId: string, clientSecret: string | undefined): OAuthClientRow | null {
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
if (!client) return null;
if (client.is_public) {
// Public clients are identified by client_id alone — PKCE provides the security guarantee.
return client;
}
// H4: constant-time comparison to prevent timing side-channel
if (!clientSecret) return null;
if (!timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) return null;
return client;
}
-65
View File
@@ -1,65 +0,0 @@
import fs from 'node:fs';
import { db } from '../db/database';
import { pluginsDataRoot } from '../nest/plugins/paths';
import { removeUserFromBudgetItems } from '../nest/budget/budget.bridge';
/**
* Erase a user's PLUGIN-held data on account deletion. Two parts:
* 1. Host-side per-user plugin tables (encrypted config values, OAuth access/refresh
* tokens, in-flight OAuth state) — deleted directly; these live in trek.db, not in
* a plugin's own db, so nothing else ever removes them.
* 2. A durable erasure row per plugin that holds `hook:user-data`, so its OWN db is
* purged of the user (drained to the plugin when it is next active).
*
* Runs in the core deletion path (NOT via the plugin runtime), so it works even when
* TREK_PLUGINS_ENABLED=false or before the runtime has booted — otherwise a deletion in
* those windows would leave the user's plugin data behind forever. Best-effort per table
* so a slimmed-down schema (some tests) can't fail the user deletion itself.
*/
export function erasePluginUserData(userId: number): void {
for (const table of ['plugin_user_config', 'plugin_oauth_tokens', 'plugin_oauth_state']) {
try { db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(userId); } catch { /* table absent (slim schema) */ }
}
try {
const rows = db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>;
const installed = new Set(rows.map((r) => r.id));
const insert = db.prepare('INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)');
for (const r of rows) {
let perms: unknown;
try { perms = JSON.parse(r.permissions ?? '[]'); } catch { perms = []; }
if (Array.isArray(perms) && perms.includes('hook:user-data')) insert.run(r.id, userId);
}
// Also enqueue for plugins UNINSTALLED with their data retained (deleteData=false):
// their data dir still holds the user's rows and a same-id reinstall would re-adopt
// them. No permissions record survives uninstall, so we can't check hook:user-data —
// enqueue for every orphan data dir; the row sits inert (no FK) and drains only if
// that id is reinstalled + active (erasure delivery is a duty, not grant-gated).
try {
for (const entry of fs.readdirSync(pluginsDataRoot(), { withFileTypes: true })) {
if (entry.isDirectory() && !installed.has(entry.name)) insert.run(entry.name, userId);
}
} catch { /* no plugin data root yet */ }
} catch { /* plugins / queue table absent (slim schema) */ }
}
function cleanupUserReferences(userId: number): void {
db.prepare('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?').run(userId);
db.prepare('UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?').run(userId);
removeUserFromBudgetItems(userId);
db.prepare('DELETE FROM share_tokens WHERE created_by = ?').run(userId);
db.prepare('DELETE FROM journey_share_tokens WHERE created_by = ?').run(userId);
// Owned journeys cascade-delete their entries/contributors/share_tokens/photos via journey_id FKs
db.prepare('DELETE FROM journeys WHERE user_id = ?').run(userId);
// Entries authored on other users' journeys (not covered by the cascade above)
db.prepare('DELETE FROM journey_entries WHERE author_id = ?').run(userId);
db.prepare('DELETE FROM journey_contributors WHERE user_id = ?').run(userId);
}
export function deleteUserCompletely(userId: number): void {
const tx = db.transaction((id: number) => {
cleanupUserReferences(id);
erasePluginUserData(id);
db.prepare('DELETE FROM users WHERE id = ?').run(id);
});
tx(userId);
}
-86
View File
@@ -1,86 +0,0 @@
import { readEnv } from '../app-config';
import { db } from '../db/database';
import { getAppUrl } from '../app-config';
/**
* Resolves the WebAuthn Relying Party ID + allowed origins for this deployment.
*
* SECURITY: the RP ID and the allowed origins are derived ONLY from server-side
* configuration — the `webauthn_rp_id` / `webauthn_origins` admin settings (or
* the matching env vars), falling back to APP_URL. They are NEVER taken from the
* request `Host` / `X-Forwarded-Host` header: a forged forwarded host would
* otherwise let an attacker bind credentials to a domain they control, or brick
* every enrolled user. This mirrors how OIDC derives its redirect URI from
* APP_URL (oidc.controller.ts) rather than from request input.
*
* Returns null when no usable RP ID can be resolved (bare IP host, or nothing
* configured) — the feature then reports itself as "not configured" and stays
* disabled so nobody can enrol a credential bound to the wrong origin.
*/
function getSetting(key: string): string | null {
const raw = (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
const trimmed = raw?.trim();
return trimmed ? trimmed : null;
}
function hostOf(url: string): string | null {
try {
return new URL(url).hostname || null;
} catch {
return null;
}
}
/** WebAuthn RP IDs must be registrable domains — never bare IP literals. */
function isIpHost(host: string): boolean {
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true; // IPv4
if (host.includes(':')) return true; // IPv6 (hostname keeps the colons)
return false;
}
export interface WebauthnConfig {
rpID: string;
rpName: string;
/** Exact allowed origins (scheme + host + port). One in prod; localhost dev adds the Vite/API ports. */
origins: string[];
}
export function resolveWebauthnConfig(): WebauthnConfig | null {
// 1. Explicit operator config always wins.
const explicitRpId = (readEnv().webauthn.rpId || getSetting('webauthn_rp_id'))?.trim() || null;
const explicitOrigins = (readEnv().webauthn.origins || getSetting('webauthn_origins') || '')
.split(',')
.map((o) => o.trim().replace(/\/+$/, ''))
.filter(Boolean);
const appUrl = getAppUrl();
const appHost = hostOf(appUrl);
// 2. Derive the RP ID from APP_URL when not explicitly set.
let rpID = explicitRpId;
if (!rpID && appHost && !isIpHost(appHost)) {
rpID = appHost; // a real domain, or "localhost"
}
if (!rpID) return null; // bare IP / unresolved → WebAuthn cannot be used here
// 3. Resolve the allowed origins. Explicit list wins verbatim (operator's
// responsibility). Otherwise derive a SINGLE origin from APP_URL — we never
// silently union dev localhost origins into a production allow-list.
let origins = explicitOrigins;
if (origins.length === 0) {
if (appHost) origins = [appUrl.replace(/\/+$/, '')];
if (rpID === 'localhost') {
// Dev: the browser origin is the Vite dev server (:5173), not the API port.
origins = Array.from(new Set([...origins, 'http://localhost:5173', 'http://localhost:3001']));
}
}
if (origins.length === 0) return null;
return { rpID, rpName: 'TREK', origins };
}
/** True when a usable RP ID resolves for this deployment (exposed as a pure boolean on app-config). */
export function isPasskeyConfigured(): boolean {
return resolveWebauthnConfig() !== null;
}