From c828fca059f58aeb3375272378a73d9fe5c4f979 Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 22 May 2026 14:42:20 +0200 Subject: [PATCH 1/8] feat(oauth): add client_credentials grant for machine clients and fix PlaceAvatar stale image retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add OAuth 2.0 client_credentials flow so AI agents and scripts can obtain tokens directly via client_id + client_secret without any browser interaction - New DB column allows_client_credentials on oauth_clients; machine clients skip redirect URI requirement and are forced confidential - New issueClientCredentialsToken() issues access-only tokens (no refresh token, RFC 6749 §4.4) - UI: "Machine client" checkbox in create-client modal, hides redirect URI field, shows indigo badge on existing machine clients - Advertise client_credentials in OAuth discovery document - 8 new integration tests (OAUTH-CC-001–008) - i18n: 4 new keys across all 15 languages - Fix PlaceAvatar: re-fetch photo via API on image_url load failure before falling back to initials - Update MCP wiki docs with new Option B machine client setup guide --- client/src/api/client.ts | 2 +- .../components/Settings/IntegrationsTab.tsx | 64 ++++++-- client/src/components/shared/PlaceAvatar.tsx | 14 +- client/src/i18n/translations/ar.ts | 4 + client/src/i18n/translations/br.ts | 4 + client/src/i18n/translations/cs.ts | 4 + client/src/i18n/translations/de.ts | 4 + client/src/i18n/translations/en.ts | 4 + client/src/i18n/translations/es.ts | 4 + client/src/i18n/translations/fr.ts | 4 + client/src/i18n/translations/hu.ts | 4 + client/src/i18n/translations/id.ts | 4 + client/src/i18n/translations/it.ts | 4 + client/src/i18n/translations/nl.ts | 4 + client/src/i18n/translations/pl.ts | 4 + client/src/i18n/translations/ru.ts | 4 + client/src/i18n/translations/zh.ts | 4 + client/src/i18n/translations/zhTw.ts | 4 + server/src/app.ts | 2 +- server/src/db/migrations.ts | 8 + server/src/routes/oauth.ts | 51 ++++++- server/src/services/oauthService.ts | 58 +++++++- server/tests/integration/oauth.test.ts | 139 +++++++++++++++++- wiki/MCP-Overview.md | 10 ++ wiki/MCP-Setup.md | 65 ++++---- 25 files changed, 417 insertions(+), 56 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 837ed16b..7b2a601d 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -209,7 +209,7 @@ export const oauthApi = { clients: { list: () => apiClient.get('/oauth/clients').then(r => r.data), - create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) => + create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) => apiClient.post('/oauth/clients', data).then(r => r.data), rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data), delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data), diff --git a/client/src/components/Settings/IntegrationsTab.tsx b/client/src/components/Settings/IntegrationsTab.tsx index 430da0f6..6daf674f 100644 --- a/client/src/components/Settings/IntegrationsTab.tsx +++ b/client/src/components/Settings/IntegrationsTab.tsx @@ -69,6 +69,7 @@ interface OAuthClient { client_id: string redirect_uris: string[] allowed_scopes: string[] + allows_client_credentials: boolean created_at: string client_secret?: string // only present on create } @@ -117,6 +118,7 @@ export default function IntegrationsTab(): React.ReactElement { const [oauthRotating, setOauthRotating] = useState(false) // oauthScopesOpen is managed internally by ScopeGroupPicker const [oauthScopesExpanded, setOauthScopesExpanded] = useState>({}) + const [oauthIsMachine, setOauthIsMachine] = useState(false) // MCP sub-tab state const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth') @@ -214,16 +216,23 @@ export default function IntegrationsTab(): React.ReactElement { }, [mcpEnabled]) const handleCreateOAuthClient = async () => { - if (!oauthNewName.trim() || !oauthNewUris.trim()) return + if (!oauthNewName.trim()) return + if (!oauthIsMachine && !oauthNewUris.trim()) return setOauthCreating(true) try { - const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean) - const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes }) + const uris = oauthIsMachine ? [] : oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean) + const d = await oauthApi.clients.create({ + name: oauthNewName.trim(), + redirect_uris: uris, + allowed_scopes: oauthNewScopes, + ...(oauthIsMachine ? { allows_client_credentials: true } : {}), + }) setOauthCreatedClient(d.client) setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }]) setOauthNewName('') setOauthNewUris('') setOauthNewScopes([]) + setOauthIsMachine(false) } catch { toast.error(t('settings.oauth.toast.createError')) } finally { @@ -342,7 +351,7 @@ export default function IntegrationsTab(): React.ReactElement {

{t('settings.oauth.clientsHint')}

- @@ -360,7 +369,15 @@ export default function IntegrationsTab(): React.ReactElement {
-

{client.name}

+
+

{client.name}

+ {client.allows_client_credentials && ( + + {t('settings.oauth.badge.machine')} + + )} +

{t('settings.oauth.clientId')}: {client.client_id} {t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)} @@ -616,15 +633,26 @@ export default function IntegrationsTab(): React.ReactElement { autoFocus />

-
- -