Skip to content

Add Clerk OAuth provider#3677

Merged
jlowin merged 12 commits intoPrefectHQ:mainfrom
mostafa6765:feature/clerk-auth
Mar 29, 2026
Merged

Add Clerk OAuth provider#3677
jlowin merged 12 commits intoPrefectHQ:mainfrom
mostafa6765:feature/clerk-auth

Conversation

@mostafa6765
Copy link
Copy Markdown
Contributor

Description

Add a Clerk OAuth provider to the FastMCP server auth stack, mirroring the existing Google style providers.

Contribution type

  • Enhancement

Checklist

  • This PR addresses an existing issue Support Clerk Auth Provider #3676
  • I have read CONTRIBUTING.md
  • I have added tests that cover my changes
  • I have run uv run prek run --all-files and all checks pass
  • I have self-reviewed my changes
  • If I used an LLM, it followed the repo's contributing conventions (not generic output)

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@marvin-context-protocol marvin-context-protocol Bot added enhancement Improvement to existing functionality. For issues and smaller PR improvements. auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. server Related to FastMCP server implementation or server-side functionality. labels Mar 28, 2026
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol Bot commented Mar 28, 2026

Test Failure Analysis

Note: Updated to reflect the latest CI run (2026-03-29, workflow run #23705994763).

Summary: The static_analysis job fails because the ty type checker reports a type error in src/fastmcp/server/auth/providers/clerk.py at line 154 — the auth argument to httpx.AsyncClient.post() is typed as None | tuple[...], but httpx does not accept None for that parameter.

Root Cause: In ClerkTokenVerifier.verify_token(), introspect_auth is initialized to None and conditionally assigned a tuple:

introspect_auth = None                          # line 144 — type: None
if self._client_id and self._client_secret:
    introspect_auth = (self._client_id, self._client_secret)   # type: tuple[str, str]
elif self._client_id:
    introspect_data_payload["client_id"] = self._client_id

introspect_response = await client.post(
    self._introspection_url,
    data=introspect_data_payload,
    headers={"User-Agent": "FastMCP-Clerk-OAuth"},
    auth=introspect_auth,   # line 154 — ty error: None is not a valid AuthType
)

httpx.AsyncClient.post() declares auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT. The AuthTypes union does not include None, so passing introspect_auth when it is None is a type error. The fix is to either omit the auth keyword argument when there is no credential, or pass httpx.USE_CLIENT_DEFAULT as the default.

Suggested Solution: In src/fastmcp/server/auth/providers/clerk.py, change the client.post() call to only pass auth when a credential has been set:

# Replace lines 150-155 with:
post_kwargs: dict = {
    "data": introspect_data_payload,
    "headers": {"User-Agent": "FastMCP-Clerk-OAuth"},
}
if introspect_auth is not None:
    post_kwargs["auth"] = introspect_auth

introspect_response = await client.post(
    self._introspection_url,
    **post_kwargs,
)

Alternatively, annotate introspect_auth with its full type and use a conditional branch:

if introspect_auth is not None:
    introspect_response = await client.post(
        self._introspection_url,
        data=introspect_data_payload,
        headers={"User-Agent": "FastMCP-Clerk-OAuth"},
        auth=introspect_auth,
    )
else:
    introspect_response = await client.post(
        self._introspection_url,
        data=introspect_data_payload,
        headers={"User-Agent": "FastMCP-Clerk-OAuth"},
    )
Detailed Analysis

Failing check: ty static type checker (via prek)

Error message from CI:

error[invalid-argument-type]: Argument to bound method `post` is incorrect
  --> src/fastmcp/server/auth/providers/clerk.py:154:25
    |
152 |                         data=introspect_data_payload,
153 |                         headers={"User-Agent": "FastMCP-Clerk-OAuth"},
154 |                         auth=introspect_auth,
    |                         ^^^^^^^^^^^^^^^^^^^^ Expected `tuple[str | bytes, str | bytes] | ((Request, /) -> Request) | Auth | UseClientDefault`, found `None | tuple[...]`
    |
info: Element `None` of this union is not assignable to `tuple[str | bytes, str | bytes] | ((Request, /) -> Request) | Auth | UseClientDefault`

Why: introspect_auth is inferred as None | tuple[str, str]. When the condition self._client_id and self._client_secret is False, it stays None. The httpx auth parameter requires a specific set of types; None is not among them (the default is the sentinel USE_CLIENT_DEFAULT, not None).

File: src/fastmcp/server/auth/providers/clerk.py, lines 144-154

Related Files
  • src/fastmcp/server/auth/providers/clerk.py — contains the type error at line 154 in ClerkTokenVerifier.verify_token()
  • .github/workflows/run-static.yml — the workflow that runs prek (ruff + prettier + ty)

@mostafa6765
Copy link
Copy Markdown
Contributor Author

Hey @jlowin — could you take a look at this?

The failing test appears to be a pre-existing issue on main, unrelated to the Clerk OAuth changes in this PR.

The marvin-context-protocol bot analysis highlights that get_http_request() does not restore headers when called directly inside a background task (without using CurrentRequest() / CurrentHeaders()), which causes the failure.

Copy link
Copy Markdown
Member

@jlowin jlowin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together!

A few things to address before this can ship:

Should likely use OIDCProxy instead of OAuthProxy. Clerk is an OIDC provider, and assuming it publishes a .well-known/openid-configuration endpoint, this should extend OIDCProxy rather than OAuthProxy. OIDCProxy handles JWKS discovery, JWT verification, and endpoint configuration automatically — no custom ClerkTokenVerifier needed. Take a look at Auth0Provider as a template; it's about 130 lines with no custom token verifier. The current approach makes two HTTP calls per token verification where local JWT verification would be zero. (Worth confirming that Clerk's OIDC discovery works as expected here — if it does, this simplifies things a lot.)

Audience verification is required. The current ClerkTokenVerifier reads the audience from introspection but never validates it against the configured client_id. We need to verify that tokens were actually issued for the correct application. OIDCProxy handles this naturally — audience is a first-class parameter (see how Auth0Provider uses it), and the underlying JWTVerifier checks it during JWT validation.

Scope fallback is too permissive. When introspection fails, the verifier assumes the token has all required scopes (lines 308-309). That means scope checking only works when introspection is healthy — if it's down, every token passes. This would likely also be resolved by the OIDCProxy switch, since scopes would come from the JWT claims directly.

If OIDCProxy works with Clerk's discovery endpoint, the switch should simplify this significantly and address all three concerns at once.

@mostafa6765
Copy link
Copy Markdown
Contributor Author

@jlowin Thanks for the thoughtful review — really appreciate the detailed feedback.

On the OIDCProxy vs OAuthProxy point:
You’re absolutely right that Clerk is an OIDC provider and that, in theory, using OIDCProxy would simplify things by leveraging JWKS discovery and local JWT verification.

That said, our current approach is intentionally aligned with the existing Google provider pattern, where we fetch user info during token verification. The main reason is that, in practice, we almost always need enriched user attributes (especially email, email_verified, etc.) immediately as part of the auth context.

With OIDCProxy, while we’d get validated JWT claims locally, we’d still need to make a follow-up request to /userinfo to retrieve those fields reliably. So the trade-off becomes:

  • OIDCProxy → 0 calls for verification + 1 call for user info
  • Current approach → 1–2 calls but returns fully enriched claims in a single step

We chose the latter to keep the auth pipeline simpler for downstream consumers and consistent with existing providers.


On audience validation:
Agreed — this was a gap. The latest update enforces strict audience checking when client_id is configured, ensuring tokens are only accepted if issued for the correct application.


On scope validation fallback:
Also agreed — the previous behavior was too permissive. This is now updated to fail closed:

  • If required_scopes is configured but scopes cannot be verified → reject the token
  • Scope checks are only skipped when not configured at all

Summary

  • We’re aware that OIDCProxy could reduce verification overhead and simplify parts of the implementation.
  • However, since user profile data is required in most flows, we’d still need an additional request — so we opted for a design that returns fully enriched claims upfront, consistent with the Google provider.
  • The key security concerns (audience + scope validation) are now addressed with strict checks.

@mostafa6765
Copy link
Copy Markdown
Contributor Author

@jlowin please take a look — I’ve fixed the oauth/token_info issue using Basic Auth.

@mostafa6765 mostafa6765 requested a review from jlowin March 29, 2026 10:12
Copy link
Copy Markdown
Member

@jlowin jlowin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I understand the reasoning better now — the Google provider is a closer analogue than Auth0 for what Clerk needs, and this follows that pattern. Withdrawing the OIDCProxy ask.

Two things to address before this can merge:

Flip the verification order. The Google provider calls tokeninfo first (auth metadata: aud, sub, scopes, expiry) and userinfo second (profile enrichment, gracefully degraded). Clerk does it backwards: userinfo first, introspection second. This means if introspection reveals the token is inactive or has a wrong audience, the userinfo call was wasted. More importantly, security-critical checks should come first. Introspect first, then fetch userinfo only if the token passes, and make userinfo failure non-fatal like Google does.

Fix the active field default. introspect_data.get("active", True) treats a missing active field as active. RFC 7662 requires active in the response — if it's absent, the response is malformed and should be treated as a rejection, not a pass.

Copy link
Copy Markdown
Member

@jlowin jlowin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks so much!

@mostafa6765
Copy link
Copy Markdown
Contributor Author

@jlowin Thanks — updated.

  • Switched to introspection-first (security checks before userinfo)
  • Enforced strict active handling (missing ⇒ reject per RFC 7662)
  • Made userinfo after introspection (enrichment only)
  • Adjusted tests accordingly

@jlowin jlowin merged commit 57a7f12 into PrefectHQ:main Mar 29, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. enhancement Improvement to existing functionality. For issues and smaller PR improvements. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants