Skip to content

Commit feadae0

Browse files
cmsparksclaude
andauthored
feat: Implement CIMD oauth for MCP (#513)
* Implement CIMD oauth for MCP * fix: revoke oauth grants on replay of any retired refresh token Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UyPgMqcdBitYcBryQfsXJm * refactor: track retired oauth refresh tokens only in their own table Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UyPgMqcdBitYcBryQfsXJm * style: wrap long lines in the oauth token integration tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UyPgMqcdBitYcBryQfsXJm --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent a3bb74e commit feadae0

32 files changed

Lines changed: 3248 additions & 72 deletions

‎cmd/api/main.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,7 @@ func apiOptions(
469469
}
470470
opts = append(opts, httpapi.WithSkillDownloadSigningKey(skillSigningKey))
471471
opts = append(opts, httpapi.WithAuthHTTPClient(operatorHTTPClient))
472+
opts = append(opts, httpapi.WithOAuthClientMetadataHTTPClient(publicServiceHTTPClient))
472473
opts = append(opts, httpapi.WithSlackOAuth(httpapi.SlackOAuthConfig{HTTPClient: publicServiceHTTPClient}))
473474
opts = append(opts, httpapi.WithRedisBackedAuth(redisClient))
474475
opts = append(opts, httpapi.WithTrustedProxyCIDRs(cfg.TrustedProxyCIDRs))

‎cmd/maintenance/main.go‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,9 @@ func runCoreMaintenanceTick(
386386
authCleanupDeleted := authCleanup.DeletedInactiveTokens > 0 ||
387387
authCleanup.DeletedBrowserSessions > 0 ||
388388
authCleanup.DeletedAbandonedUsers > 0 ||
389-
authCleanup.DeletedDeviceFlows > 0
389+
authCleanup.DeletedDeviceFlows > 0 ||
390+
authCleanup.DeletedOAuthCodes > 0 ||
391+
authCleanup.DeletedOAuthTokens > 0
390392
worked := reapedRuntimeLocks > 0 ||
391393
expiredDaemonRuntimes > 0 ||
392394
expiredProcessTools > 0 ||
@@ -426,6 +428,10 @@ func runCoreMaintenanceTick(
426428
authCleanup.DeletedAbandonedUsers,
427429
"deleted_device_flows",
428430
authCleanup.DeletedDeviceFlows,
431+
"deleted_oauth_codes",
432+
authCleanup.DeletedOAuthCodes,
433+
"deleted_oauth_tokens",
434+
authCleanup.DeletedOAuthTokens,
429435
)
430436
}
431437
}

‎docs/api/authentication.mdx‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,39 @@ Keep polling at the returned interval. After `slow_down`, add five seconds to th
113113

114114
A denied or expired flow returns `400` with `"access_denied"` or `"expired_token"`. The PAT does not expire automatically; it appears in the approving user's token list, where it can be revoked, and is used as a bearer token for subsequent API calls.
115115

116+
## Connecting MCP clients
117+
118+
The Omnara API is also exposed as an MCP server at `https://app.omnara.com/api/mcp`. MCP clients that support the [MCP authorization spec](https://modelcontextprotocol.io/specification/draft/basic/authorization) discover the OAuth endpoints themselves and sign you in through the browser, so no token needs to be pasted into the client.
119+
120+
Add the server to your client's `mcp.json`:
121+
122+
```json
123+
{
124+
"mcpServers": {
125+
"omnara": {
126+
"type": "http",
127+
"url": "https://app.omnara.com/api/mcp"
128+
}
129+
}
130+
}
131+
```
132+
133+
On first use the client opens the dashboard's **Authorize access to Omnara** page. Approve it and the client stores its own short-lived tokens, refreshing them in the background.
134+
135+
Clients without OAuth support can pass a personal access token or org API key instead:
136+
137+
```json
138+
{
139+
"mcpServers": {
140+
"omnara": {
141+
"type": "http",
142+
"url": "https://app.omnara.com/api/mcp",
143+
"headers": { "Authorization": "Bearer omnara_pat_v1_…" }
144+
}
145+
}
146+
}
147+
```
148+
116149
## Managing tokens
117150

118151
The **API Tokens** page — or [List personal access tokens](/api-reference/endpoints/users-and-api-keys/list-the-authenticated-users-personal-access-tokens) — shows your tokens with creation and last-used times, metadata only: the secret is never shown again. `last_used_at` tells you which tokens are actually alive; prune the rest, and [revoke](/api-reference/endpoints/users-and-api-keys/revoke-a-personal-access-token) immediately if a token leaks — revocation is permanent.

‎frontend/apps/web/src/router.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,15 @@ const deviceAuthRoute = createRoute({
180180
component: lazyRouteComponent(() => import('@/routes/DeviceAuth'), 'DeviceAuth'),
181181
})
182182

183+
const oauthAuthorizeRoute = createRoute({
184+
getParentRoute: () => authenticatedRoute,
185+
path: '/oauth/authorize',
186+
beforeLoad: ({ context, location }) => {
187+
requireOrganization(context.me, location.href)
188+
},
189+
component: lazyRouteComponent(() => import('@/routes/OAuthAuthorize'), 'OAuthAuthorize'),
190+
})
191+
183192
const onboardingRoute = createRoute({
184193
getParentRoute: () => authenticatedRoute,
185194
path: '/onboarding',
@@ -237,6 +246,7 @@ const routeTree = rootRoute.addChildren([
237246
forgotPasswordRoute,
238247
authenticatedRoute.addChildren([
239248
deviceAuthRoute,
249+
oauthAuthorizeRoute,
240250
onboardingRoute,
241251
invitationsRoute,
242252
onboardedRoute.addChildren([
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/** @vitest-environment happy-dom */
2+
3+
import { act } from 'react'
4+
import { createRoot, type Root } from 'react-dom/client'
5+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
import { OAuthAuthorize } from '@/routes/OAuthAuthorize'
8+
import { type FakeApi, fakeApi, type FakeRoute, jsonResponse, neverResponds } from '@/test/fake-api'
9+
import { enableReactActEnvironment } from '@/test/react-act'
10+
11+
let container: HTMLDivElement
12+
let root: Root
13+
let restoreActEnvironment: () => void
14+
let assignedLocation: string | undefined
15+
16+
const query =
17+
'?response_type=code&client_id=https%3A%2F%2Fclient.example%2Fclient.json&redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fcallback&state=abc'
18+
19+
function oauthAuthorizeApi({
20+
pending = () =>
21+
jsonResponse({
22+
client_id: 'https://client.example/client.json',
23+
client_name: 'Example MCP Client',
24+
client_uri: 'https://client.example',
25+
redirect_uri: 'http://127.0.0.1:3000/callback',
26+
redirect_host: '127.0.0.1:3000',
27+
loopback: true,
28+
resource: 'https://omnara.test/api/mcp',
29+
}),
30+
approve = () => jsonResponse({ redirect_url: 'http://127.0.0.1:3000/callback?code=abc' }),
31+
deny = neverResponds,
32+
}: {
33+
pending?: FakeRoute['respond']
34+
approve?: FakeRoute['respond']
35+
deny?: FakeRoute['respond']
36+
} = {}): FakeApi {
37+
const api = fakeApi([
38+
{ method: 'GET', path: '/api/auth/oauth/authorize/pending', respond: pending },
39+
{ method: 'POST', path: '/api/auth/oauth/authorize/approve', respond: approve },
40+
{ method: 'POST', path: '/api/auth/oauth/authorize/deny', respond: deny },
41+
])
42+
vi.stubGlobal('fetch', api.fetch)
43+
return api
44+
}
45+
46+
async function renderOAuthAuthorize() {
47+
await act(async () => {
48+
root.render(<OAuthAuthorize />)
49+
await new Promise((resolve) => setTimeout(resolve, 0))
50+
})
51+
}
52+
53+
function button(label: string): HTMLButtonElement {
54+
const match = Array.from(container.querySelectorAll('button')).find(
55+
(candidate) => candidate.textContent.trim() === label,
56+
)
57+
if (!match) throw new Error(`Missing button: ${label}`)
58+
return match
59+
}
60+
61+
beforeAll(() => {
62+
restoreActEnvironment = enableReactActEnvironment()
63+
})
64+
65+
afterAll(() => {
66+
restoreActEnvironment()
67+
})
68+
69+
beforeEach(() => {
70+
window.history.replaceState(null, '', `/oauth/authorize${query}`)
71+
assignedLocation = undefined
72+
vi.spyOn(window.location, 'assign').mockImplementation((url: string | URL) => {
73+
assignedLocation = String(url)
74+
})
75+
container = document.createElement('div')
76+
document.body.append(container)
77+
root = createRoot(container)
78+
})
79+
80+
afterEach(() => {
81+
act(() => {
82+
root.unmount()
83+
})
84+
container.remove()
85+
vi.unstubAllGlobals()
86+
vi.restoreAllMocks()
87+
})
88+
89+
describe('OAuth authorization decisions', () => {
90+
it('shows the client and warns about loopback redirects', async () => {
91+
oauthAuthorizeApi()
92+
await renderOAuthAuthorize()
93+
94+
expect(container.textContent).toContain('Example MCP Client')
95+
expect(container.textContent).toContain('127.0.0.1:3000')
96+
expect(container.textContent).toContain('redirects to a program running on your own computer')
97+
})
98+
99+
it('approves with the original query and follows the redirect', async () => {
100+
const api = oauthAuthorizeApi()
101+
await renderOAuthAuthorize()
102+
103+
await act(async () => {
104+
button('Approve').click()
105+
await new Promise((resolve) => setTimeout(resolve, 0))
106+
})
107+
108+
expect(
109+
api.requestsTo('POST', '/api/auth/oauth/authorize/approve').map((request) => request.body),
110+
).toEqual([{ query }])
111+
expect(assignedLocation).toBe('http://127.0.0.1:3000/callback?code=abc')
112+
expect(container.textContent).toContain('Returning to the application')
113+
})
114+
115+
it('follows the error redirect when the request is rejected for the client', async () => {
116+
oauthAuthorizeApi({
117+
pending: () =>
118+
jsonResponse(
119+
{
120+
error: 'invalid_target',
121+
error_description: 'resource is required',
122+
redirect_url: 'http://127.0.0.1:3000/callback?error=invalid_target',
123+
},
124+
400,
125+
),
126+
})
127+
await renderOAuthAuthorize()
128+
129+
expect(assignedLocation).toBe('http://127.0.0.1:3000/callback?error=invalid_target')
130+
})
131+
132+
it('shows client errors that cannot be redirected', async () => {
133+
oauthAuthorizeApi({
134+
pending: () =>
135+
jsonResponse(
136+
{ error: 'invalid_client', error_description: 'client metadata returned status 404' },
137+
400,
138+
),
139+
})
140+
await renderOAuthAuthorize()
141+
142+
expect(assignedLocation).toBeUndefined()
143+
expect(container.textContent).toContain('client metadata returned status 404')
144+
})
145+
})

0 commit comments

Comments
 (0)