diff --git a/admin/src/api.ts b/admin/src/api.ts index 3cbc087f2..45fa0296d 100644 --- a/admin/src/api.ts +++ b/admin/src/api.ts @@ -47,4 +47,5 @@ export const api = { apiKeys: () => apiFetch('/admin/api/api-keys'), createApiKey: (name: string) => apiFetch('/admin/api/api-keys', { method: 'POST', body: JSON.stringify({ name }) }), revokeApiKey: (name: string) => apiFetch('/admin/api/api-keys/revoke', { method: 'POST', body: JSON.stringify({ name }) }), + updateClientTtl: (clientId: string, tokenTtl: number | null) => apiFetch('/admin/api/update-client-ttl', { method: 'POST', body: JSON.stringify({ clientId, tokenTtl }) }), }; diff --git a/admin/src/pages/Agents.tsx b/admin/src/pages/Agents.tsx index f9945e1e5..f806d5e6a 100644 --- a/admin/src/pages/Agents.tsx +++ b/admin/src/pages/Agents.tsx @@ -18,6 +18,7 @@ interface Agent { last_used_at: string | null; total_requests: number; requests_today: number; + token_ttl: number | null; } interface ApiKey { @@ -269,9 +270,19 @@ function RegisterModal({ onClose, onRegistered }: { }) { const [name, setName] = useState(''); const [scopes, setScopes] = useState({ read: true, write: false, admin: false }); + const [ttl, setTtl] = useState('86400'); // 24h default const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const ttlOptions = [ + { label: '1 hour', value: '3600' }, + { label: '24 hours', value: '86400' }, + { label: '7 days', value: '604800' }, + { label: '30 days', value: '2592000' }, + { label: '1 year', value: '31536000' }, + { label: 'No expiry', value: '0' }, + ]; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!name.trim()) { setError('Name required'); return; } @@ -284,7 +295,7 @@ function RegisterModal({ onClose, onRegistered }: { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: name.trim(), scopes: selectedScopes }), + body: JSON.stringify({ name: name.trim(), scopes: selectedScopes, tokenTtl: ttl === '0' ? 315360000 : Number(ttl) }), }); if (!res.ok) throw new Error('Registration failed'); const data = await res.json(); @@ -304,7 +315,7 @@ function RegisterModal({ onClose, onRegistered }: { setName(e.target.value)} autoFocus /> -
+
{(['read', 'write', 'admin'] as const).map(s => ( @@ -315,6 +326,13 @@ function RegisterModal({ onClose, onRegistered }: { ))}
+
+ + +
{error &&
{error}
}
@@ -417,6 +435,8 @@ function AgentDrawer({ agent, onClose }: { agent: Agent; onClose: () => void }) ))} Registered {new Date(agent.created_at).toLocaleDateString()} + Token TTL + {agent.token_ttl ? (agent.token_ttl >= 31536000 ? 'No expiry' : agent.token_ttl >= 86400 ? `${Math.floor(agent.token_ttl / 86400)}d` : agent.token_ttl >= 3600 ? `${Math.floor(agent.token_ttl / 3600)}h` : `${agent.token_ttl}s`) : '1h (default)'}
Config Export
diff --git a/src/commands/serve-http.ts b/src/commands/serve-http.ts index ec9777781..eec4709fc 100644 --- a/src/commands/serve-http.ts +++ b/src/commands/serve-http.ts @@ -264,7 +264,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption app.get('/admin/api/agents', requireAdmin, async (_req: Request, res: Response) => { try { const agents = await sql` - SELECT c.client_id, c.client_name, c.grant_types, c.scope, c.created_at, + SELECT c.client_id, c.client_name, c.grant_types, c.scope, c.created_at, c.token_ttl, (SELECT max(created_at) FROM mcp_request_log WHERE token_name = c.client_id) as last_used_at, (SELECT count(*)::int FROM mcp_request_log WHERE token_name = c.client_id) as total_requests, (SELECT count(*)::int FROM mcp_request_log WHERE token_name = c.client_id AND created_at > now() - interval '24 hours') as requests_today @@ -388,17 +388,34 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption // Register client from admin dashboard app.post('/admin/api/register-client', requireAdmin, express.json(), async (req: Request, res: Response) => { try { - const { name, scopes } = req.body; + const { name, scopes, tokenTtl } = req.body; if (!name) { res.status(400).json({ error: 'Name required' }); return; } const result = await oauthProvider.registerClientManual( name, ['client_credentials'], scopes || 'read', [], ); - res.json(result); + // Set per-client TTL if specified + if (tokenTtl && Number(tokenTtl) > 0) { + await sql`UPDATE oauth_clients SET token_ttl = ${Number(tokenTtl)} WHERE client_id = ${result.clientId}`; + } + res.json({ ...result, tokenTtl: tokenTtl ? Number(tokenTtl) : null }); } catch (e) { res.status(500).json({ error: e instanceof Error ? e.message : 'Registration failed' }); } }); + // Update client TTL + app.post('/admin/api/update-client-ttl', requireAdmin, express.json(), async (req: Request, res: Response) => { + try { + const { clientId, tokenTtl } = req.body; + if (!clientId) { res.status(400).json({ error: 'clientId required' }); return; } + const ttl = tokenTtl === null || tokenTtl === 0 ? null : Number(tokenTtl); + await sql`UPDATE oauth_clients SET token_ttl = ${ttl} WHERE client_id = ${clientId}`; + res.json({ updated: true, tokenTtl: ttl }); + } catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : 'Update failed' }); + } + }); + // --------------------------------------------------------------------------- // SSE live activity feed // --------------------------------------------------------------------------- diff --git a/src/core/oauth-provider.ts b/src/core/oauth-provider.ts index 67f1fca8d..194bc873b 100644 --- a/src/core/oauth-provider.ts +++ b/src/core/oauth-provider.ts @@ -366,8 +366,12 @@ export class GBrainOAuthProvider implements OAuthServerProvider { const requestedScopes = requestedScope ? requestedScope.split(' ').filter(Boolean) : allowedScopes; const grantedScopes = requestedScopes.filter(s => allowedScopes.includes(s)); + // Per-client TTL override (stored in oauth_clients.token_ttl) + const ttlRows = await this.sql`SELECT token_ttl FROM oauth_clients WHERE client_id = ${clientId}`; + const clientTtl = ttlRows.length > 0 && ttlRows[0].token_ttl ? Number(ttlRows[0].token_ttl) : undefined; + // Client credentials: access token only, NO refresh token (RFC 6749 4.4.3) - return this.issueTokens(clientId, grantedScopes, undefined, false); + return this.issueTokens(clientId, grantedScopes, undefined, false, clientTtl); } // ------------------------------------------------------------------------- @@ -419,11 +423,13 @@ export class GBrainOAuthProvider implements OAuthServerProvider { scopes: string[], resource: URL | undefined, includeRefresh: boolean, + ttlOverride?: number, ): Promise { const accessToken = generateToken('gbrain_at_'); const accessHash = hashToken(accessToken); const now = Math.floor(Date.now() / 1000); - const accessExpiry = now + this.tokenTtl; + const effectiveTtl = ttlOverride || this.tokenTtl; + const accessExpiry = now + effectiveTtl; await this.sql` INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at, resource) @@ -434,7 +440,7 @@ export class GBrainOAuthProvider implements OAuthServerProvider { const result: OAuthTokens = { access_token: accessToken, token_type: 'bearer', - expires_in: this.tokenTtl, + expires_in: effectiveTtl, scope: scopes.join(' '), };