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(' '),
};