Skip to content

Commit 4eecc41

Browse files
authored
feat: support secure OAuth MCP credential lifecycle (#25)
* refactor: define scoped OAuth refresh and credential inputs * feat: refresh OAuth grants through bounded HTTPS transport * feat: persist and refresh tenant-scoped OAuth credentials * test: release cancelled refresh fixture during cleanup * feat: compose generic OAuth refresh with existing MCP execution * feat: expose pinned OAuth credential API and safe metadata * test: verify OAuth isolation refresh and mutation ordering * docs: define OAuth ownership refresh policy and public contract * test: track explicit expiry in OAuth resource acceptance * docs: reconcile OAuth credential coverage boundaries * fix: place OAuth migration after current main schema * fix: read safe OAuth credentials in Core Web * docs: record real OAuth qualification and limits
1 parent 54b7693 commit 4eecc41

54 files changed

Lines changed: 3181 additions & 234 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CONTRIBUTING.md‎

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,19 +1304,19 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
13041304
attached Vault or anonymous MCP. Already-resolved tokens and running Sessions
13051305
are not revoked. Exact hosted archive, visibility, overlapping-mutation and error
13061306
semantics remain unverified; row removal does not prove physical storage erasure.
1307-
- Static-bearer Credentials are children of tenant-owned Vaults in the execution
1307+
- Static-bearer and OAuth Credentials are children of tenant-owned Vaults in the execution
13081308
database. Creation admits the owner in the same SQL statement as the insert;
1309-
retrieval joins the owning Vault and selects public metadata only. No public
1310-
operation decrypts or returns a token. Encrypt before passing secret values to
1309+
retrieval joins the owning Vault and selects public metadata only. Public reads do not decrypt or return a token. OAuth replacement authenticates
1310+
the stored grant before applying a partial update. Encrypt before passing secret values to
13111311
SQL, using the execution service's separately configured random 32-byte key and
13121312
the standard library's random-nonce AES-GCM. The versioned authenticated binding
13131313
includes tenant, Vault, Credential, authentication purpose and exact destination.
13141314
Never reuse product master-key conventions or daemon transport encryption for
13151315
this storage boundary. Missing key configuration disables credential writes;
13161316
malformed explicit configuration fails startup. See
13171317
[`services/agents-api/credentials.md`](services/agents-api/credentials.md) for
1318-
key persistence and current limits. OAuth and storage-key rotation remain
1319-
separate gaps; resource creation never contacts the destination.
1318+
key persistence and current limits. Storage-key rotation remains
1319+
separate work; resource creation never contacts the destination.
13201320
- `GET /v1/vaults/{vault_id}/credentials` lists safe metadata only, with both
13211321
project and Vault ownership enforced on the parent, cursor and row query. An
13221322
inaccessible parent returns not-found, even when the collection would be empty.
@@ -1327,10 +1327,10 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
13271327
Synthetic archived fixtures prove filtering only. There is no public archive writer,
13281328
timestamp or delete-to-archive inference; existing create/retrieve/token replacement,
13291329
Session bindings and dispatch keep their rules. Migration rollback refuses to lose
1330-
archived classification. Archive/revocation lifecycle, OAuth and hosted
1331-
query/concurrency semantics remain gaps.
1332-
- Credential `POST /v1/vaults/{vault_id}/credentials/{credential_id}` replaces only
1333-
the static-bearer token and update time. Require `auth.type=static_bearer` and a
1330+
archived classification. Archive lifecycle and hosted query/concurrency
1331+
semantics remain gaps.
1332+
- For static auth, Credential `POST /v1/vaults/{vault_id}/credentials/{credential_id}`
1333+
replaces only the token and update time. Require `auth.type=static_bearer` and a
13341334
string `auth.token`, preserving opaque bytes; reject extra mutation fields before
13351335
writing. Reuse safe metadata for the immutable encryption binding, then scope the
13361336
atomic SQL mutation independently by tenant, Vault, Credential, static auth type
@@ -1340,7 +1340,29 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
13401340
and Session snapshots stay unchanged. Subsequent dispatch reads use the committed
13411341
replacement through existing scoped lookup; already-resolved requests may retain
13421342
the old token. This is not storage-key rotation, in-flight revocation or hot reload.
1343-
OAuth and exact hosted concurrent-update/retry/timestamp semantics remain gaps.
1343+
Exact hosted concurrent-update/retry/timestamp semantics remain gaps.
1344+
- OAuth grant ownership stays in Core. The application performs authorization and
1345+
provider revocation; do not add public login/callback/refresh/revoke routes.
1346+
Store access/refresh/client secrets together under existing authenticated tenant,
1347+
Vault, Credential, auth-type and destination encryption. Authenticate refresh
1348+
metadata against its encrypted copy before using an endpoint or grant. Read/list
1349+
queries still select safe metadata only. Shared MCP selection admits both auth
1350+
types and freezes one identity without changing native adapter contracts.
1351+
At dispatch, a known-expired grant is refreshed through the declared endpoint
1352+
auth method, with stored scope/resource, then persisted before returning access.
1353+
Serialize refresh and replacement with the same PostgreSQL Credential row lock;
1354+
deletion and Vault cascade cannot be undone by a stale refresh. Network exchanges
1355+
are bounded and fail closed; never return provider error bodies or claim an
1356+
uncertain grant exchange was committed. No background scheduler, 401 retry,
1357+
hot replacement, output repair or harness-specific OAuth path is introduced.
1358+
Refresh uses verified HTTPS, rejects redirects, and checks resolved addresses
1359+
before dialing them. Private issuer origins need explicit operator configuration
1360+
in `AGENTS_API_OAUTH_TRUSTED_ORIGINS`; tenants cannot relax that boundary and TLS
1361+
verification remains mandatory. Keycloak is acceptance infrastructure only.
1362+
Preserve the pinned update omission/null and immutable-field rules described in
1363+
[OAuth credentials](services/agents-api/oauth-credentials.md); record unspecified
1364+
hosted semantics. Native processes receive only access tokens. Provider revocation,
1365+
withdrawal of already-dispatched tokens and Session cancellation remain distinct.
13441366
- Credential `DELETE /v1/vaults/{vault_id}/credentials/{credential_id}` removes one
13451367
owned row, including ciphertext, with tenant/Vault/ID checked in the same SQL
13461368
mutation. It needs no encryption key, secret read or network call. Local reads,
@@ -1387,7 +1409,7 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
13871409
pinned resource types. Saved-Agent updates never change existing Session
13881410
snapshots; per-Session tools replace the whole field. The initial profile admits
13891411
HTTP(S), boolean `required` (default false), empty/null metadata and empty/null headers.
1390-
Static bearer authentication requires HTTPS and the attached-Vault rules below.
1412+
Static and OAuth bearer authentication require HTTPS and the attached-Vault rules below.
13911413
Inline authorization, URL userinfo/query/fragment,
13921414
other origins and stdio remain explicitly unsupported.
13931415
- Codex required MCP initialization additionally needs `mcp_http_required`, advertised
@@ -1426,19 +1448,19 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
14261448
with TLS verification.
14271449
This execution profile rejects empty values and bytes outside RFC 6750 b64token
14281450
syntax with generic errors; it never trims tokens or narrows opaque Credential
1429-
storage. OAuth and hosted redirect/error equivalence
1430-
remain separate work.
1451+
storage. Core-managed OAuth uses this same access-token path; native OAuth
1452+
login/refresh and hosted redirect/error equivalence remain separate work.
14311453
- Session `vault_ids` omission/null/empty means `[]`; nonempty attachments must all
14321454
belong to the authenticated tenant. Preserve caller order and public MCP
14331455
`credential_id`. Saved Agents may store a nullable/nonempty credential reference
14341456
without authorizing its use. Session admission resolves an explicit credential
14351457
only inside attached Vaults for the exact declared URL, or selects the unique
1436-
matching static credential when the ID is omitted/null. No match remains
1458+
matching static or OAuth credential when the ID is omitted/null. No match remains
14371459
anonymous; ambiguity is a local 400 and unavailable references use the same 404.
14381460
Resolve before any Session, initial input or event write. Freeze safe bindings,
14391461
including anonymous decisions, in private Session configuration; never populate
14401462
the public credential field from implicit resolution. At actual dispatch, recheck
1441-
tenant, attached Vault, selected ID, static auth type and exact URL before scoped
1463+
tenant, attached Vault, selected ID, frozen auth type and exact URL before scoped
14421464
decryption. Metadata queries select no ciphertext; tokens enter only the existing
14431465
transient daemon request. Selected authentication requires `mcp_http_bearer_auth`
14441466
during device selection and the final preclaim check. Missing/wrong keys or
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`;
4+
5+
test("loads mixed OAuth and static credentials without offering OAuth static replacement", async ({ page, request }) => {
6+
expect((await request.post(`${fixtureBaseUrl}/__fixture/reset`)).ok()).toBe(true);
7+
const vaultId = "11111111-1111-4111-8111-111111111111";
8+
const vault = { id: vaultId, object: "vault", created_at: 1, name: "Mixed credentials", metadata: {} };
9+
const oauth = {
10+
id: "22222222-2222-4222-8222-222222222222", vault_id: vaultId,
11+
object: "vault.credential", name: "OAuth MCP", created_at: 2, updated_at: 2,
12+
auth: {
13+
type: "mcp_oauth", mcp_server_url: "https://oauth.example/tools", expires_at: null,
14+
refresh: {
15+
client_id: "public-client-id", token_endpoint: "https://issuer.example/token",
16+
token_endpoint_auth: { type: "client_secret_basic" }, resource: null, scope: null,
17+
},
18+
},
19+
};
20+
const staticCredential = {
21+
id: "33333333-3333-4333-8333-333333333333", vault_id: vaultId,
22+
object: "vault.credential", name: "Static MCP", created_at: 2, updated_at: 2,
23+
auth: { type: "static_bearer", mcp_server_url: "https://static.example/tools" },
24+
};
25+
await page.route("**/v1/vaults?*", (route) => route.fulfill({ json: {
26+
object: "list", data: [vault], has_more: false, first_id: vaultId, last_id: vaultId,
27+
} }));
28+
await page.route(`**/v1/vaults/${vaultId}/credentials?*`, (route) => route.fulfill({ json: {
29+
object: "list", data: [oauth, staticCredential], has_more: false, first_id: oauth.id, last_id: staticCredential.id,
30+
} }));
31+
await page.goto("/");
32+
await page.getByRole("button", { name: "Vaults", exact: true }).click();
33+
await expect(page.getByRole("heading", { name: "Mixed credentials" })).toBeVisible();
34+
await expect(page.getByText("OAuth MCP", { exact: true })).toBeVisible();
35+
await expect(page.getByText("OAuth · Manage authorization and token replacement in your application.")).toBeVisible();
36+
await expect(page.getByRole("button", { name: "Replace token for OAuth MCP" })).toHaveCount(0);
37+
await expect(page.getByRole("button", { name: "Delete OAuth MCP" })).toBeVisible();
38+
await page.getByRole("button", { name: "Replace token for Static MCP" }).click();
39+
await expect(page.getByRole("dialog", { name: "Replace token · Static MCP" })).toBeVisible();
40+
});

‎apps/web/src/features/vaults/VaultsView.test.tsx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,30 @@ describe("Vaults view", () => {
4141
expect(html).not.toContain("archived");
4242
});
4343

44+
it("shows OAuth metadata with application guidance and only offers static token replacement", () => {
45+
const vaultId = "11111111-1111-4111-8111-111111111111";
46+
const html = renderToStaticMarkup(<VaultsView
47+
busy={false} coreError={null} coreState="ready" operations={operations}
48+
catalog={{
49+
vaults: [{ id: vaultId, object: "vault", created_at: 1, name: "Runtime", metadata: {} }],
50+
credentials: [{
51+
id: "22222222-2222-4222-8222-222222222222", vault_id: vaultId,
52+
object: "vault.credential", name: "OAuth MCP", created_at: 2, updated_at: 2,
53+
auth: { type: "mcp_oauth", mcp_server_url: "https://oauth.example/tools", expires_at: null, refresh: null },
54+
}, {
55+
id: "33333333-3333-4333-8333-333333333333", vault_id: vaultId,
56+
object: "vault.credential", name: "Static MCP", created_at: 2, updated_at: 2,
57+
auth: { type: "static_bearer", mcp_server_url: "https://static.example/tools" },
58+
}],
59+
}}
60+
/>);
61+
expect(html).toContain("OAuth MCP");
62+
expect(html).toContain("Manage authorization and token replacement in your application.");
63+
expect(html).not.toContain('aria-label="Replace token for OAuth MCP"');
64+
expect(html).toContain('aria-label="Replace token for Static MCP"');
65+
expect(html).toContain('aria-label="Delete OAuth MCP"');
66+
});
67+
4468
it("keeps an incomplete catalog in a durable failed state", () => {
4569
const html = renderToStaticMarkup(<VaultsView busy={false} catalog={null} coreError="safe failure" coreState="failed" operations={operations} />);
4670
expect(html).toContain("Couldn’t load Vaults");

‎apps/web/src/features/vaults/VaultsView.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,9 @@ export function VaultsView({ busy, catalog, coreError, coreState, operations }:
149149
{credentials.length ? credentials.map((credential) => (
150150
<article className="credential-row" key={credential.id}>
151151
<KeyRound size={15} strokeWidth={1.5} aria-hidden="true" />
152-
<div><strong>{credential.name}</strong><code>{credential.auth.mcp_server_url}</code><small>Updated {formatTimestamp(credential.updated_at)} · token hidden</small></div>
152+
<div><strong>{credential.name}</strong><code>{credential.auth.mcp_server_url}</code><small>Updated {formatTimestamp(credential.updated_at)} · token hidden</small>{credential.auth.type === "mcp_oauth" ? <small>OAuth · Manage authorization and token replacement in your application.</small> : null}</div>
153153
<div className="credential-actions">
154-
<button className="icon-button outline" type="button" aria-label={`Replace token for ${credential.name}`} title="Replace token" onClick={() => { setActionError(null); setCredentialDialog({ mode: "replace", vault, credential }); }} disabled={busy}><RotateCcw size={13} /></button>
154+
{credential.auth.type === "static_bearer" ? <button className="icon-button outline" type="button" aria-label={`Replace token for ${credential.name}`} title="Replace token" onClick={() => { setActionError(null); setCredentialDialog({ mode: "replace", vault, credential }); }} disabled={busy}><RotateCcw size={13} /></button> : null}
155155
<button className="icon-button danger" type="button" aria-label={`Delete ${credential.name}`} onClick={() => { setActionError(null); setDeleteTarget({ kind: "credential", vault, credential }); }} disabled={busy}><Trash2 size={13} /></button>
156156
</div>
157157
</article>

0 commit comments

Comments
 (0)