Skip to content

Let API callers supply a token for gr.OAuthToken endpoints - #13667

Open
abidlabs wants to merge 4 commits into
mainfrom
fix-oauth-token-for-api-callers
Open

Let API callers supply a token for gr.OAuthToken endpoints#13667
abidlabs wants to merge 4 commits into
mainfrom
fix-oauth-token-for-api-callers

Conversation

@abidlabs

@abidlabs abidlabs commented Jul 24, 2026

Copy link
Copy Markdown
Member

gr.OAuthToken is only ever populated from the OAuth session cookie, so it is always None for an API caller — there's no browser and no session. Any app whose function takes one therefore cannot be driven programmatically at all. This is not specific to gr.Workflow; a plain gr.Interface shows it. It surfaced through a deployed workflow, whose model nodes reached Inference Providers with no token and failed with Sign in with your HF account to use this model.

Why the body, not a header

gradio_client already forwards token= as x-hf-authorization, but I confirmed against a real Space that HF's proxy strips x-hf-* — the app only receives x-ip-token, an opaque JWT whose user claim is encrypted and which is rejected by both whoami ("Invalid user token") and Inference Providers. A custom header like x-gradio-token does survive, but that's undocumented infra behaviour that could change silently and would present as "OAuth randomly stopped working."

So the token travels in the request body as a reserved oauth_token field. Because it rides beside data rather than in it:

  • it never becomes a positional argument and never appears in an endpoint's parameter schema, so no existing call signature changes;
  • it can't be captured by flagging or cached examples, which only see component values;
  • it works from curl and any other client with no client-side support at all.

A token only goes where the app asked for it

get_api_info now reports oauth_token: "required" | "optional" per endpoint, derived from the function signature, and the client consults that before including the field. An app can't collect tokens from calls that had no reason to carry one, and view_api() says plainly which endpoints act on the caller's behalf:

 - predict(api_name="/report") -> value_1
    Acts on your behalf: this endpoint takes your Hugging Face token (optional).
    Pass it with Client(..., oauth_token=...) to grant it.

oauth_token= is deliberately separate from token=: the latter only authenticates you to the app, so accessing a private Space no longer implies handing its code an act-as-you credential. It is never inferred from a locally saved token — if you don't pass it, nothing is granted.

Verified

Against the real OAuth Space gradio-tests/test-calculator-1 (/report takes a gr.OAuthToken, /calculator doesn't):

call result
oauth_token= set, /report user:<name> — usable token arrived
oauth_token= set, /calculator 6 — token not sent
oauth_token= unset, /report none
curl with {"data":[],"oauth_token":"..."} token arrived

One flaky e2e test covers this, in test_external.py alongside the other real-Space tests. Existing suites pass: test_routes, test_blocks, test_helpers, test_workflow, test_workflow_api (376 tests).

I also audited the paths that could persist a body field: analytics send only launch metadata, and error_payload returns only the message — neither touches request payloads.

The JS client has the same oauth_token option and the same per-endpoint gating.

Follow-ups, not in this PR

  • What's forwarded is a full-scope user token rather than a scoped OAuth grant, so an endpoint receiving one can do more than a browser OAuth login would permit. Worth documenting alongside the flag.
  • build_hf_headers() attaches the locally saved token to every client request, including to 127.0.0.1 and non-HF hosts. Unrelated to this change and not something it makes worse, but it may deserve its own issue.

🤖 Generated with Claude Code

@gradio-pr-bot

gradio-pr-bot commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

🪼 branch checks and previews

Name Status URL
Spaces ready! Spaces preview
Website ready! Website preview
🦄 Changes detected! Details

Install Gradio from this PR

pip install https://huggingface.co/buckets/gradio/pypi-previews/resolve/64fc4bc819a68b4a0c2d7591282f1b7d44ff7169/gradio-6.20.0-py3-none-any.whl

Install Gradio Python Client from this PR

pip install "gradio-client @ git+https://github.com/gradio-app/gradio@64fc4bc819a68b4a0c2d7591282f1b7d44ff7169#subdirectory=client/python"

Import Gradio JS Client from this PR via CDN

import { Client } from "https://huggingface.co/buckets/gradio/npm-previews/resolve/64fc4bc819a68b4a0c2d7591282f1b7d44ff7169/browser.js";

@gradio-pr-bot

gradio-pr-bot commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

🦄 change detected

This Pull Request includes changes to the following packages.

Package Version
@gradio/client minor
gradio minor
gradio_client minor

  • oauth: let API callers supply a token for endpoints that take a gr.OAuthToken, via oauth_token on the Python and JS clients — sent only to endpoints that declare they need one

Something isn't right?

  • Maintainers can change the version label to modify the version bump.
  • If the bot has failed to detect any changes, or if this pull request needs to update multiple packages to different versions or requires a more comprehensive changelog entry, maintainers can update the changelog file directly.

`gr.OAuthToken` was only ever populated from the OAuth session cookie, so it
was always `None` for an API caller — no browser, no session. Any app whose
function takes one could not be driven programmatically at all, which is how
this surfaced: a deployed `gr.Workflow` reached its model nodes with no token
and failed with "Sign in with your HF account to use this model".

Callers can now pass `Client(..., oauth_token=...)`, which travels in the
request body as a reserved `oauth_token` field rather than a header — a Space
sits behind a proxy that strips `x-hf-*`, so a header never arrives. Because it
rides beside `data` instead of in it, it never becomes a positional argument or
appears in an endpoint's parameter schema, and it can't be captured by flagging
or cached examples. It also works from `curl` and any other client, with no
client-side support needed.

A token is sent only to endpoints that declare they take one: `get_api_info`
reports `oauth_token: "required" | "optional"` per endpoint, derived from the
function signature, and the client consults that before including the field. So
an app cannot collect tokens from calls that had no reason to carry one, and
`view_api()` states plainly which endpoints act on the caller's behalf.
`oauth_token=` is deliberately separate from `token=`, which only authenticates
the caller to the app, and is never inferred from a locally saved token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@abidlabs
abidlabs force-pushed the fix-oauth-token-for-api-callers branch from c883b4a to e9bad59 Compare July 24, 2026 22:32
@abidlabs abidlabs changed the title Inject a caller-supplied token into gr.OAuthToken so OAuth apps work through the API Let API callers supply a token for gr.OAuthToken endpoints Jul 24, 2026
@abidlabs
abidlabs requested a review from Copilot July 24, 2026 22:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR enables programmatic (non-browser) API callers to supply a Hugging Face user token to Gradio endpoints whose functions accept gr.OAuthToken, by carrying the token in a reserved oauth_token request-body field and only sending it to endpoints that declare they accept it.

Changes:

  • Backend: plumbs an optional oauth_token field through predict request models and injects it into special_args() as an OAuthToken when present.
  • API introspection: reports per-endpoint oauth_token: "required" | "optional" so callers can see which endpoints act on their behalf.
  • Python client + test: adds Client(..., oauth_token=...), selectively includes the field per endpoint, and adds a real-space e2e test.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
test/test_oauth_api_access.py Flaky e2e test verifying body-carried oauth_token reaches only endpoints that declare gr.OAuthToken.
gradio/utils.py Adds helper to detect whether an endpoint takes gr.OAuthToken and whether it’s optional/required.
gradio/routes.py Threads oauth_token from /call/v2 request bodies into PredictBody.
gradio/route_utils.py Wraps body oauth_token into an OAuthToken object and passes it into Blocks.process_api().
gradio/data_classes.py Extends request body models and API info typing to include oauth_token.
gradio/blocks.py Propagates an oauth_token parameter through function calling and exposes requirement in get_api_info().
client/python/gradio_client/client.py Adds oauth_token= to Client, surfaces it in view_api(), and includes it in request bodies when appropriate.
.changeset/shy-tokens-arrive.md Announces minor releases for gradio and gradio_client with the new feature.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread gradio/utils.py Outdated
Comment on lines +1226 to +1244
def oauth_token_requirement(fn: Callable | None) -> str | None:
"""Whether `fn` takes a gr.OAuthToken, and whether it insists on one.

Returns "required" for `gr.OAuthToken`, "optional" for `gr.OAuthToken | None`,
and None when the function never receives one. Reported per endpoint so a
caller can see which endpoints act on their behalf, and so clients only send
a token where the app asked for it.
"""
from gradio.oauth import OAuthToken

if fn is None:
return None
hints = get_type_hints(fn) or getattr(fn, "__annotations__", {}) or {}
for hint in hints.values():
if hint is OAuthToken:
return "required"
if hint == Optional[OAuthToken]:
return "optional"
return None

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed the first half: the scan now looks only at parameter annotations, so a function that returns an OAuthToken is no longer reported as one that receives it.

On the second half — missing OAuthToken | None — that one already worked. Optional[X] and PEP 604 X | None compare equal at runtime, so the single hint == Optional[OAuthToken] check matches both forms:

>>> (OAuthToken | None) == typing.Optional[OAuthToken]
True

Verified against both syntaxes before and after the change (def a(t: OAuthToken | None) and def b(t: Optional[OAuthToken]) both report optional), and special_args relies on the same equality today. I left the check as-is and added a comment noting it covers both, rather than adding a second branch that can never be reached.

Comment thread gradio/routes.py Outdated
Comment on lines 1341 to 1346
parameters_info = app.api_info["named_endpoints"]["/" + api_name][ # type: ignore
"parameters"
]
body = dict(body)
oauth_token = body.pop("oauth_token", None)
processed_args = client_utils.construct_args(
Comment on lines 1196 to 1201
data = {
"data": data or [],
"fn_index": self.fn_index,
**self.oauth_token_payload(),
**kwargs,
}
Comment thread client/python/gradio_client/client.py Outdated
Comment on lines +1067 to +1069
""" "required"/"optional" when this endpoint's function takes a
gr.OAuthToken, else None. A token is only ever sent to endpoints that
say they take one."""
abidlabs and others added 3 commits July 24, 2026 15:40
The JS client gains the same `oauth_token` option and the same gating: it is
included in the payload only for endpoints whose api_info declares they take a
gr.OAuthToken, so parity with the Python client holds and an app still cannot
collect tokens from calls that had no reason to carry one.

The end-to-end check moves into test_external.py, which already hits real
Spaces and is marked flaky and serial, rather than living in a file of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he gate

`oauth_token_requirement` scanned every annotation, including the return type,
so a function that *returned* an OAuthToken was reported as one that receives
one. It now looks only at parameters.

`/call/v2` popped `oauth_token` from every request body, which reserved the name
globally and would swallow a real parameter that happened to be called that. It
is only treated as reserved for endpoints that declare they take a token.

In the client, `**kwargs` expanded after the gated payload, so passing
`oauth_token=` as an ordinary keyword argument would have sent a token to an
endpoint that never asked for one. The computed payload now goes last and wins.

Also reworded a docstring that opened with a quoted word, which `ruff format`
had to space away from the opening triple quote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`oauth_token_requirement` returned plain `str`, which cannot be assigned to the
`Literal["required", "optional"]` key on the APIEndpointInfo TypedDict, and the
e2e test subscripted `view_api()` (overloaded on return_format) and iterated
client.endpoints without narrowing the value type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

3 participants