mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(oauth): browser-initiated dynamic client registration (DCR)
Adds an OAuth 2.1 public client registration flow so MCP clients can
self-register via a user-facing consent page instead of requiring manual
setup in Settings.
Server:
- DB migration adds `is_public` and `created_via` columns to oauth_clients
- New GET /api/oauth/register/validate — validates DCR params, returns
requested scopes; unauthenticated callers get loginRequired flag
- New POST /api/oauth/register — creates a public client, saves consent,
and redirects with client_id (cookie auth required)
- `authenticateClient` / `refreshTokens` skip secret check for public
clients (PKCE provides the security guarantee)
- `createOAuthClient` accepts options for isPublic/createdVia; public
clients store an opaque secret hash instead of a usable secret
- `rotateOAuthClientSecret` blocked on public clients
- `isValidRedirectUri` extracted as a shared helper
- Discovery metadata now advertises registration_endpoint and auth method
`none`; token/revoke endpoints no longer require client_secret for
public clients
Client:
- New OAuthRegisterPage (/oauth/register) — loading → optional
login-required gate → scope selection → done states
- New ScopeGroupPicker component — collapsible groups, indeterminate
checkboxes, select-all per group or globally
- oauthApi.register.{validate,submit} added to api/client.ts
- apiClient exported so it can be reused outside api/client.ts
- IntegrationsTab tests fixed for new collapsible section structure
- collab_notes fallback changed from undefined to [] in MCP trip tools
This commit is contained in:
@@ -933,6 +933,13 @@ function runMigrations(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_parent ON oauth_tokens(parent_token_id);
|
||||
`);
|
||||
},
|
||||
// Migration: Public client support for browser-initiated dynamic registration (DCR)
|
||||
() => {
|
||||
db.exec(`
|
||||
ALTER TABLE oauth_clients ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE oauth_clients ADD COLUMN created_via TEXT NOT NULL DEFAULT 'settings_ui';
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -181,7 +181,7 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
reservations: canReadRes ? summary.reservations : undefined,
|
||||
packing: canReadPacking ? summary.packing : undefined,
|
||||
budget: canReadBudget ? summary.budget : undefined,
|
||||
collab_notes: canReadCollab ? summary.collab_notes : undefined,
|
||||
collab_notes: canReadCollab ? summary.collab_notes : [],
|
||||
todos,
|
||||
pollCount,
|
||||
messageCount,
|
||||
@@ -198,7 +198,7 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
reservations: canReadRes ? summary.reservations : undefined,
|
||||
packing: canReadPacking ? summary.packing : undefined,
|
||||
budget: canReadBudget ? summary.budget : undefined,
|
||||
collab_notes: canReadCollab ? summary.collab_notes : undefined,
|
||||
collab_notes: canReadCollab ? summary.collab_notes : [],
|
||||
todos,
|
||||
pollCount,
|
||||
messageCount,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
revokeToken,
|
||||
verifyPKCE,
|
||||
authenticateClient,
|
||||
isValidRedirectUri,
|
||||
listOAuthClients,
|
||||
createOAuthClient,
|
||||
deleteOAuthClient,
|
||||
@@ -76,10 +77,11 @@ oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request,
|
||||
authorization_endpoint: `${base}/oauth/authorize`,
|
||||
token_endpoint: `${base}/oauth/token`,
|
||||
revocation_endpoint: `${base}/oauth/revoke`,
|
||||
registration_endpoint: `${base}/oauth/register`,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
||||
scopes_supported: ALL_SCOPES,
|
||||
scope_descriptions: Object.fromEntries(
|
||||
ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label])
|
||||
@@ -102,8 +104,8 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
|
||||
return res.status(403).json({ error: 'mcp_disabled', error_description: 'MCP is not enabled' });
|
||||
}
|
||||
|
||||
if (!client_id || !client_secret) {
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'client_id and client_secret are required' });
|
||||
if (!client_id) {
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' });
|
||||
}
|
||||
|
||||
// ---- authorization_code grant ----
|
||||
@@ -180,8 +182,8 @@ oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Respo
|
||||
const { token, client_id, client_secret } = body;
|
||||
const ip = getClientIp(req);
|
||||
|
||||
if (!token || !client_id || !client_secret) {
|
||||
return res.status(400).json({ error: 'invalid_request', error_description: 'token, client_id, and client_secret are required' });
|
||||
if (!token || !client_id) {
|
||||
return res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id are required' });
|
||||
}
|
||||
|
||||
if (!authenticateClient(client_id, client_secret)) {
|
||||
@@ -304,6 +306,76 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
|
||||
return res.json({ redirect: url.toString() });
|
||||
});
|
||||
|
||||
// ---- Browser-initiated dynamic client registration ----
|
||||
|
||||
// SPA calls this on load to validate DCR params before rendering scope selection UI
|
||||
oauthApiRouter.get('/register/validate', validateLimiter, optionalAuth, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
|
||||
const { redirect_uri, client_name, scope } = req.query as Record<string, string>;
|
||||
const userId = (req as OptionalAuthRequest).user?.id ?? null;
|
||||
|
||||
if (!redirect_uri) {
|
||||
return res.json({ valid: false, error: 'invalid_request', error_description: 'redirect_uri is required' });
|
||||
}
|
||||
|
||||
if (!isValidRedirectUri(redirect_uri)) {
|
||||
return res.json({ valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri must use HTTPS (localhost is exempt)' });
|
||||
}
|
||||
|
||||
// Anti-fingerprinting: don't expose details to unauthenticated callers
|
||||
if (userId === null) {
|
||||
return res.json({ valid: true, loginRequired: true });
|
||||
}
|
||||
|
||||
const resolvedName = (client_name || '').trim().slice(0, 100) || 'MCP Client';
|
||||
const requestedScopes = (scope || '').split(' ').filter(s => (ALL_SCOPES as string[]).includes(s));
|
||||
|
||||
return res.json({ valid: true, client_name: resolvedName, requested_scopes: requestedScopes });
|
||||
});
|
||||
|
||||
// User submits DCR approval (or cancel) — requires cookie auth
|
||||
oauthApiRouter.post('/register', requireCookieAuth, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
|
||||
const { user } = req as AuthRequest;
|
||||
const { client_name, redirect_uri, scopes, state, approved } = req.body as {
|
||||
client_name: string;
|
||||
redirect_uri: string;
|
||||
scopes: string[];
|
||||
state?: string;
|
||||
approved?: boolean;
|
||||
};
|
||||
const ip = getClientIp(req);
|
||||
|
||||
// Validate redirect_uri before constructing any redirect URL
|
||||
if (!redirect_uri || !isValidRedirectUri(redirect_uri)) {
|
||||
return res.status(400).json({ error: 'invalid_request', error_description: 'Invalid redirect_uri' });
|
||||
}
|
||||
|
||||
if (approved === false) {
|
||||
const url = new URL(redirect_uri);
|
||||
url.searchParams.set('error', 'access_denied');
|
||||
url.searchParams.set('error_description', 'User cancelled the registration');
|
||||
if (state) url.searchParams.set('state', state);
|
||||
return res.json({ redirect: url.toString() });
|
||||
}
|
||||
|
||||
const result = createOAuthClient(
|
||||
user.id, client_name, [redirect_uri], scopes, ip,
|
||||
{ isPublic: true, createdVia: 'browser-registration' },
|
||||
);
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
|
||||
const newClientId = result.client!.client_id as string;
|
||||
saveConsent(newClientId, user.id, scopes, ip);
|
||||
|
||||
const url = new URL(redirect_uri);
|
||||
url.searchParams.set('client_id', newClientId);
|
||||
if (state) url.searchParams.set('state', state);
|
||||
return res.json({ redirect: url.toString() });
|
||||
});
|
||||
|
||||
// ---- OAuth client CRUD ----
|
||||
|
||||
oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
@@ -55,6 +55,8 @@ interface OAuthClientRow {
|
||||
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'
|
||||
}
|
||||
|
||||
interface OAuthTokenRow {
|
||||
@@ -100,21 +102,33 @@ function generateRefreshToken(): string {
|
||||
|
||||
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 FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via 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),
|
||||
redirect_uris: JSON.parse(r.redirect_uris),
|
||||
allowed_scopes: JSON.parse(r.allowed_scopes),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Returns true if the URI is a valid OAuth redirect target (HTTPS or localhost). */
|
||||
export function isValidRedirectUri(uri: string): boolean {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
return url.protocol === 'https:' || url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createOAuthClient(
|
||||
userId: number,
|
||||
name: string,
|
||||
redirectUris: string[],
|
||||
allowedScopes: string[],
|
||||
ip?: string | null,
|
||||
options?: { isPublic?: boolean; createdVia?: string },
|
||||
): { 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 };
|
||||
@@ -122,14 +136,15 @@ export function createOAuthClient(
|
||||
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
|
||||
|
||||
for (const uri of redirectUris) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
if (url.protocol !== 'https:' && url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') {
|
||||
return { error: `Redirect URI must use HTTPS (localhost exempt): ${uri}`, status: 400 };
|
||||
}
|
||||
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 };
|
||||
@@ -139,20 +154,23 @@ export function createOAuthClient(
|
||||
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 };
|
||||
|
||||
const isPublic = options?.isPublic ?? false;
|
||||
const createdVia = options?.createdVia ?? 'settings_ui';
|
||||
const id = randomUUID();
|
||||
const clientId = randomUUID();
|
||||
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex');
|
||||
const secretHash = hashToken(rawSecret);
|
||||
// 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) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes));
|
||||
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia);
|
||||
|
||||
const row = db.prepare(
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at FROM oauth_clients WHERE id = ?'
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE id = ?'
|
||||
).get(id) as OAuthClientRow;
|
||||
|
||||
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim() }, ip });
|
||||
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic }, ip });
|
||||
|
||||
return {
|
||||
client: {
|
||||
@@ -163,7 +181,10 @@ export function createOAuthClient(
|
||||
redirect_uris: JSON.parse(row.redirect_uris),
|
||||
allowed_scopes: JSON.parse(row.allowed_scopes),
|
||||
created_at: row.created_at,
|
||||
client_secret: rawSecret, // shown once — not stored in plain text
|
||||
is_public: Boolean(row.is_public),
|
||||
created_via: row.created_via,
|
||||
// client_secret only present for confidential clients — shown once, not stored in plain text
|
||||
...(rawSecret ? { client_secret: rawSecret } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -173,8 +194,9 @@ export function rotateOAuthClientSecret(
|
||||
clientRowId: string,
|
||||
ip?: string | null,
|
||||
): { error?: string; status?: number; client_secret?: string } {
|
||||
const row = db.prepare('SELECT id, client_id FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
|
||||
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);
|
||||
@@ -363,12 +385,16 @@ function revokeChain(rootId: number): number[] {
|
||||
export function refreshTokens(
|
||||
rawRefreshToken: string,
|
||||
clientId: string,
|
||||
clientSecret: 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 FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
|
||||
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 (!timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) 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(`
|
||||
@@ -587,10 +613,15 @@ export function verifyPKCE(codeVerifier: string, codeChallenge: string): boolean
|
||||
// Client authentication (for token endpoint)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function authenticateClient(clientId: string, clientSecret: string): OAuthClientRow | null {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user