mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
6dda05f672
Eight files in services/ hold no state and touch no database: avatarUrl, conflictResult, demo, passwordPolicy, timezoneService, cookie, and the two crypto helpers. They are imported from 65 places, 62 of them already inside src/nest, so having them live in the legacy layer was pure inertia. They stay plain functions rather than becoming providers. Three importers sit outside the container and would have no way to reach an injectable one: db/migrations.ts runs before the app is built, and middleware/auth.ts and middleware/mfaPolicy.ts run beside it. Wrapping these in DI would buy ceremony and nothing else. mfaCrypto and apiKeyCrypto move together into common/crypto — they read the same ENCRYPTION_KEY, and the directory says so. services/ drops from 44 files to 36, 9,870 lines to 8,493. One trap worth recording: src/app-config and src/nest/app-config both exist. cookie.ts imported '../app-config' from services/, which resolved to the former; after the move the same string silently pointed at the latter, which has no readEnv. It is now explicit about which one it wants. Nothing else changes — no signatures, no behaviour, no tests rewritten beyond their import paths.
237 lines
12 KiB
TypeScript
237 lines
12 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import crypto from 'node:crypto';
|
|
import { DatabaseService } from '../database/database.service';
|
|
import { encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
|
import { getAppUrl } from '../../app-config';
|
|
import { isPrivateIp } from './install/safe-fetch';
|
|
import { safeFetchLlm } from '../../utils/ssrfGuard';
|
|
|
|
/**
|
|
* Host-brokered outbound OAuth (#plugins). A plugin becomes an OAuth *client* of a
|
|
* third-party service; the HOST runs the whole flow (authorize -> callback -> token
|
|
* exchange -> refresh) with PKCE + state and HOLDS the tokens. The plugin only ever
|
|
* triggers "connect" and reads a short-lived access token via `ctx.oauth.getAccessToken()`
|
|
* — it never sees the refresh token or the client secret.
|
|
*
|
|
* Provider config (endpoints + client credentials) is the plugin's admin-owned
|
|
* INSTANCE settings — a plugin declares these `scope:'instance'` fields and the admin
|
|
* fills them in:
|
|
* oauth_authorize_url, oauth_token_url, oauth_scopes (optional), and the two secrets
|
|
* oauth_client_id, oauth_client_secret.
|
|
* Tokens are per-user + encrypted at rest; the PKCE verifier/state row is short-lived.
|
|
*/
|
|
export interface OAuthProviderConfig {
|
|
authorizeUrl: string;
|
|
tokenUrl: string;
|
|
scopes: string;
|
|
clientId: string;
|
|
clientSecret: string;
|
|
}
|
|
|
|
const STATE_TTL_MS = 10 * 60 * 1000; // an authorize round-trip must finish within 10 min
|
|
const REFRESH_SKEW_S = 60; // refresh a token expiring within a minute
|
|
|
|
function b64url(buf: Buffer): string {
|
|
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
}
|
|
|
|
/** Cheap fast-fail for an obviously-internal token endpoint. This is a pre-check, not the
|
|
* authoritative gate: the real SSRF defence is the DNS-resolving, IP-pinning guard inside
|
|
* the fetch (tokenRequest), which blocks the cloud-metadata range even for a DNS name. This
|
|
* rejects the literal loopback / link-local / metadata hosts (v4 AND v6) plus the internal
|
|
* name suffixes, so a bracketed IPv6 literal or a `.internal` name can't slip past the
|
|
* fast-fail. Private LAN (10./192.168./…) is deliberately left to the fetch policy so a
|
|
* self-hosted internal IdP stays reachable. */
|
|
function assertSafeHttps(urlStr: string, what: string): URL {
|
|
let u: URL;
|
|
try {
|
|
u = new URL(urlStr);
|
|
} catch {
|
|
throw new Error(`${what} is not a valid URL`);
|
|
}
|
|
if (u.protocol !== 'https:') throw new Error(`${what} must be https`);
|
|
const host = u.hostname.toLowerCase();
|
|
const ip = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
|
const loopbackOrMeta =
|
|
ip === '::1' || ip.startsWith('127.') || ip.startsWith('0.') ||
|
|
ip.startsWith('169.254.') || /^fe[89ab][0-9a-f]:/.test(ip) || ip.startsWith('fd00:ec2:');
|
|
if (loopbackOrMeta) throw new Error(`${what} may not point at a loopback or metadata address`);
|
|
if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal')) {
|
|
throw new Error(`${what} may not point at a local address`);
|
|
}
|
|
if (/^\d+\.\d+\.\d+\.\d+$/.test(host) && isPrivateIp(host)) throw new Error(`${what} may not point at a private address`);
|
|
return u;
|
|
}
|
|
|
|
@Injectable()
|
|
export class PluginOAuthService {
|
|
constructor(private readonly dbs: DatabaseService) {}
|
|
|
|
private get db() {
|
|
return this.dbs.connection;
|
|
}
|
|
|
|
/** The plugin's decrypted OAuth provider config from its INSTANCE settings, or null
|
|
* when any required piece is missing/blank. */
|
|
providerConfig(pluginId: string): OAuthProviderConfig | null {
|
|
const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(pluginId) as { config: string } | undefined;
|
|
if (!row) return null;
|
|
let cfg: Record<string, unknown>;
|
|
try {
|
|
cfg = JSON.parse(row.config || '{}');
|
|
} catch {
|
|
return null;
|
|
}
|
|
const authorizeUrl = String(cfg.oauth_authorize_url ?? '').trim();
|
|
const tokenUrl = String(cfg.oauth_token_url ?? '').trim();
|
|
const clientId = cfg.oauth_client_id ? String(decrypt_api_key(cfg.oauth_client_id)) : '';
|
|
const clientSecret = cfg.oauth_client_secret ? String(decrypt_api_key(cfg.oauth_client_secret)) : '';
|
|
const scopes = String(cfg.oauth_scopes ?? '').trim();
|
|
if (!authorizeUrl || !tokenUrl || !clientId || !clientSecret) return null;
|
|
return { authorizeUrl, tokenUrl, scopes, clientId, clientSecret };
|
|
}
|
|
|
|
private redirectUri(pluginId: string): string {
|
|
return `${getAppUrl()}/api/plugin-oauth/${pluginId}/callback`;
|
|
}
|
|
|
|
/** Whether the acting user has a stored token for this plugin. */
|
|
status(pluginId: string, userId: number): { configured: boolean; connected: boolean } {
|
|
const configured = this.providerConfig(pluginId) !== null;
|
|
const tok = this.db.prepare('SELECT 1 FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ? AND access_token IS NOT NULL').get(pluginId, userId);
|
|
return { configured, connected: !!tok };
|
|
}
|
|
|
|
/** Begin the authorize flow: mint PKCE + state, persist them, return the provider URL. */
|
|
startConnect(pluginId: string, userId: number, nowMs: number): string {
|
|
const cfg = this.providerConfig(pluginId);
|
|
if (!cfg) throw new Error('OAuth is not configured for this plugin');
|
|
const authorize = assertSafeHttps(cfg.authorizeUrl, 'authorize_url');
|
|
assertSafeHttps(cfg.tokenUrl, 'token_url'); // fail fast if the token endpoint is unsafe too
|
|
|
|
const verifier = b64url(crypto.randomBytes(32));
|
|
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
|
|
const state = b64url(crypto.randomBytes(24));
|
|
|
|
// Drop this user's stale states for the plugin, then store the fresh one.
|
|
this.db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
|
|
this.db.prepare('INSERT INTO plugin_oauth_state (state, plugin_id, user_id, verifier, created_at) VALUES (?, ?, ?, ?, ?)').run(
|
|
state,
|
|
pluginId,
|
|
userId,
|
|
verifier,
|
|
nowMs,
|
|
);
|
|
|
|
authorize.searchParams.set('response_type', 'code');
|
|
authorize.searchParams.set('client_id', cfg.clientId);
|
|
authorize.searchParams.set('redirect_uri', this.redirectUri(pluginId));
|
|
if (cfg.scopes) authorize.searchParams.set('scope', cfg.scopes);
|
|
authorize.searchParams.set('state', state);
|
|
authorize.searchParams.set('code_challenge', challenge);
|
|
authorize.searchParams.set('code_challenge_method', 'S256');
|
|
return authorize.toString();
|
|
}
|
|
|
|
/** Complete the callback: verify state, exchange the code, store the tokens. */
|
|
async completeCallback(pluginId: string, userId: number, code: string, state: string, nowMs: number): Promise<void> {
|
|
const row = this.db.prepare('SELECT verifier, user_id, created_at FROM plugin_oauth_state WHERE state = ? AND plugin_id = ?').get(state, pluginId) as
|
|
| { verifier: string; user_id: number; created_at: number }
|
|
| undefined;
|
|
// State must exist, belong to THIS user, and be fresh — this binds the callback to
|
|
// the connect request and blocks CSRF / a replayed/foreign state.
|
|
if (!row || row.user_id !== userId || nowMs - row.created_at > STATE_TTL_MS) {
|
|
this.db.prepare('DELETE FROM plugin_oauth_state WHERE state = ?').run(state);
|
|
throw new Error('invalid or expired OAuth state');
|
|
}
|
|
this.db.prepare('DELETE FROM plugin_oauth_state WHERE state = ?').run(state); // single-use
|
|
|
|
const cfg = this.providerConfig(pluginId);
|
|
if (!cfg) throw new Error('OAuth is not configured for this plugin');
|
|
|
|
const token = await this.tokenRequest(cfg, {
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: this.redirectUri(pluginId),
|
|
code_verifier: row.verifier,
|
|
});
|
|
this.storeToken(pluginId, userId, token, nowMs);
|
|
}
|
|
|
|
/** A valid access token for the acting user, refreshing it if it is expiring. Null when
|
|
* the user hasn't connected. The plugin never receives the refresh token. */
|
|
async getAccessToken(pluginId: string, userId: number, nowMs: number): Promise<string | null> {
|
|
const row = this.db.prepare('SELECT access_token, refresh_token, expires_at FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?').get(pluginId, userId) as
|
|
| { access_token: string | null; refresh_token: string | null; expires_at: number | null }
|
|
| undefined;
|
|
if (!row || !row.access_token) return null;
|
|
|
|
const notExpiring = row.expires_at == null || row.expires_at - REFRESH_SKEW_S * 1000 > nowMs;
|
|
if (notExpiring) return decrypt_api_key(row.access_token) as string;
|
|
|
|
if (!row.refresh_token) return decrypt_api_key(row.access_token) as string; // no refresh token — hand back what we have
|
|
const cfg = this.providerConfig(pluginId);
|
|
if (!cfg) return null;
|
|
const token = await this.tokenRequest(cfg, {
|
|
grant_type: 'refresh_token',
|
|
refresh_token: decrypt_api_key(row.refresh_token) as string,
|
|
});
|
|
// Some providers omit a new refresh_token on refresh — keep the existing one.
|
|
if (!token.refresh_token) token.refresh_token = decrypt_api_key(row.refresh_token) as string;
|
|
this.storeToken(pluginId, userId, token, nowMs);
|
|
return token.access_token ?? null;
|
|
}
|
|
|
|
disconnect(pluginId: string, userId: number): void {
|
|
this.db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
|
|
this.db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
|
|
}
|
|
|
|
// --- internals ---
|
|
|
|
private async tokenRequest(cfg: OAuthProviderConfig, params: Record<string, string>): Promise<{ access_token?: string; refresh_token?: string; expires_in?: number; scope?: string }> {
|
|
assertSafeHttps(cfg.tokenUrl, 'token_url');
|
|
const body = new URLSearchParams({ ...params, client_id: cfg.clientId, client_secret: cfg.clientSecret });
|
|
// Route the server-side token POST through the SSRF guard: it resolves the host
|
|
// and refuses the link-local / cloud-metadata range (169.254/fe80/IMDSv6) while
|
|
// pinning the connection to the resolved IP, so a token_url that is a DNS name
|
|
// (or IPv6 literal) pointing at metadata can't reach it and can't DNS-rebind.
|
|
// Loopback/LAN stay reachable so a self-hosted internal IdP keeps working.
|
|
const resp = await safeFetchLlm(cfg.tokenUrl, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
body: body.toString(),
|
|
});
|
|
if (!resp.ok) throw new Error(`token endpoint returned ${resp.status}`);
|
|
const json = (await resp.json()) as Record<string, unknown>;
|
|
return {
|
|
access_token: typeof json.access_token === 'string' ? json.access_token : undefined,
|
|
refresh_token: typeof json.refresh_token === 'string' ? json.refresh_token : undefined,
|
|
expires_in: typeof json.expires_in === 'number' ? json.expires_in : undefined,
|
|
scope: typeof json.scope === 'string' ? json.scope : undefined,
|
|
};
|
|
}
|
|
|
|
private storeToken(pluginId: string, userId: number, token: { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string }, nowMs: number): void {
|
|
if (!token.access_token) throw new Error('token endpoint returned no access_token');
|
|
const expiresAt = token.expires_in ? nowMs + token.expires_in * 1000 : null;
|
|
this.db.prepare(
|
|
`INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token, refresh_token, expires_at, scope, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
|
ON CONFLICT(plugin_id, user_id) DO UPDATE SET
|
|
access_token = excluded.access_token,
|
|
refresh_token = COALESCE(excluded.refresh_token, plugin_oauth_tokens.refresh_token),
|
|
expires_at = excluded.expires_at,
|
|
scope = excluded.scope,
|
|
updated_at = excluded.updated_at`,
|
|
).run(
|
|
pluginId,
|
|
userId,
|
|
encrypt_api_key(token.access_token),
|
|
token.refresh_token ? encrypt_api_key(token.refresh_token) : null,
|
|
expiresAt,
|
|
token.scope ?? null,
|
|
);
|
|
}
|
|
}
|