Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
956ff8b
Add OAuth configuration, entities and schema
MarceloRGonc Jul 28, 2026
b6f62ab
Add OAuth protocol primitives
MarceloRGonc Jul 28, 2026
c1f7a89
Add OAuth issuance, revocation and token exchange
MarceloRGonc Jul 28, 2026
5fe3eba
Authenticate OAuth tokens with positive audience enforcement
MarceloRGonc Jul 28, 2026
d9e2270
Add OAuth endpoints, discovery and cleanup, behind a feature flag
MarceloRGonc Jul 28, 2026
15c917a
Cover OAuth database semantics with integration tests
MarceloRGonc Jul 28, 2026
5814799
Document external-agent OAuth and add a flow script
MarceloRGonc Jul 28, 2026
640deca
Make OAuth config tests independent of the local environment
MarceloRGonc Jul 28, 2026
d826965
Pass the tool allow-list to the MCP server as a file
MarceloRGonc Jul 28, 2026
bc4c289
Add the OAuth consent screen
MarceloRGonc Jul 28, 2026
04156b7
Move consent into a dialog on a Connected apps settings page
MarceloRGonc Jul 29, 2026
c348055
Record why the OAuth principal must stay SERVICE
MarceloRGonc Jul 29, 2026
4539448
Let a connection switch project, bounded by membership
MarceloRGonc Jul 29, 2026
244827b
Drop the project row from the consent screen
MarceloRGonc Jul 29, 2026
9df350a
Stop tracking the local engine code cache
MarceloRGonc Jul 29, 2026
9638167
Bring the design doc back in line with what shipped
MarceloRGonc Jul 29, 2026
3a5314f
Drop three write-only columns and the responses that carried them
MarceloRGonc Jul 29, 2026
458da69
Move the project off the grant, onto the refresh token
MarceloRGonc Jul 29, 2026
3a904a8
Style connected apps after the integrations card, with a red Disconnect
MarceloRGonc Jul 29, 2026
79f4a76
Drop the divider under the connected apps heading
MarceloRGonc Jul 29, 2026
8337fcc
Match the settings page title to the other settings routes
MarceloRGonc Jul 29, 2026
c02430e
Merge branch 'main' into mg/OPS-4673
MarceloRGonc Jul 29, 2026
06e5d5c
Address PR review: cleanup handler, retention anchor, module-scope t()
MarceloRGonc Jul 29, 2026
5a1c6f1
Import accessTokenManager after the mocks in the signup test
MarceloRGonc Jul 30, 2026
b975ebd
Fix the SonarCloud findings worth fixing
MarceloRGonc Jul 30, 2026
c00f09c
Bound the grant caches and validate the OAuth TTLs at boot
MarceloRGonc Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,21 @@ CHROMATIC_PROJECT_TOKEN=chpt_sample_secret
# THEME
OPS_DARK_THEME_ENABLED=false
OPS_CODE_BLOCK_MEMORY_LIMIT_IN_MB=256

# EXTERNAL AGENT OAUTH (OPS-4673)
# Turns on the OAuth 2.1 authorization server used by external agents.
OPS_OAUTH_ENABLED=false
# Public base URL of this API. Becomes the token issuer and the API audience.
OPS_OAUTH_ISSUER_URL=http://localhost:3000
# Canonical URL of the hosted MCP server, when one is deployed.
OPS_MCP_RESOURCE_URL=
# Shared secret the MCP resource server authenticates with. Minimum 32 characters.
OPS_OAUTH_RS_CLIENT_SECRET=
# Token lifetimes. Shown with their defaults; the access-token TTL is the upper
# bound on how long a revoked connection can keep working.
OPS_OAUTH_ACCESS_TOKEN_TTL_SECONDS=900
OPS_OAUTH_REFRESH_TOKEN_TTL_DAYS=30
OPS_OAUTH_EXCHANGE_TOKEN_TTL_SECONDS=300
# Optional: sign OAuth tokens with an operator-managed key instead of the
# auto-generated one held in the database.
OPS_OAUTH_SIGNING_KEY_PEM_PATH=
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ node_modules
/tmp
/.nx
/cache
# The engine resolves its code cache relative to the working directory, so running a
# server from inside its own package writes one there too. Needs `**/` because a pattern
# with an interior slash is anchored to this file's directory.
**/cache/codes/
/packages/ui-components/storybook-static


Expand Down
608 changes: 608 additions & 0 deletions docs/oauth-design.md

Large diffs are not rendered by default.

197 changes: 197 additions & 0 deletions docs/oauth-manual-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# Testing external-agent OAuth locally

How to exercise the OAuth 2.1 authorization server by hand. Design:
`docs/oauth-design.md` (OPS-4673).

The whole chain works end to end: an MCP client discovers the server, registers,
opens the browser at the consent screen, and receives a token. Two ways to test it
— [by hand with the script](#walk-the-whole-flow), which needs no browser, or
[with a real client](#connect-a-real-client), which is what users will do.

The MCP resource server lives in its own repository, `openops-mcp`.

## Start the API with OAuth on

OAuth is off by default and every route 404s until it is enabled. Postgres is
required — the migration is registered for Postgres only.

```bash
docker compose up -d --wait

export $(grep -v '^#' .env | xargs) # your usual local settings
export PATH="$PWD/node_modules/.bin:$PATH" # the block rebuild step needs nx

export OPS_OAUTH_ENABLED=true
export OPS_OAUTH_ISSUER_URL=http://localhost:3000 # public base URL of this API
export OPS_MCP_RESOURCE_URL=http://localhost:3020/mcp
export OPS_OAUTH_RS_CLIENT_SECRET=$(openssl rand -hex 32)

npx nx build server-api && node dist/packages/server/api/main.js
```

First boot generates the RS256 signing keypair and logs
`OAuth authorization server enabled`. Nothing else is needed: the keypair is
created automatically and stored encrypted.

Sanity check, in another shell:

```bash
curl -s localhost:3000/.well-known/oauth-authorization-server | jq
curl -s localhost:3000/v1/oauth/jwks.json | jq '.keys[0] | {kty, alg, kid}'
```

## Walk the whole flow

```bash
tools/oauth-flow.sh # api resource — a CLI or partner agent calling REST directly
tools/oauth-flow.sh mcp # mcp resource — adds the token-exchange step
```

Pass `OPS_OAUTH_RS_CLIENT_SECRET` with the same value the API was started with;
the `mcp` mode authenticates as the resource server. The script registers a
client, authorizes, approves consent, redeems the code, calls the API, rotates
the refresh token, and revokes the connection — printing the token claims at each
step so you can see what a client actually receives.

The two modes differ in one way that matters: with `mcp`, the client's own token
is **refused** by the API (401) and has to be exchanged for a separate
API-audience token first. That is the no-token-passthrough rule, and the script
asserts it.

## Connect a real client

This is the path a user takes, and the only one that exercises the consent screen.
You need the frontend running (`npx nx serve react-ui`, port 4200) as well as the
API, and `OPS_FRONTEND_URL` pointing at it — that is what the authorize endpoint
redirects the browser to.

Start the MCP resource server from the `openops-mcp` repository:

```bash
cd ../openops-mcp
MCP_TRANSPORT=http \
OPENOPS_API_URL=http://localhost:3000 \
OPENOPS_MCP_ROUTES=config/routes.oss.yaml \
OPENOPS_MCP_ISSUER=http://localhost:3000 \
OPENOPS_MCP_RESOURCE_URL=http://localhost:3020/mcp \
OPENOPS_MCP_CLIENT_SECRET="$OPS_OAUTH_RS_CLIENT_SECRET" \
uv run openops-mcp
```

Then point a client at it. With Claude Code:

```bash
claude mcp add --transport http openops http://localhost:3020/mcp
```

The client discovers the authorization server, registers itself, and opens your
browser at **Settings → Connected apps**, with the consent dialog over it. Sign in
if you are not already. Approving sends the browser back to the client, which
redeems the code and lists the tools.

Worth confirming while you are here:

- **The project is named in the dialog**, and it matches `project_id` in the
issued token — that claim is what every later request is authorized against.
- **Cancelling** returns the client to its callback with `error=access_denied`.
So does dismissing the dialog: the client is waiting on its redirect, and
telling it no beats leaving it to time out.
- **Reloading the page** after deciding shows the expired-request message rather
than a second consent dialog. The pending record is single-use.
- **Connecting a second client** (or the same one again) produces an independent
connection. Both appear as separate rows on that page, and disconnecting one
leaves the other working — which is the point of the per-connection model.
- **The page is hidden** when `OPS_OAUTH_ENABLED` is false, because every route it
depends on is unregistered.

## Switching project

A connection acts wherever the user can, not only where it started. With a token in
hand:

```bash
# Where may this connection go, and where is it now?
curl -s localhost:3000/v1/oauth/projects -H "Authorization: Bearer $TOKEN" | jq

# Move a direct API client.
curl -s -X POST localhost:3000/v1/oauth/token \
-d "grant_type=refresh_token&refresh_token=$REFRESH&client_id=$CID&project_id=$OTHER" | jq

# Move a resource server on an agent's behalf — the Claude Code path.
curl -s -X POST localhost:3000/v1/oauth/token \
-u "openops-mcp-rs:$OPS_OAUTH_RS_CLIENT_SECRET" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=$MCP_TOKEN&project_id=$OTHER" | jq
```

Naming a project the user is not a member of returns `invalid_target`, and on the
refresh path the refusal happens before the token is consumed — so a wrong guess does
not cost a working connection. Decode `project_id` from the returned access token to
confirm the move.

This edition has one project per organization, so there is usually nowhere else to go.
To exercise it, add a second project to the same organization — note that
`tablesDatabaseToken` must be a genuinely encrypted value, since the API decrypts it at
boot and will refuse to start on a malformed one.

## Things worth poking at by hand

Each of these should produce a clean OAuth error, never a 500:

```bash
CID=$(curl -s -X POST localhost:3000/v1/oauth/register -H 'Content-Type: application/json' \
-d '{"client_name":"Probe","redirect_uris":["http://127.0.0.1:41100/callback"]}' | jq -r .client_id)
AUTH="localhost:3000/v1/oauth/authorize?client_id=$CID&redirect_uri=http%3A%2F%2F127.0.0.1%3A41100%2Fcallback&response_type=code&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&resource=http%3A%2F%2Flocalhost%3A3020%2Fmcp"

# Unregistered redirect_uri: renders an error, must NOT redirect (open-redirect boundary)
curl -si "localhost:3000/v1/oauth/authorize?client_id=$CID&redirect_uri=https%3A%2F%2Fattacker.example%2Fsteal&response_type=code&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&resource=http%3A%2F%2Flocalhost%3A3020%2Fmcp" | head -1

# Missing PKCE: redirects back to the *registered* uri with error + state + iss
curl -si "localhost:3000/v1/oauth/authorize?client_id=$CID&redirect_uri=http%3A%2F%2F127.0.0.1%3A41100%2Fcallback&response_type=code&resource=http%3A%2F%2Flocalhost%3A3020%2Fmcp&state=s" | grep -i location

# Registration refuses non-loopback http and consent-skipping grants
curl -s -X POST localhost:3000/v1/oauth/register -H 'Content-Type: application/json' \
-d '{"client_name":"E","redirect_uris":["http://evil.example/cb"]}' | jq
curl -s -X POST localhost:3000/v1/oauth/register -H 'Content-Type: application/json' \
-d '{"client_name":"E","redirect_uris":["https://a.example/cb"],"grant_types":["implicit"]}' | jq

# Consent decision without the anti-CSRF header. Needs a session first — without
# one you get `missing access token`, because the route requires a logged-in user
# before it looks at anything else.
curl -s -c /tmp/ck -X POST localhost:3000/v1/authentication/sign-in \
-H 'Content-Type: application/json' \
-d '{"email":"local-admin@openops.com","password":"12345678"}' -o /dev/null
curl -s -b /tmp/ck -X POST "localhost:3000/v1/oauth/requests/anything/decision" \
-H 'Content-Type: application/json' -d '{"approve":true}' | jq
# -> invalid_request: the x-openops-consent header is required
```

To check that **connections are independent**, run `tools/oauth-flow.sh` twice
without revoking in between, then look at Settings → Connected apps (or
`GET /v1/oauth/grants`): two rows for the same client, each revocable on its own.

## Inspecting state

```bash
docker exec postgres psql -U postgres -d openops -c \
"SELECT id, \"clientId\", \"projectId\", status, \"lastUsedAt\" FROM oauth_grant ORDER BY created DESC;"

docker exec postgres psql -U postgres -d openops -c \
"SELECT \"grantId\", \"familyId\", \"revokedAt\" IS NOT NULL AS revoked FROM oauth_refresh_token ORDER BY created DESC;"
```

The hourly cleanup job is registered at boot. Confirm it is scheduled with:

```bash
docker exec redis redis-cli zrange "bull:system-job-queue:repeat" 0 -1 | grep oauth
```

## Resetting between runs

```bash
docker exec postgres psql -U postgres -d openops -c \
"DROP TABLE IF EXISTS oauth_refresh_token, oauth_authorization_code,
oauth_pending_authorization, oauth_grant, oauth_client, oauth_signing_key CASCADE;
DELETE FROM migrations WHERE name = 'CreateOAuthTables1785312000000';"
```

The migration re-runs on the next boot and a fresh signing key is generated.
Original file line number Diff line number Diff line change
@@ -1,32 +1,13 @@
import { FlagId } from '@openops/shared';
import { t } from 'i18next';
import { Settings, Sparkles, SunMoon } from 'lucide-react';
import { Plug, Settings, Sparkles, SunMoon } from 'lucide-react';
import { useMemo } from 'react';

import SidebarLayout from '@/app/common/components/sidebar-layout';
import { flagsHooks } from '@/app/common/hooks/flags-hooks';

const iconSize = 20;

const baseNavItems = [
{
title: t('General'),
href: '/settings/general',
icon: <Settings size={iconSize} />,
},
];

const appearanceNavItem = {
title: t('Appearance'),
href: '/settings/appearance',
icon: <SunMoon size={iconSize} />,
};

const aiNavItem = {
title: t('OpenOps AI'),
href: '/settings/ai',
icon: <Sparkles size={iconSize} />,
};

interface SettingsLayoutProps {
children: React.ReactNode;
}
Expand All @@ -38,11 +19,53 @@ export default function ProjectSettingsLayout({
FlagId.DARK_THEME_ENABLED,
).data;

const sidebarNavItems = [
...baseNavItems,
...(showAppearanceSettings ? [appearanceNavItem] : []),
aiNavItem,
];
// Hidden unless the instance can actually accept external connections: with OAuth
// off, every route the page depends on is unregistered.
const showConnectedApps = flagsHooks.useFlag<boolean>(
FlagId.CONNECTED_APPS_ENABLED,
).data;

/*
* Titles are resolved here rather than in module-scope constants (OPS-4318).
*
* A production build can place this module in a chunk that evaluates before the entry
* chunk runs `i18n.init()`. `t()` returns undefined until then, and a title captured
* in a top-level constant would freeze that undefined — a nav item with no text, in
* builds only. Inside the component the call happens at render, long after init.
*/
const sidebarNavItems = useMemo(
() => [
{
title: t('General'),
href: '/settings/general',
icon: <Settings size={iconSize} />,
},
...(showAppearanceSettings
? [
{
title: t('Appearance'),
href: '/settings/appearance',
icon: <SunMoon size={iconSize} />,
},
]
: []),
{
title: t('OpenOps AI'),
href: '/settings/ai',
icon: <Sparkles size={iconSize} />,
},
...(showConnectedApps
? [
{
title: t('Connected apps'),
href: '/settings/connected-apps',
icon: <Plug size={iconSize} />,
},
]
: []),
],
[showAppearanceSettings, showConnectedApps],
);

return <SidebarLayout items={sidebarNavItems}>{children}</SidebarLayout>;
}
4 changes: 4 additions & 0 deletions packages/react-ui/src/app/constants/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export const QueryKeys = {
// Cloud
cloudUserInfo: 'cloud-user-info',

// OAuth
oauthConsentRequest: 'oauth-consent-request',
connectedApps: 'connected-apps',

// Connections
appConnections: 'app-connections',
appConnection: 'app-connection',
Expand Down
Loading
Loading