Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
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
1 change: 1 addition & 0 deletions docs/source/components/auth/api-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ authentication:
| `redirect_uri` | The redirect URI for OAuth 2.0 authentication. Must match the registered redirect URI with the OAuth provider.|
| `scopes` | List of permissions to the API provider (e.g., `read`, `write`). |
| `use_pkce` | Whether to use PKCE (Proof Key for Code Exchange) in the OAuth 2.0 flow, defaults to `False` |
| `use_popup_auth` | Whether to open the OAuth consent page in a popup window or use a redirect-based flow, defaults to `True` (popup) |
| `authorization_kwargs` | Additional keyword arguments to include in the authorization request. |


Expand Down
6 changes: 6 additions & 0 deletions examples/front_ends/simple_auth/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ RUN apt-get update && apt-get install -y \
# Clone the OAuth2 server example
RUN git clone https://github.com/authlib/example-oauth2-server.git oauth2-server

# Apply patches: add an explicit Cancel button to the authorize route and
# template so that declining the OAuth2 consent redirects back with
# error=access_denied instead of leaving the client app waiting indefinitely.
COPY patches/oauth2-server.patch /tmp/oauth2-server.patch
RUN patch -p1 -d /app/oauth2-server < /tmp/oauth2-server.patch

# Change to the OAuth2 server directory
WORKDIR /app/oauth2-server

Expand Down
30 changes: 30 additions & 0 deletions examples/front_ends/simple_auth/patches/oauth2-server.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
--- a/website/routes.py
+++ b/website/routes.py
@@ -105,7 +105,7 @@
if not user and "username" in request.form:
username = request.form.get("username")
user = User.query.filter_by(username=username).first()
- if request.form["confirm"]:
+ if request.form.get("confirm") == "yes":
grant_user = user
else:
grant_user = None
--- a/website/templates/authorize.html
+++ b/website/templates/authorize.html
@@ -9,14 +9,11 @@
<form action="" method="post">
- <label>
- <input type="checkbox" name="confirm">
- <span>Consent?</span>
- </label>
{% if not user %}
<p>You haven't logged in. Log in with:</p>
<div>
<input type="text" name="username">
</div>
{% endif %}
<br>
- <button>Submit</button>
+ <button type="submit" name="confirm" value="yes">Authorize</button>
+ <button type="submit" name="confirm" value="no">Cancel</button>
</form>
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ authentication:
client_id: ${NAT_OAUTH_CLIENT_ID}
client_secret: ${NAT_OAUTH_CLIENT_SECRET}
use_pkce: false
use_popup_auth: true

workflow:
_type: react_agent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ class OAuth2AuthCodeFlowProviderConfig(AuthProviderBaseConfig, name="oauth2_auth
use_pkce: bool = Field(default=False,
description="Whether to use PKCE (Proof Key for Code Exchange) in the OAuth 2.0 flow.")

use_popup_auth: bool = Field(
default=True,
description=("When True (default), the OAuth login page opens in a popup window and the originating page "
"remains open. When False, the browser navigates to the OAuth login page directly and is "
"redirected back after authentication completes."))

authorization_kwargs: dict[str, str] | None = Field(description=("Additional keyword arguments for the "
"authorization request."),
default=None)
3 changes: 3 additions & 0 deletions packages/nvidia_nat_core/src/nat/data_models/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ class _HumanPromptOAuthConsent(HumanPromptBase):
the consent flow.
"""
input_type: typing.Literal[HumanPromptModelType.OAUTH_CONSENT] = HumanPromptModelType.OAUTH_CONSENT
use_popup: bool = Field(default=True,
description="When True the UI should open the OAuth URL in a popup window. "
"When False the UI should navigate the current tab to the OAuth URL.")


class HumanPromptBinary(HumanPromptBase):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class FlowState:
verifier: str | None = None
client: AsyncOAuth2Client | None = None
config: OAuth2AuthCodeFlowProviderConfig | None = None
return_url: str | None = None


class WebSocketAuthenticationFlowHandler(FlowHandlerBase):
Expand All @@ -50,12 +51,14 @@ def __init__(self,
add_flow_cb: Callable[[str, FlowState], Awaitable[None]],
remove_flow_cb: Callable[[str], Awaitable[None]],
web_socket_message_handler: WebSocketMessageHandler,
auth_timeout_seconds: float = 300.0):
auth_timeout_seconds: float = 300.0,
return_url: str | None = None):

self._add_flow_cb: Callable[[str, FlowState], Awaitable[None]] = add_flow_cb
self._remove_flow_cb: Callable[[str], Awaitable[None]] = remove_flow_cb
self._web_socket_message_handler: WebSocketMessageHandler = web_socket_message_handler
self._auth_timeout_seconds: float = auth_timeout_seconds
self._return_url: str | None = return_url

async def authenticate(
self,
Expand Down Expand Up @@ -114,7 +117,8 @@ def _create_authorization_url(self,
async def _handle_oauth2_auth_code_flow(self, config: OAuth2AuthCodeFlowProviderConfig) -> AuthenticatedContext:

state = secrets.token_urlsafe(16)
flow_state = FlowState(config=config)
return_url = None if config.use_popup_auth else self._return_url
flow_state = FlowState(config=config, return_url=return_url)

flow_state.client = self.create_oauth_client(config)

Expand All @@ -130,8 +134,8 @@ async def _handle_oauth2_auth_code_flow(self, config: OAuth2AuthCodeFlowProvider
challenge=flow_state.challenge)

await self._add_flow_cb(state, flow_state)
await self._web_socket_message_handler.create_websocket_message(_HumanPromptOAuthConsent(text=authorization_url)
)
await self._web_socket_message_handler.create_websocket_message(
_HumanPromptOAuthConsent(text=authorization_url, use_popup=config.use_popup_auth))
try:
token = await asyncio.wait_for(flow_state.future, timeout=self._auth_timeout_seconds)
except TimeoutError as exc:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json

AUTH_REDIRECT_CANCELLED_POPUP_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>Authorization Cancelled</title>
<script>
(function () {
window.history.replaceState(null, "", window.location.pathname);

window.opener?.postMessage({ type: 'AUTH_CANCELLED' }, '*');

window.close();
})();
</script>
</head>
<body>
<p>Authorization cancelled. You may now close this window.</p>
</body>
</html>
"""

_AUTH_REDIRECT_CANCELLED_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html>
<head>
<title>Authorization Cancelled</title>
<script>
(function () {
var returnTo = RETURN_URL_PLACEHOLDER;
if (returnTo) {
window.location.replace(returnTo);
} else {
window.history.back();
}
})();
</script>
</head>
<body>
<p>Authorization cancelled. Redirecting&hellip;</p>
</body>
</html>
"""


def build_auth_redirect_cancelled_html(return_url: str | None = None) -> str:
"""Build the authorization-cancelled HTML page.

Redirects back to the UI without the ``oauth_auth_completed`` query
parameter so the UI's cancellation-message branch handles it.

Args:
return_url: The UI origin to navigate back to. Falls back to
``window.history.back()`` when not provided.

Returns:
An HTML string for the post-cancellation redirect page.
"""
safe_json = json.dumps(return_url).replace('<', '\\u003c').replace('>', '\\u003e').replace('/', '\\u002f')
return _AUTH_REDIRECT_CANCELLED_HTML_TEMPLATE.replace("RETURN_URL_PLACEHOLDER", safe_json)
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import json

AUTH_REDIRECT_SUCCESS_HTML = """
<!DOCTYPE html>
<html>
Expand All @@ -33,3 +35,43 @@
</body>
</html>
"""

_AUTH_REDIRECT_SUCCESS_HTML_REDIRECT_TEMPLATE = """\
<!DOCTYPE html>
<html>
<head>
<title>Authentication Complete</title>
<script>
(function () {
var returnTo = RETURN_URL_PLACEHOLDER;
if (returnTo) {
var url = new URL(returnTo);
url.searchParams.set('oauth_auth_completed', 'true');
window.location.replace(url.toString());
} else {
window.history.back();
}
})();
</script>
</head>
<body>
<p>Authentication complete. Redirecting&hellip;</p>
</body>
</html>
"""


def build_auth_redirect_success_html(return_url: str | None = None) -> str:
"""Build the redirect-based authentication success HTML page.

Args:
return_url: The URL to redirect to after successful authentication. When
provided the page navigates there immediately with an ``oauth_auth_completed``
query parameter so the UI can distinguish a successful return from the user
pressing back; otherwise it falls back to ``window.history.back()``.

Returns:
An HTML string for the post-authentication redirect page.
"""
safe_json = json.dumps(return_url).replace('<', '\\u003c').replace('>', '\\u003e').replace('/', '\\u002f')
return _AUTH_REDIRECT_SUCCESS_HTML_REDIRECT_TEMPLATE.replace("RETURN_URL_PLACEHOLDER", safe_json)
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
from fastapi import Request
from fastapi.responses import HTMLResponse

from nat.front_ends.fastapi.html_snippets.auth_code_grant_cancelled import AUTH_REDIRECT_CANCELLED_POPUP_HTML
from nat.front_ends.fastapi.html_snippets.auth_code_grant_cancelled import build_auth_redirect_cancelled_html
from nat.front_ends.fastapi.html_snippets.auth_code_grant_success import AUTH_REDIRECT_SUCCESS_HTML
from nat.front_ends.fastapi.html_snippets.auth_code_grant_success import build_auth_redirect_success_html

if TYPE_CHECKING:
from nat.front_ends.fastapi.fastapi_front_end_plugin_worker import FastApiFrontEndPluginWorker
Expand All @@ -37,13 +40,34 @@ async def add_authorization_route(worker: "FastApiFrontEndPluginWorker", app: Fa
async def redirect_uri(request: Request):
"""Handle the redirect URI for OAuth2 authentication."""
state = request.query_params.get("state")
error = request.query_params.get("error")

async with worker._outstanding_flows_lock:
if not state or state not in worker._outstanding_flows:
return HTMLResponse("Invalid state. Please restart the authentication process.", status_code=400)

flow_state = worker._outstanding_flows[state]

# The OAuth provider returned an error (e.g. user denied consent).
# Signal the waiting workflow and redirect the browser back to the UI.
if error:
error_description = request.query_params.get("error_description", "")
logger.info("OAuth authorisation denied for state %s: %s (%s)", state, error, error_description)
if not flow_state.future.done():
flow_state.future.set_exception(RuntimeError(f"Authorisation denied: {error} ({error_description})"))
await worker._remove_flow(state)
if flow_state.return_url:
return HTMLResponse(content=build_auth_redirect_cancelled_html(flow_state.return_url),
status_code=200,
headers={
"Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache"
})
return HTMLResponse(content=AUTH_REDIRECT_CANCELLED_POPUP_HTML,
status_code=200,
headers={
"Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache"
})

config = flow_state.config
verifier = flow_state.verifier
client = flow_state.client
Expand Down Expand Up @@ -80,7 +104,11 @@ async def redirect_uri(request: Request):
finally:
await worker._remove_flow(state)

return HTMLResponse(content=AUTH_REDIRECT_SUCCESS_HTML,
if flow_state.config and not flow_state.config.use_popup_auth:
success_html = build_auth_redirect_success_html(flow_state.return_url)
else:
success_html = AUTH_REDIRECT_SUCCESS_HTML
return HTMLResponse(content=success_html,
status_code=200,
headers={
"Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,13 @@ async def _websocket_endpoint(websocket: WebSocket):
websocket.scope["headers"] = headers

async with WebSocketMessageHandler(websocket, session_manager, worker.get_step_adaptor(), worker) as handler:
flow_handler = WebSocketAuthenticationFlowHandler(worker._add_flow, worker._remove_flow, handler)
origin = websocket.headers.get("origin")
allowed_origins = worker.front_end_config.cors.allow_origins or []
return_url = origin if origin and origin in allowed_origins else None
flow_handler = WebSocketAuthenticationFlowHandler(worker._add_flow,
worker._remove_flow,
handler,
return_url=return_url)
handler.set_flow_handler(flow_handler)
await handler.run()

Expand Down
27 changes: 27 additions & 0 deletions packages/nvidia_nat_core/tests/nat/builder/test_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from nat.data_models.interactive import HumanPromptText
from nat.data_models.interactive import HumanResponseText
from nat.data_models.interactive import InteractionPrompt
from nat.data_models.interactive import _HumanPromptOAuthConsent

# ------------------------------------------------------------------------------
# Tests for Interactive Data Models
Expand Down Expand Up @@ -151,3 +152,29 @@ def test_human_prompt_base_timeout_validation_gt_zero():
HumanPromptText(text="x", required=True, timeout=0)
with pytest.raises(ValidationError):
HumanPromptText(text="x", required=True, timeout=-1)


# ------------------------------------------------------------------------------
# Tests for _HumanPromptOAuthConsent
# ------------------------------------------------------------------------------


def test_human_prompt_oauth_consent_defaults():
"""_HumanPromptOAuthConsent defaults: input_type is OAUTH_CONSENT and use_popup is True."""
prompt = _HumanPromptOAuthConsent(text="https://auth.example.com/authorize")
assert prompt.input_type == HumanPromptModelType.OAUTH_CONSENT
assert prompt.use_popup is True


def test_human_prompt_oauth_consent_use_popup_false():
"""_HumanPromptOAuthConsent accepts use_popup=False for redirect-based auth flow."""
prompt = _HumanPromptOAuthConsent(text="https://auth.example.com/authorize", use_popup=False)
assert prompt.use_popup is False
assert prompt.input_type == HumanPromptModelType.OAUTH_CONSENT


def test_human_prompt_oauth_consent_text_preserved():
"""_HumanPromptOAuthConsent stores the authorization URL in the text field."""
url = "https://auth.example.com/authorize?client_id=abc&state=xyz"
prompt = _HumanPromptOAuthConsent(text=url)
assert prompt.text == url
Loading