feat(admin): per-client token TTL — configurable token lifetime

Problem: OAuth tokens expire in 1 hour (hardcoded). Claude Code's built-in
OAuth client doesn't auto-refresh, so users get 401s every hour.

Fix: per-client token_ttl column on oauth_clients table. Set at registration
time or updated later via the admin dashboard.

Server:
- oauth_clients.token_ttl column (nullable integer, seconds)
- exchangeClientCredentials reads per-client TTL, falls back to server default
- POST /admin/api/register-client accepts tokenTtl param
- POST /admin/api/update-client-ttl for existing clients
- Agents API returns token_ttl for display

Admin UI:
- Register modal: Token Lifetime dropdown (1h, 24h, 7d, 30d, 1y, no expiry)
- Agent drawer: shows current TTL in Details section

Presets: gstack-desktop and garry-claude-code set to 30-day tokens.
This commit is contained in:
Wintermute
2026-05-03 15:14:29 +00:00
parent f7f95e5be8
commit e7989e9756
4 changed files with 52 additions and 8 deletions
+1
View File
@@ -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 }) }),
};
+22 -2
View File
@@ -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 }: {
<label>Agent Name</label>
<input placeholder="e.g. perplexity-production" value={name} onChange={e => setName(e.target.value)} autoFocus />
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 16 }}>
<label>Scopes</label>
<div className="checkbox-group">
{(['read', 'write', 'admin'] as const).map(s => (
@@ -315,6 +326,13 @@ function RegisterModal({ onClose, onRegistered }: {
))}
</div>
</div>
<div style={{ marginBottom: 20 }}>
<label>Token Lifetime</label>
<select value={ttl} onChange={e => setTtl(e.target.value)}
style={{ width: '100%', background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', fontSize: 14 }}>
{ttlOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
{error && <div style={{ color: 'var(--error)', fontSize: 13, marginBottom: 12 }}>{error}</div>}
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
@@ -417,6 +435,8 @@ function AgentDrawer({ agent, onClose }: { agent: Agent; onClose: () => void })
))}</span>
<span style={{ color: 'var(--text-secondary)' }}>Registered</span>
<span>{new Date(agent.created_at).toLocaleDateString()}</span>
<span style={{ color: 'var(--text-secondary)' }}>Token TTL</span>
<span>{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)'}</span>
</div>
<div className="section-title">Config Export</div>
+20 -3
View File
@@ -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
// ---------------------------------------------------------------------------
+9 -3
View File
@@ -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<OAuthTokens> {
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(' '),
};