mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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:
@@ -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 }) }),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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(' '),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user