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'),
|
apiKeys: () => apiFetch('/admin/api/api-keys'),
|
||||||
createApiKey: (name: string) => apiFetch('/admin/api/api-keys', { method: 'POST', body: JSON.stringify({ name }) }),
|
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 }) }),
|
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;
|
last_used_at: string | null;
|
||||||
total_requests: number;
|
total_requests: number;
|
||||||
requests_today: number;
|
requests_today: number;
|
||||||
|
token_ttl: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiKey {
|
interface ApiKey {
|
||||||
@@ -269,9 +270,19 @@ function RegisterModal({ onClose, onRegistered }: {
|
|||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [scopes, setScopes] = useState({ read: true, write: false, admin: false });
|
const [scopes, setScopes] = useState({ read: true, write: false, admin: false });
|
||||||
|
const [ttl, setTtl] = useState('86400'); // 24h default
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim()) { setError('Name required'); return; }
|
if (!name.trim()) { setError('Name required'); return; }
|
||||||
@@ -284,7 +295,7 @@ function RegisterModal({ onClose, onRegistered }: {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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');
|
if (!res.ok) throw new Error('Registration failed');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -304,7 +315,7 @@ function RegisterModal({ onClose, onRegistered }: {
|
|||||||
<label>Agent Name</label>
|
<label>Agent Name</label>
|
||||||
<input placeholder="e.g. perplexity-production" value={name} onChange={e => setName(e.target.value)} autoFocus />
|
<input placeholder="e.g. perplexity-production" value={name} onChange={e => setName(e.target.value)} autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: 20 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<label>Scopes</label>
|
<label>Scopes</label>
|
||||||
<div className="checkbox-group">
|
<div className="checkbox-group">
|
||||||
{(['read', 'write', 'admin'] as const).map(s => (
|
{(['read', 'write', 'admin'] as const).map(s => (
|
||||||
@@ -315,6 +326,13 @@ function RegisterModal({ onClose, onRegistered }: {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>}
|
{error && <div style={{ color: 'var(--error)', fontSize: 13, marginBottom: 12 }}>{error}</div>}
|
||||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
<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>
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>Registered</span>
|
<span style={{ color: 'var(--text-secondary)' }}>Registered</span>
|
||||||
<span>{new Date(agent.created_at).toLocaleDateString()}</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>
|
||||||
|
|
||||||
<div className="section-title">Config Export</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) => {
|
app.get('/admin/api/agents', requireAdmin, async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const agents = await sql`
|
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 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) 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
|
(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
|
// Register client from admin dashboard
|
||||||
app.post('/admin/api/register-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
app.post('/admin/api/register-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { name, scopes } = req.body;
|
const { name, scopes, tokenTtl } = req.body;
|
||||||
if (!name) { res.status(400).json({ error: 'Name required' }); return; }
|
if (!name) { res.status(400).json({ error: 'Name required' }); return; }
|
||||||
const result = await oauthProvider.registerClientManual(
|
const result = await oauthProvider.registerClientManual(
|
||||||
name, ['client_credentials'], scopes || 'read', [],
|
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) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e instanceof Error ? e.message : 'Registration failed' });
|
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
|
// SSE live activity feed
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -366,8 +366,12 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
|||||||
const requestedScopes = requestedScope ? requestedScope.split(' ').filter(Boolean) : allowedScopes;
|
const requestedScopes = requestedScope ? requestedScope.split(' ').filter(Boolean) : allowedScopes;
|
||||||
const grantedScopes = requestedScopes.filter(s => allowedScopes.includes(s));
|
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)
|
// 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[],
|
scopes: string[],
|
||||||
resource: URL | undefined,
|
resource: URL | undefined,
|
||||||
includeRefresh: boolean,
|
includeRefresh: boolean,
|
||||||
|
ttlOverride?: number,
|
||||||
): Promise<OAuthTokens> {
|
): Promise<OAuthTokens> {
|
||||||
const accessToken = generateToken('gbrain_at_');
|
const accessToken = generateToken('gbrain_at_');
|
||||||
const accessHash = hashToken(accessToken);
|
const accessHash = hashToken(accessToken);
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const accessExpiry = now + this.tokenTtl;
|
const effectiveTtl = ttlOverride || this.tokenTtl;
|
||||||
|
const accessExpiry = now + effectiveTtl;
|
||||||
|
|
||||||
await this.sql`
|
await this.sql`
|
||||||
INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at, resource)
|
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 = {
|
const result: OAuthTokens = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
token_type: 'bearer',
|
token_type: 'bearer',
|
||||||
expires_in: this.tokenTtl,
|
expires_in: effectiveTtl,
|
||||||
scope: scopes.join(' '),
|
scope: scopes.join(' '),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user