Skip to content

feat(egress): client-side Dynamic Client Registration for Atlassian Rovo authv2 - #1519

Open
go-faustino wants to merge 2 commits into
agentic-community:mainfrom
go-faustino:feat/egress-dcr-atlassian-authv2
Open

feat(egress): client-side Dynamic Client Registration for Atlassian Rovo authv2#1519
go-faustino wants to merge 2 commits into
agentic-community:mainfrom
go-faustino:feat/egress-dcr-atlassian-authv2

Conversation

@go-faustino

@go-faustino go-faustino commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

What this does

Adds RFC 7591 client-side Dynamic Client Registration to the egress OAuth engine, and wires the atlassian provider recipe to Atlassian's Rovo MCP authv2 Authorization Server.

From 2026-05-27 Atlassian Identity only issues MCP-audience tokens to DCR-registered clients. A static classic-3LO app still authenticates, but its token carries the wrong audience and is rejected at the Rovo MCP tool layer. So the gateway has to register its own OAuth client rather than use operator-supplied credentials.

Verified end to end against live Atlassian

DCR-registered public client → authorize → consent → code → token → MCP session → real data-tool calls:

  • token exchange succeeds as a public client (PKCE only, no client_secret), HTTP 200, refresh token present
  • aud binds to the DCR client_id plus the user's site/workspace ARIs — not to api.atlassian.com, and no resource/audience parameter participates
  • MCP initialize200, tools/list200
  • getVisibleJiraProjects, getJiraIssue, atlassianUserInfo, getAccessibleAtlassianResources all 200 / isError: false, with the site reporting granular read:jira-work/write:jira-work scopes

The blocker, for the record

This PR sat conflicting for six weeks because the authorize request kept failing with invalid_request / "Incorrect request parameters". The cause turned out to be a single missing scope: read:account.

Rovo authv2 rejects any authorize request that omits it — and rejects it after the user submits consent, naming no scope. Nothing advertises the requirement: RFC 9728 has no required-scopes field, so read:account looks exactly as optional as the other 21 entries in scopes_supported. Isolated by a controlled sweep, same client and redirect, one variable at a time:

Requested scopes Consent screen Result
minimal Jira set, no read:account left as-is fail
minimal Jira set, no read:account Search group unticked fail
minimal Jira set + read:account Search group unticked success
minimal Jira set + read:account left as-is success
full 13-scope set (includes read:account) left as-is success

read:account fully determines the outcome. The consent screen's Read/Write/Search permission groups have no effect in either direction.

Ruled out with evidence, not inference: DCR client propagation/churn, redirect_uris not being persisted (the DCR 201 does echo them), prompt=consent, RFC 8707 resource, classic audience, state length, org entitlement, callback-domain allowlisting, and site/ARI resolution.

Scope of the change

Rebased onto current main. That rebase showed most of the original PR had become redundant: upstream now has TokenEndpointAuthStyle.NONE, whose docstring already anticipates "a client minted by an MCP resource server's Dynamic Client Registration endpoint", and service._client_secret already returns None for that style while fail-closing for confidential ones.

So the original public-client apparatus — the public_client field, the _build_token_request change, and _token_leg_secret — is deleted and rebuilt on upstream's NONE style. This PR no longer touches _build_token_request, and the fail-closed guard plus its two tests are byte-identical to main.

What remains is only what upstream lacks:

  • requires_dcr, registration_url, protected_resource_metadata_url, dcr_client_name, default_scopes, required_scopes on OAuthProviderConfig
  • register_dcr_client() with registration-endpoint discovery walking RFC 9728 → RFC 8414 (the Rovo AS is path-scoped and its registration_endpoint is tenant-scoped, so pinning is not viable)
  • discovery and registration through the same CREDENTIALED_OAUTH_PROFILE guarded client as the token endpoint
  • one-time DCR at config time, persisting and reusing the client_id so a re-save does not churn a registration; 502 on failure
  • a clear 400 instead of a KeyError/500 at both consent entry points when a requires_dcr provider has no client_id yet
  • required_scopes, unioned into every request regardless of operator config — deliberately not just a default, since a default only applies when the operator supplies nothing and an explicit list omitting read:account would fail identically
  • config-time scope validation against the resource's scopes_supported, reusing the PRM the discovery walk already fetches (no extra round-trip) and running before registration so a bad config cannot orphan a DCR client at the AS
  • the atlassian recipe: requires_dcr + NONE style + PRM discovery, no audience, no resource

A NONE-style client does not persist the client_secret Atlassian returns anyway (it arrives with client_secret_expires_at: 0 even when token_endpoint_auth_method is echoed as "none"): the token leg never reads it, so storing it is needless exposure.

Notes

  • read:jira-user and the classic read:confluence-content.all / write:confluence-content are absent from the authv2 resource's scopes_supported.
  • Confluence scopes are not defaulted. The 5-scope Jira default covers 16 of the 31 tools and narrows aud to Jira ARIs; operators wanting Confluence opt in per server.
  • The Rovo AS also advertises pushed_authorization_request_endpoint and client_id_metadata_document_supported: true, so PAR and CIMD are available for later — relevant to feat: Brokered MCP Server with CIMD Authentication #1687.

Tests

334 passed in tests/unit/egress_auth/. ruff check and ruff format --check clean.

@go-faustino
go-faustino force-pushed the feat/egress-dcr-atlassian-authv2 branch from 5b1391d to c0f2078 Compare July 20, 2026 17:09
@codecov-commenter

codecov-commenter commented Jul 20, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@go-faustino
go-faustino force-pushed the feat/egress-dcr-atlassian-authv2 branch 4 times, most recently from 173f181 to cc71053 Compare July 23, 2026 16:14
@aarora79

Copy link
Copy Markdown
Contributor

Hi @go-faustino — we pulled this PR and tested the full DCR + public-PKCE flow end-to-end against Atlassian's live Rovo authv2 AS. It mostly works; we made one fix on top and hit a wall on the Atlassian side we couldn't get past. Sharing everything so you can take the last mile (you presumably have a working authv2 org).

What works (verified live):

  1. RFC 9728 → 8414 discovery of the registration endpoint.
  2. RFC 7591 DCR registration (201 Created, fresh client per config-save).
  3. The authorize URL the code builds is RFC-complete and matches Atlassian's own AS metadata (response_type=code, client_id, redirect_uri, state, space-joined scope, code_challenge+S256, prompt=consent, endpoint https://auth.atlassian.com/authorize).
  4. Reached the real consent screen + site picker with a valid client and valid scopes.

One fix we'd suggest — Atlassian authv2 DCR returns a confidential client.
We observed that the DCR endpoint hands back a client_secret even when we register with token_endpoint_auth_method=none. The current code forces client_secret="" whenever cfg.public_client on the token/refresh legs, which drops the secret Atlassian issued and expects → the token exchange would fail. Our change decides the token-leg auth by whether a secret is actually held, not by the declared public_client intent (262 egress_auth tests pass). Open question: is the intended model (a) register none but send the returned secret if Atlassian gives one — what we did — or (b) register a confidential client for Atlassian (client_secret_post) and drop public_client=True?

Config gotchas we hit (not code):

  • read:jira-user is not in the Rovo authv2 resource's scopes_supported; Atlassian rejected consent ("requested scopes that have not been added to the app: read:jira-user") until we removed it. Classic 3LO had it; authv2 doesn't. Might be worth validating requested scopes against the resource metadata at config time.
  • Our org blocked the callback domain until an admin allowlisted it.

The wall — need your input. After the domain was allowlisted and scopes were valid, the authorize leg still redirects to:

https://id.atlassian.com/error?error=invalid_request&error_description=Incorrect%20request%20parameters

…for a freshly-minted DCR client — even though an earlier, identical-shape request (different client) had reached the consent screen minutes before. No callback ever reached the gateway; the failure is entirely Atlassian-side of the authorize step. We suspect DCR-client churn/propagation (we created ~6 clients while debugging), an org entitlement nuance, or an authorize detail authv2 needs beyond the standard set.

Questions:

  1. Did you get an end-to-end token mint working on your org? What did the final working authorize request look like (anything beyond the params above)?
  2. Does authv2 need anything special after DCR — propagation delay, a specific prompt, audience/resource, a particular token_endpoint_auth_method, or org-side app approval?

Minor robustness bug (unfixed, your call): GET /oauth2/egress/connectbuild_consent_url does egress_oauth["client_id"] unguarded (egress_oauth_facade_routes.py:178service.py:287); a requires_dcr provider with no client_id yet raises KeyError → HTTP 500. A clean 4xx ("re-save egress config to register") would be friendlier.

Full patch (our commit on top of this PR head — git am-able)
diff --git a/registry/egress_auth/oauth_engine.py b/registry/egress_auth/oauth_engine.py
index ef413054..94ace6ae 100644
--- a/registry/egress_auth/oauth_engine.py
+++ b/registry/egress_auth/oauth_engine.py
@@ -166,11 +166,14 @@ def _build_token_request(
     """Return (form_data, headers), placing the client secret per the provider's style."""
     headers = {"Accept": "application/json"}
     data = dict(form)
-    if cfg.public_client:
-        # RFC 6749 public client (token_endpoint_auth_method=none): no secret is
-        # sent -- PKCE is the proof-of-possession. DCR providers like Atlassian
-        # authv2 hand back a secret-less client, and their token endpoint rejects
-        # a client_secret. Only the client_id identifies the client.
+    if not client_secret:
+        # No secret to present, so authenticate as an RFC 6749 public client
+        # (token_endpoint_auth_method=none) proven by PKCE; only the client_id
+        # identifies the client. We key off the ABSENCE of a secret rather than
+        # cfg.public_client because some DCR providers (Atlassian authv2) return a
+        # confidential client -- with a client_secret -- even when we register with
+        # token_endpoint_auth_method=none. In that case a secret IS stored and the
+        # branches below send it, matching what the provider actually expects.
         data["client_id"] = client_id
         return data, headers
     if cfg.token_endpoint_auth_style == TokenEndpointAuthStyle.BASIC_HEADER:
diff --git a/registry/egress_auth/service.py b/registry/egress_auth/service.py
index b21b7a65..2c7438ed 100644
--- a/registry/egress_auth/service.py
+++ b/registry/egress_auth/service.py
@@ -24,6 +24,7 @@ from registry.egress_auth import oauth_engine
 from registry.egress_auth.providers import resolve_provider
 from registry.egress_auth.schemas import (
     EgressConnection,
+    OAuthProviderConfig,
     OAuthState,
     StoredToken,
 )
@@ -214,6 +215,24 @@ class EgressAuthService:
             raise EgressAuthError("could not decrypt egress client_secret (SECRET_KEY changed?)")
         return secret
 
+    def _token_leg_secret(
+        self,
+        cfg: OAuthProviderConfig,
+        egress_oauth: dict,
+    ) -> str:
+        """Return the client_secret to present on the token/refresh legs.
+
+        A public client sends no secret (PKCE is the proof-of-possession), so we
+        return "" only when none is stored. We key off the STORED secret rather
+        than cfg.public_client alone because some DCR providers (Atlassian authv2)
+        return a confidential client -- with a secret -- even when we register with
+        token_endpoint_auth_method=none. If a secret was persisted we must send it,
+        or the provider rejects the exchange; if none was stored we stay public.
+        """
+        if not egress_oauth.get("client_secret_encrypted"):
+            return ""
+        return self._client_secret(egress_oauth)
+
     def _is_near_expiry(self, token: StoredToken) -> bool:
         if not token.expires_at:
             return False  # no expiry info -> treat as long-lived; refresh on 401 elsewhere
@@ -316,8 +335,9 @@ class EgressAuthService:
             raise EgressAuthError("state auth_method mismatch")
 
         cfg = resolve_provider(egress_oauth)
-        # Public clients (DCR authv2) have no secret; PKCE is the proof-of-possession.
-        client_secret = "" if cfg.public_client else self._client_secret(egress_oauth)
+        # Send the stored secret if we have one; a genuine public client (no secret
+        # persisted) sends none and relies on PKCE. See _token_leg_secret.
+        client_secret = self._token_leg_secret(cfg, egress_oauth)
         token = await oauth_engine.exchange_code(
             cfg=cfg,
             client_id=egress_oauth["client_id"],
@@ -410,7 +430,7 @@ class EgressAuthService:
             if current is None or not current.refresh_token:
                 return None
             cfg = resolve_provider(egress_oauth)
-            client_secret = "" if cfg.public_client else self._client_secret(egress_oauth)
+            client_secret = self._token_leg_secret(cfg, egress_oauth)
             try:
                 new = await oauth_engine.refresh_token(
                     cfg=cfg,
diff --git a/tests/unit/egress_auth/test_oauth_engine.py b/tests/unit/egress_auth/test_oauth_engine.py
index 6c204d9f..23212222 100644
--- a/tests/unit/egress_auth/test_oauth_engine.py
+++ b/tests/unit/egress_auth/test_oauth_engine.py
@@ -259,10 +259,12 @@ class TestQuirkParsers:
         assert "Authorization" not in headers
         assert headers["Accept"] == "application/json"
 
-    def test_public_client_omits_secret(self):
-        # RFC 6749 public client (token_endpoint_auth_method=none): only client_id
-        # is sent -- no client_secret in the body and no Basic header. Atlassian
-        # authv2's token endpoint rejects a secret on a public DCR client.
+    def test_no_secret_authenticates_as_public_client(self):
+        # With no secret to present, authenticate as an RFC 6749 public client
+        # (token_endpoint_auth_method=none): only client_id is sent -- no
+        # client_secret in the body and no Basic header. This is the genuine
+        # public-client case (a secret-less DCR client), keyed off the ABSENCE of
+        # a secret rather than cfg.public_client.
         cfg = OAuthProviderConfig(
             name="atlassian",
             display_name="Atlassian",
@@ -275,17 +277,21 @@ class TestQuirkParsers:
         assert "client_secret" not in data
         assert "Authorization" not in headers
 
-    def test_public_client_ignores_supplied_secret(self):
-        # Even if a secret is somehow present, a public client never sends it.
+    def test_public_client_with_secret_still_sends_it(self):
+        # Some DCR providers (Atlassian authv2) return a CONFIDENTIAL client -- with
+        # a client_secret -- even when we register token_endpoint_auth_method=none.
+        # When a secret is actually held it MUST be sent, or the token exchange is
+        # rejected. The secret's presence, not cfg.public_client, drives the choice.
         cfg = OAuthProviderConfig(
-            name="p",
-            display_name="P",
-            authorize_url="https://i/a",
-            token_url="https://i/t",
+            name="atlassian",
+            display_name="Atlassian",
+            authorize_url="https://auth.atlassian.com/authorize",
+            token_url="https://auth.atlassian.com/oauth/token",
             public_client=True,
         )
-        data, _ = oauth_engine._build_token_request(cfg, "cid", "leaked", {"grant_type": "x"})
-        assert "client_secret" not in data
+        data, _ = oauth_engine._build_token_request(cfg, "cid", "real-secret", {"grant_type": "x"})
+        assert data["client_id"] == "cid"
+        assert data["client_secret"] == "real-secret"
 
 
 @pytest.mark.unit

@go-faustino
go-faustino force-pushed the feat/egress-dcr-atlassian-authv2 branch from cc71053 to 68a40e0 Compare July 27, 2026 07:18
go-faustino added a commit to go-faustino/mcp-gateway-registry that referenced this pull request Jul 28, 2026
…d DCR consent

Incorporates review feedback from @aarora79 (agentic-community#1519), who tested the flow
end-to-end against Atlassian's live authv2 AS.

Atlassian's authv2 DCR endpoint returns a CONFIDENTIAL client (a client_secret)
even when we register token_endpoint_auth_method=none. The previous code forced
client_secret="" whenever cfg.public_client on the token/refresh legs, dropping
the secret Atlassian issued and expects. Decide the token-leg auth by whether a
secret is actually held, not by the declared public_client intent:

- oauth_engine._build_token_request: key the public-client branch off the ABSENCE
  of a secret (`if not client_secret`) rather than cfg.public_client. PKCE is
  always sent, so this works whether the DCR client ends up public or confidential.
- service._token_leg_secret: send the stored secret if one was persisted, else
  stay public. Used by both exchange and refresh.
- service.build_consent_url: a requires_dcr provider whose client_id has not been
  registered yet now raises a clear EgressAuthError instead of a KeyError; the
  initiate + connect routes translate it to a clean 400 ("re-save egress config
  to register") instead of a 500.

Tests updated/added for secret-presence token requests, _token_leg_secret, and
the unregistered-DCR 400 on both consent routes.
@go-faustino

Copy link
Copy Markdown
Contributor Author

Thanks @aarora79 — this is an incredibly useful review, especially validating the discovery/DCR/authorize legs against the live AS. I've pushed af8375df adopting your fixes and answering your questions below.

Adopted from your patch

Token-leg auth keyed off secret presence (your option (a)). You're right — Atlassian's authv2 DCR hands back a client_secret even when we register token_endpoint_auth_method=none, and my if cfg.public_client: short-circuit dropped it. Pushed your exact approach:

  • oauth_engine._build_token_request now branches on if not client_secret (PKCE is always sent, so it's correct whether the DCR client comes back public or confidential).
  • Added service._token_leg_secret(cfg, egress_oauth) (send the stored secret if one was persisted, else stay public) and wired it into both exchange_code and refresh_token.
  • Tests updated (test_no_secret_authenticates_as_public_client, test_public_client_with_secret_still_sends_it, plus _token_leg_secret unit tests).

I kept public_client=True on the atlassian recipe because it still drives the two things that are intent (not observation): the DCR registration request (token_endpoint_auth_method=none) and relaxing the operator-client_secret requirement at config time. The token-leg behaviour is now purely secret-presence-driven, so option (b) isn't needed.

Robustness bug (the unguarded client_id → KeyError/500). Fixed. build_consent_url now raises a clear EgressAuthError ("re-save the server's egress config to trigger dynamic client registration") when a requires_dcr provider has no client_id yet, and both consent entry points (/api/egress-auth/initiate and /oauth2/egress/connect) translate it to a clean 400 instead of a 500. Tests added on both routes.

Scope gotcha — confirmed, independently

Same finding on our side: read:jira-user (and the classic read:confluence-content.all / write:confluence-content) are not in the authv2 resource's scopes_supported; the AS rejects consent until they're removed. We run the minimal Jira set: read:me, offline_access, read:jira-work, write:jira-work. Agree that validating requested scopes against the PRM scopes_supported at config time is worth doing — I'd rather land it as a focused follow-up than add another config-time network call to this PR, but happy to include it here if you'd prefer.

Your questions

1. Did I get an end-to-end token mint on my org, and what did the final working authorize look like?

Straight answer: I have not reproduced a clean interactive mint through the gateway's own callback either — I hit the identical error=invalid_request&error_description=Incorrect%20request%20parameters at /authorize, and in some attempts a Redirect URL is missing variant, for freshly-minted DCR clients. My conclusions about the token shape (aud = [DCR client_id, <site ARIs>], no resource/audience binding) came from decoding vended tokens + the resource/AS metadata, and from a standards-compliant reference client — not from a clean gateway-callback mint. So I can't hand you a "magic" authorize request beyond the RFC-complete set the code builds (which you already confirmed matches the AS metadata). We're standing at the same wall.

2. What does authv2 need after DCR? Ranked by how well it fits our shared symptom (identical-shape request reaches consent for one client, fails for the next):

  • DCR client propagation / churn (my top suspect). "Worked minutes earlier, a fresh client fails" is the classic signature of a just-registered client not yet usable at /authorize. We both created several clients while debugging. Suggestion to isolate: register once, stop churning, wait ~30–60s, then authorize; and don't re-register on every config save (this PR already reuses a persisted/operator client_id for exactly this reason).
  • redirect_uri exact-match. register_dcr_client sends redirect_uris: [<gateway callback>] and the authorize leg uses the same value, so the code is consistent. But Redirect URL is missing specifically means the AS doesn't recognise the redirect for that client — which is either propagation or a client that didn't actually persist redirect_uris. Does the DCR 201 response you saw echo back redirect_uris? If it doesn't, that's the smoking gun. (Also worth isolating: mcp-remote uses a loopback 127.0.0.1 redirect; the gateway uses an https callback — if a loopback client authorizes but an identical https one doesn't, that narrows it to redirect handling rather than propagation.)
  • Org-side app authorization / allowlist. Your callback-domain allowlist matches what we saw; some orgs additionally gate the app itself. For a per-config DCR client this may need re-approval each time a new client appears — another reason to register once and reuse.
  • No resource / audience. Confirmed rejected/ignored on both legs; the recipe keeps them off. Not the cause of the authorize error.

Net

With your token-leg fix + valid scopes + the domain allowlist, the only thing between this and a working vend is that intermittent authorize-side invalid_request — and your own data (one client reached consent) says that's an Atlassian propagation/churn/entitlement issue, not a code gap. If you can confirm whether your DCR 201 echoed redirect_uris, and whether a single non-churned client succeeds after a short delay, I think we can close it out. Thanks again for taking it this far.

go-faustino added a commit to go-faustino/mcp-gateway-registry that referenced this pull request Jul 28, 2026
…d DCR consent

Incorporates review feedback from @aarora79 (agentic-community#1519), who tested the flow
end-to-end against Atlassian's live authv2 AS.

Atlassian's authv2 DCR endpoint returns a CONFIDENTIAL client (a client_secret)
even when we register token_endpoint_auth_method=none. The previous code forced
client_secret="" whenever cfg.public_client on the token/refresh legs, dropping
the secret Atlassian issued and expects. Decide the token-leg auth by whether a
secret is actually held, not by the declared public_client intent:

- oauth_engine._build_token_request: key the public-client branch off the ABSENCE
  of a secret (`if not client_secret`) rather than cfg.public_client. PKCE is
  always sent, so this works whether the DCR client ends up public or confidential.
- service._token_leg_secret: send the stored secret if one was persisted, else
  stay public. Used by both exchange and refresh.
- service.build_consent_url: a requires_dcr provider whose client_id has not been
  registered yet now raises a clear EgressAuthError instead of a KeyError; the
  initiate + connect routes translate it to a clean 400 ("re-save egress config
  to register") instead of a 500.

Tests updated/added for secret-presence token requests, _token_leg_secret, and
the unregistered-DCR 400 on both consent routes.
@go-faustino
go-faustino force-pushed the feat/egress-dcr-atlassian-authv2 branch 2 times, most recently from 85b5c8e to 5c97e5c Compare July 29, 2026 15:35
go-faustino added a commit to go-faustino/mcp-gateway-registry that referenced this pull request Jul 29, 2026
…d DCR consent

Incorporates review feedback from @aarora79 (agentic-community#1519), who tested the flow
end-to-end against Atlassian's live authv2 AS.

Atlassian's authv2 DCR endpoint returns a CONFIDENTIAL client (a client_secret)
even when we register token_endpoint_auth_method=none. The previous code forced
client_secret="" whenever cfg.public_client on the token/refresh legs, dropping
the secret Atlassian issued and expects. Decide the token-leg auth by whether a
secret is actually held, not by the declared public_client intent:

- oauth_engine._build_token_request: key the public-client branch off the ABSENCE
  of a secret (`if not client_secret`) rather than cfg.public_client. PKCE is
  always sent, so this works whether the DCR client ends up public or confidential.
- service._token_leg_secret: send the stored secret if one was persisted, else
  stay public. Used by both exchange and refresh.
- service.build_consent_url: a requires_dcr provider whose client_id has not been
  registered yet now raises a clear EgressAuthError instead of a KeyError; the
  initiate + connect routes translate it to a clean 400 ("re-save egress config
  to register") instead of a 500.

Tests updated/added for secret-presence token requests, _token_leg_secret, and
the unregistered-DCR 400 on both consent routes.
@aarora79

aarora79 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Hi @go-faustino, will test this next week, so with this do you have Atlassian-Rovo server working?

@go-faustino

go-faustino commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Hi @aarora79 — thanks, appreciate you taking another pass. I'll be away until September so won't be able to respond to findings in real time, but to set expectations honestly: as of my last comment, no, I haven't gotten a clean end-to-end token mint through the gateway's own callback. The DCR/PKCE/discovery/authorize-URL shape is validated correct (yours and mine both reach consent), but we're both hitting the same invalid_request wall at /authorize for freshly-minted DCR clients — I think it's Atlassian-side (propagation/churn or entitlement), not something in this code, but I couldn't confirm.

If you get a chance to test: the two things that'd actually move this forward are (1) whether your DCR 201 echoes back redirect_uris, and (2) whether a single non-churned client succeeds after a short delay (~30-60s) rather than registering repeatedly while debugging. If you get a clean mint, that'd basically close this out.

I'll pick this back up in September — feel free to push commits or just leave findings on the thread in the meantime.

@go-faustino
go-faustino force-pushed the feat/egress-dcr-atlassian-authv2 branch from 5c97e5c to 9a0d3b0 Compare August 4, 2026 21:52
go-faustino added a commit to go-faustino/mcp-gateway-registry that referenced this pull request Aug 4, 2026
…d DCR consent

Incorporates review feedback from @aarora79 (agentic-community#1519), who tested the flow
end-to-end against Atlassian's live authv2 AS.

Atlassian's authv2 DCR endpoint returns a CONFIDENTIAL client (a client_secret)
even when we register token_endpoint_auth_method=none. The previous code forced
client_secret="" whenever cfg.public_client on the token/refresh legs, dropping
the secret Atlassian issued and expects. Decide the token-leg auth by whether a
secret is actually held, not by the declared public_client intent:

- oauth_engine._build_token_request: key the public-client branch off the ABSENCE
  of a secret (`if not client_secret`) rather than cfg.public_client. PKCE is
  always sent, so this works whether the DCR client ends up public or confidential.
- service._token_leg_secret: send the stored secret if one was persisted, else
  stay public. Used by both exchange and refresh.
- service.build_consent_url: a requires_dcr provider whose client_id has not been
  registered yet now raises a clear EgressAuthError instead of a KeyError; the
  initiate + connect routes translate it to a clean 400 ("re-save egress config
  to register") instead of a 500.

Tests updated/added for secret-presence token requests, _token_leg_secret, and
the unregistered-DCR 400 on both consent routes.
go-faustino added a commit to go-faustino/mcp-gateway-registry that referenced this pull request Aug 5, 2026
…d DCR consent

Incorporates review feedback from @aarora79 (agentic-community#1519), who tested the flow
end-to-end against Atlassian's live authv2 AS.

Atlassian's authv2 DCR endpoint returns a CONFIDENTIAL client (a client_secret)
even when we register token_endpoint_auth_method=none. The previous code forced
client_secret="" whenever cfg.public_client on the token/refresh legs, dropping
the secret Atlassian issued and expects. Decide the token-leg auth by whether a
secret is actually held, not by the declared public_client intent:

- oauth_engine._build_token_request: key the public-client branch off the ABSENCE
  of a secret (`if not client_secret`) rather than cfg.public_client. PKCE is
  always sent, so this works whether the DCR client ends up public or confidential.
- service._token_leg_secret: send the stored secret if one was persisted, else
  stay public. Used by both exchange and refresh.
- service.build_consent_url: a requires_dcr provider whose client_id has not been
  registered yet now raises a clear EgressAuthError instead of a KeyError; the
  initiate + connect routes translate it to a clean 400 ("re-save egress config
  to register") instead of a 500.

Tests updated/added for secret-presence token requests, _token_leg_secret, and
the unregistered-DCR 400 on both consent routes.
@go-faustino
go-faustino force-pushed the feat/egress-dcr-atlassian-authv2 branch from 9a0d3b0 to d0adb61 Compare August 5, 2026 20:31
go-faustino added a commit to go-faustino/mcp-gateway-registry that referenced this pull request Aug 6, 2026
…d DCR consent

Incorporates review feedback from @aarora79 (agentic-community#1519), who tested the flow
end-to-end against Atlassian's live authv2 AS.

Atlassian's authv2 DCR endpoint returns a CONFIDENTIAL client (a client_secret)
even when we register token_endpoint_auth_method=none. The previous code forced
client_secret="" whenever cfg.public_client on the token/refresh legs, dropping
the secret Atlassian issued and expects. Decide the token-leg auth by whether a
secret is actually held, not by the declared public_client intent:

- oauth_engine._build_token_request: key the public-client branch off the ABSENCE
  of a secret (`if not client_secret`) rather than cfg.public_client. PKCE is
  always sent, so this works whether the DCR client ends up public or confidential.
- service._token_leg_secret: send the stored secret if one was persisted, else
  stay public. Used by both exchange and refresh.
- service.build_consent_url: a requires_dcr provider whose client_id has not been
  registered yet now raises a clear EgressAuthError instead of a KeyError; the
  initiate + connect routes translate it to a clean 400 ("re-save egress config
  to register") instead of a 500.

Tests updated/added for secret-presence token requests, _token_leg_secret, and
the unregistered-DCR 400 on both consent routes.
@go-faustino
go-faustino force-pushed the feat/egress-dcr-atlassian-authv2 branch 2 times, most recently from 2845106 to 291b4dc Compare August 12, 2026 11:49
go-faustino added a commit to go-faustino/mcp-gateway-registry that referenced this pull request Aug 12, 2026
…d DCR consent

Incorporates review feedback from @aarora79 (agentic-community#1519), who tested the flow
end-to-end against Atlassian's live authv2 AS.

Atlassian's authv2 DCR endpoint returns a CONFIDENTIAL client (a client_secret)
even when we register token_endpoint_auth_method=none. The previous code forced
client_secret="" whenever cfg.public_client on the token/refresh legs, dropping
the secret Atlassian issued and expects. Decide the token-leg auth by whether a
secret is actually held, not by the declared public_client intent:

- oauth_engine._build_token_request: key the public-client branch off the ABSENCE
  of a secret (`if not client_secret`) rather than cfg.public_client. PKCE is
  always sent, so this works whether the DCR client ends up public or confidential.
- service._token_leg_secret: send the stored secret if one was persisted, else
  stay public. Used by both exchange and refresh.
- service.build_consent_url: a requires_dcr provider whose client_id has not been
  registered yet now raises a clear EgressAuthError instead of a KeyError; the
  initiate + connect routes translate it to a clean 400 ("re-save egress config
  to register") instead of a 500.

Tests updated/added for secret-presence token requests, _token_leg_secret, and
the unregistered-DCR 400 on both consent routes.
@zoltan-fedor

Copy link
Copy Markdown

+1

1 similar comment
@adrianB1996

Copy link
Copy Markdown

+1

@go-faustino
go-faustino force-pushed the feat/egress-dcr-atlassian-authv2 branch from 291b4dc to e4c4321 Compare September 2, 2026 08:38
@go-faustino go-faustino changed the title feat(egress): client-side DCR + public PKCE clients for Atlassian Rovo authv2 feat(egress): client-side Dynamic Client Registration for Atlassian Rovo authv2 Sep 2, 2026
@go-faustino

go-faustino commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Hi @aarora79 — back as promised. End-to-end mint works. Root cause is one missing scope: read:account.

Rovo authv2 rejects any authorize request that omits it, after the user submits consent, with invalid_request / "Incorrect request parameters" and no indication of which scope is at fault. RFC 9728 has no required-scopes field, so read:account looks as optional as the other 21 entries in scopes_supported. That is why neither of us caught it: every requested scope was individually valid.

Same DCR client, same redirect, one variable: without read:account the request fails (consent groups ticked or not); with it, it succeeds (consent groups ticked or not). mcp-remote always worked because it requests the full set, which includes read:account.

Your question 1: the DCR 201 does echo redirect_uris, and the consent screen shows the registered callback under "Domains". Also ruled out: propagation/churn, prompt=consent, resource/audience, state length, org entitlement.

Token exchange succeeds as a public client — PKCE only, no client_secret, HTTP 200, refresh token present. aud binds to the DCR client_id plus the user's site/workspace ARIs. Real data tools (getVisibleJiraProjects, getJiraIssue) return 200 with granular read:jira-work/write:jira-work, not :agent-interface.

That reverses the token-leg fix you contributed. Atlassian's DCR issues a secret even when token_endpoint_auth_method is "none", but the token endpoint does not require it. Your patch keyed auth off "is a secret held" — the right call on the evidence then, untestable because neither of us had a code. I have dropped it.

Rebased onto current main. TokenEndpointAuthStyle.NONE now exists upstream and already covers this case, so the original public_client / _build_token_request / _token_leg_secret apparatus is gone. This PR no longer touches _build_token_request; your fail-closed guard and its two tests are byte-identical to main.

What remains is DCR discovery + registration, the atlassian authv2 recipe (requires_dcr + NONE, no audience/resource), required_scopes=["read:account"] (mandated, not merely defaulted — a default would not catch an operator-supplied list that omits it), a 5-scope Jira default_scopes, and config-time validation against scopes_supported (the check you asked for). NONE-style clients do not persist the secret Atlassian hands back.

Happy to split the scope work into a follow-up if you would rather land DCR on its own. Thanks for the live testing that narrowed this to the post-consent step.

…6587]

From 2026-05-27 Atlassian Identity only issues MCP-audience tokens to RFC 7591
DCR clients; a static classic-3LO app authenticates but is rejected at the Rovo
MCP tool layer because its token carries the wrong audience.

This commit lets the gateway register its own OAuth client at config time and
reuse that client_id for all subsequent consent/token flows for that server.

Schema changes (OAuthProviderConfig):
- requires_dcr (bool): provider mandates Dynamic Client Registration
- registration_url (str|None): pinned RFC 7591 endpoint; falls back to discovery
- protected_resource_metadata_url (str|None): RFC 9728 document for AS discovery
- dcr_client_name (str): client_name sent on the registration request
- default_scopes (list): used when the server config carries no scope list
- required_scopes (list): unioned into every request regardless of config

Engine additions (oauth_engine.py):
- _get_json / _post_dcr: SSRF-safe discovery and registration requests via
  CREDENTIALED_OAUTH_PROFILE (same guard as the token endpoint)
- _discover_registration_url: walks RFC 9728 -> RFC 8414 append form
  ({as}/.well-known/oauth-authorization-server) to find registration_endpoint
- register_dcr_client: RFC 7591 POST; token_endpoint_auth_method derived from
  cfg.token_endpoint_auth_style (NONE -> "none", else "client_secret_post")
- fetch_protected_resource_metadata: returns the whole PRM so one fetch serves
  both scope validation and the registration-endpoint walk
- validate_scopes_against_prm: reports scopes absent from scopes_supported;
  reports none when the PRM does not advertise the array at all

Route changes (configure_egress_auth):
- For requires_dcr providers: run DCR once, persist client_id; reuse on re-saves.
  Confidential DCR clients that return no secret are rejected with HTTP 502.
  NONE-style (public PKCE) clients with no secret are accepted, and any secret
  the AS volunteers is not persisted -- the token leg never reads it.
- Resolve scopes before use: apply default_scopes when none are configured, then
  union in required_scopes. DCR registers the resolved list, not the raw input.
- Validate scopes against the resource's scopes_supported before registering, so
  a bad config cannot orphan a DCR client at the AS. Unsupported scopes give a
  400 naming them. An unreachable PRM is fatal only when a registration is about
  to happen; on the reuse path validation is skipped with a warning so re-saving
  an existing server does not depend on the provider's metadata endpoint.
- TokenEndpointAuthStyle imported at module level so the check is explicit.

Facade + public routes (initiate_consent):
- Wrap build_consent_url in try/except EgressAuthError -> HTTP 400 so a
  requires_dcr provider whose client_id was never registered gives a clear
  error instead of a KeyError/500.

Service (build_consent_url):
- Raise EgressAuthError with an actionable message when a requires_dcr provider
  has no client_id. Scoped to requires_dcr so operators of plainly
  misconfigured non-DCR providers are not told to trigger a registration that
  would never run.

Provider recipe (atlassian):
- requires_dcr=True, token_endpoint_auth_style=NONE, protected_resource_metadata_url
  pointing at Rovo MCP protected-resource metadata, no classic audience, no
  RFC 8707 resource.
- required_scopes=["read:account"]. Verified live by a controlled sweep: authv2
  rejects any authorize request without read:account, and does so only AFTER the
  user submits consent, with an opaque invalid_request / "Incorrect request
  parameters" that names no scope. Nothing advertises the requirement (RFC 9728
  has no required-scopes field), so it is not discoverable from metadata. It is
  mandated rather than merely defaulted because a default is only a fallback: an
  operator-supplied list omitting it would fail identically.
- default_scopes: the 5-scope set verified end to end (token minted PKCE-only,
  and real data tools -- getVisibleJiraProjects, getJiraIssue, atlassianUserInfo
  -- all returning 200). Confluence scopes are not defaulted; add per-server.
- The consent screen's three permission groups (Read / Write / Search) do not
  affect the outcome in either direction; only read:account does.
- Token leg confirmed public: the exchange succeeds with PKCE and no
  client_secret. Atlassian's DCR returns a secret (client_secret_expires_at: 0)
  even when token_endpoint_auth_method is echoed as "none"; it is deliberately
  unused and unpersisted. To flip to confidential if that ever changes: set
  token_endpoint_auth_style to POST_BODY here and the route persists the
  DCR-returned secret, with the confidential guard rejecting a secret-less
  response automatically. The engine needs no change either way.
…ches [PE1-6587]

TestDynamicClientRegistration (test_oauth_engine.py):
- Discovery: RFC 9728 PRM -> RFC 8414 AS metadata walk; pinned registration_url
  short-circuits discovery; missing AS list / registration_endpoint raise errors.
- Registration: NONE-style sends token_endpoint_auth_method=none and returns no
  secret; confidential client returns secret; missing client_id raises.
- End-to-end: register_dcr_client through the real guarded client (mock httpx).
- A pre-fetched PRM is reused rather than re-fetched.

TestDcrTransport (test_oauth_engine.py):
- _get_json happy path, non-JSON, SSRF guard failure, HTTP error wrapping.
- _post_dcr happy path, error payload, non-JSON, SSRF guard failure, HTTP error.

TestScopeValidation (test_oauth_engine.py):
- validate_scopes_against_prm: all-supported, some-unsupported, empty input,
  and a PRM with no scopes_supported (validation skipped, not failed closed).
- fetch_protected_resource_metadata happy path and unconfigured-URL error.

TestAtlassianAuthorizeUrl (test_oauth_engine.py):
- Confirm no audience or RFC 8707 resource in the Atlassian authorize URL.

TestConfigureEgressDcr (test_configure_egress_url_validation.py):
- NONE-style client: DCR runs, client_id persisted, no secret required.
- Skips DCR when client_id already present (config re-save idempotency).
- DCR engine error -> HTTP 502 with detail.
- Reuse existing client_id + rotate supplied secret (confidential provider).
- NONE-style drops an operator-supplied secret, and discards one volunteered by
  the AS -- Atlassian returns a secret even for token_endpoint_auth_method=none.
- Confidential DCR client returning no secret -> HTTP 502.
- DCR receives the RESOLVED scope list (defaults applied, required unioned in),
  since registering the raw operator input would mint a client whose grant can
  never complete consent.

TestConfigureEgressDefaultScopes / TestConfigureScopeValidation:
- default_scopes applied only when the operator supplies none; explicit scopes
  win; providers without defaults are unaffected.
- required_scopes appended to an explicit list that omits them -- the case a
  default cannot cover -- without duplicating an already-correct entry, and not
  applied to providers that declare none.
- Unsupported scope -> 400 naming it; valid set passes; validation skipped when
  the PRM advertises no scopes_supported.
- Rejection happens before DCR (asserted on the registration path, with no
  client_id supplied, so the branch is actually entered).
- Unreachable PRM: fatal 502 when registering, degrades to unvalidated on the
  reuse path so an edit does not depend on the provider's metadata endpoint.

TestConsentAndCallback (test_service.py):
- requires_dcr provider with no client_id raises EgressAuthError on
  build_consent_url ("not registered yet").

TestBuildConsentUrl (test_public_routes.py, test_facade_routes.py):
- EgressAuthError on consent entry points -> HTTP 400.

TestAtlassianProvider (test_providers.py):
- requires_dcr + NONE style + PRM URL pinned; no classic audience param.
- default_scopes is the verified 5-scope set; required_scopes is ["read:account"].
- Non-atlassian providers declare neither default_scopes nor required_scopes.
- test_builtin_providers_are_confidential updated: non-DCR providers stay
  confidential; DCR providers (atlassian) may use NONE style.
@go-faustino
go-faustino force-pushed the feat/egress-dcr-atlassian-authv2 branch from e4c4321 to 6e3e8a2 Compare September 2, 2026 08:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants