Files
dify/api/tests/unit_tests/controllers/openapi/test_oauth_device.py
T

590 lines
24 KiB
Python

import builtins
from collections.abc import Callable
from datetime import UTC, datetime
from http.cookies import SimpleCookie
from types import SimpleNamespace
from unittest.mock import Mock, create_autospec
import pytest
from flask import Flask
from flask.views import MethodView
from controllers.console import flask_admission, wraps
from controllers.openapi import bp as openapi_bp
from controllers.openapi.oauth_device import (
DeviceApproveApi,
DeviceDenyApi,
OAuthDeviceCodeApi,
OAuthDeviceLookupApi,
OAuthDeviceTokenApi,
_error_response,
)
from libs.helper import RateLimiter
from libs.login import AccountWithTenant
from machinery.context import RequestContext
from models.account import Account, AccountStatus
from services.oauth_device_application_service import OAuthDeviceApplicationService
from services.oauth_device_contracts import (
AccessDeniedError,
AlreadyResolvedError,
ApprovalInProgressError,
ApprovalOutcomeUnknownError,
ApprovalSessionConsumedError,
AuthorizationPendingError,
DeviceApprovalContext,
DeviceAuthorization,
DeviceLookup,
DeviceMutation,
DeviceRequestContext,
DeviceSSOCompletion,
DeviceSSOInitiation,
DeviceStateLostError,
ExpiredOrUnknownError,
ExpiredTokenError,
ExternalApprovalCSRFError,
ExternalApprovalRateLimitError,
ExternalIdentityConflictError,
ExternalUserCodeMismatchError,
ExternalUserCodeNotFoundError,
InvalidApprovalSessionError,
InvalidUserCodeError,
OAuthDeviceError,
OAuthDeviceSSOConfigurationError,
OAuthDeviceSSOInitiationError,
PollTooFastError,
UnsupportedClientError,
)
"""Account-branch device-flow approve/deny under /openapi/v1."""
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
def _rule(app: Flask, path: str):
return next(r for r in app.url_map.iter_rules() if r.rule == path)
class TestDeviceApproveDenyCases:
@staticmethod
@pytest.fixture
def openapi_app() -> Flask:
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(openapi_bp)
return app
@staticmethod
def test_approve_route_registered(openapi_app: Flask):
rules = {r.rule for r in openapi_app.url_map.iter_rules()}
assert "/openapi/v1/oauth/device/approve" in rules
@staticmethod
def test_deny_route_registered(openapi_app: Flask):
rules = {r.rule for r in openapi_app.url_map.iter_rules()}
assert "/openapi/v1/oauth/device/deny" in rules
@staticmethod
def test_approve_dispatches_to_class(openapi_app: Flask):
rule = _rule(openapi_app, "/openapi/v1/oauth/device/approve")
assert openapi_app.view_functions[rule.endpoint].view_class is DeviceApproveApi
@staticmethod
def test_deny_dispatches_to_class(openapi_app: Flask):
rule = _rule(openapi_app, "/openapi/v1/oauth/device/deny")
assert openapi_app.view_functions[rule.endpoint].view_class is DeviceDenyApi
@staticmethod
def test_approve_and_deny_methods(openapi_app: Flask):
approve = _rule(openapi_app, "/openapi/v1/oauth/device/approve")
deny = _rule(openapi_app, "/openapi/v1/oauth/device/deny")
assert "POST" in approve.methods
assert "POST" in deny.methods
@staticmethod
def test_unknown_approval_outcome_is_retryable() -> None:
body, status = _error_response(ApprovalOutcomeUnknownError())
assert status == 503
assert body == {"error": "approval_outcome_unknown"}
"""POST /openapi/v1/oauth/device/code is the canonical RFC 8628 device
authorization endpoint.
Tests verify URL routing without invoking the handler — invoking would
require Redis, which the unit-test runtime does not initialise.
"""
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
class TestDeviceCodeCases:
@staticmethod
@pytest.fixture
def openapi_app() -> Flask:
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(openapi_bp)
return app
@staticmethod
def test_openapi_route_registered(openapi_app: Flask) -> None:
rules = {r.rule for r in openapi_app.url_map.iter_rules()}
assert "/openapi/v1/oauth/device/code" in rules
@staticmethod
def test_route_dispatches_to_class(openapi_app: Flask) -> None:
rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/code")
view = openapi_app.view_functions[rule.endpoint]
assert hasattr(view, "view_class")
assert view.view_class is OAuthDeviceCodeApi
@staticmethod
def test_route_accepts_post(openapi_app: Flask) -> None:
rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/code")
assert rule.methods is not None
assert "POST" in rule.methods
@staticmethod
def test_known_client_ids_default_includes_difyctl() -> None:
from configs import dify_config
assert "difyctl" in dify_config.OPENAPI_KNOWN_CLIENT_IDS
"""GET /openapi/v1/oauth/device/lookup is the canonical user-code lookup."""
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
class TestDeviceLookupCases:
@staticmethod
@pytest.fixture
def openapi_app() -> Flask:
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(openapi_bp)
return app
@staticmethod
def test_openapi_route_registered(openapi_app: Flask) -> None:
rules = {r.rule for r in openapi_app.url_map.iter_rules()}
assert "/openapi/v1/oauth/device/lookup" in rules
@staticmethod
def test_route_dispatches_to_class(openapi_app: Flask) -> None:
rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/lookup")
view = openapi_app.view_functions[rule.endpoint]
assert hasattr(view, "view_class")
assert view.view_class is OAuthDeviceLookupApi
@staticmethod
def test_route_accepts_get(openapi_app: Flask) -> None:
rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/lookup")
assert rule.methods is not None
assert "GET" in rule.methods
"""POST /openapi/v1/oauth/device/token is the canonical poll endpoint."""
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
class TestDeviceTokenCases:
@staticmethod
@pytest.fixture
def openapi_app() -> Flask:
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(openapi_bp)
return app
@staticmethod
def test_openapi_route_registered(openapi_app: Flask) -> None:
rules = {r.rule for r in openapi_app.url_map.iter_rules()}
assert "/openapi/v1/oauth/device/token" in rules
@staticmethod
def test_route_dispatches_to_class(openapi_app: Flask) -> None:
rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/token")
view = openapi_app.view_functions[rule.endpoint]
assert hasattr(view, "view_class")
assert view.view_class is OAuthDeviceTokenApi
"""Device-flow HTTP contracts with application services isolated at their public boundary."""
_PREFIX = "/openapi/v1/oauth/device"
_COOKIE = "device_approval_grant"
class TestOauthDeviceHttpCases:
@staticmethod
@pytest.fixture
def device_service(
openapi_app: Flask, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
) -> Mock:
service = create_autospec(OAuthDeviceApplicationService, instance=True)
openapi_app.extensions["application_services"] = SimpleNamespace(
oauth_device=service,
feature_queries=SimpleNamespace(has_valid_enterprise_license=lambda: True),
)
# Supply an authenticated account while retaining the real admission/context wrapper.
account = Account(name="Ada", email="ada@example.com", status=AccountStatus.ACTIVE)
account.id = "account-1"
account_with_tenant = AccountWithTenant(account, "workspace-1")
config_overrides(
LOGIN_DISABLED=True,
ENABLE_OAUTH_BEARER=True,
CONSOLE_API_URL="https://api.example",
CONSOLE_WEB_URL="https://console.example",
)
monkeypatch.setattr(wraps, "_is_setup_completed", lambda: True)
monkeypatch.setattr(wraps, "current_account_with_tenant", lambda: account_with_tenant)
monkeypatch.setattr(flask_admission, "current_account_with_tenant", lambda: account_with_tenant)
monkeypatch.setattr(RateLimiter, "is_rate_limited", lambda *_args: False)
monkeypatch.setattr(RateLimiter, "increment_rate_limit", lambda *_args: None)
return service
@staticmethod
def test_device_code_serializes_authorization_and_passes_request_origin(
openapi_app: Flask, device_service: Mock
) -> None:
device_service.start.return_value = DeviceAuthorization(
device_code="opaque-code",
user_code="ABCD-EFGH",
verification_uri="https://console.example/device",
expires_in=900,
interval=5,
)
response = openapi_app.test_client().post(
f"{_PREFIX}/code",
json={"client_id": "difyctl", "device_label": "laptop"},
base_url="https://api.example",
environ_overrides={"REMOTE_ADDR": "192.0.2.10"},
)
assert response.status_code == 200
assert response.json == {
"device_code": "opaque-code",
"user_code": "ABCD-EFGH",
"verification_uri": "https://console.example/device",
"expires_in": 900,
"interval": 5,
}
device_service.start.assert_called_once_with(
client_id="difyctl",
device_label="laptop",
created_ip="192.0.2.10",
request_origin="https://api.example/",
)
@staticmethod
@pytest.mark.parametrize("path", ["code", "token", "approve", "deny", "approve-external"])
@pytest.mark.parametrize("body", ["{}", "[]", "{invalid"])
def test_invalid_device_request_never_reaches_service(
openapi_app: Flask, device_service: Mock, path: str, body: str
) -> None:
response = openapi_app.test_client().post(f"{_PREFIX}/{path}", data=body, content_type="application/json")
assert response.status_code == 400
assert device_service.mock_calls == []
@staticmethod
def test_lookup_requires_user_code(openapi_app: Flask, device_service: Mock) -> None:
response = openapi_app.test_client().get(f"{_PREFIX}/lookup")
assert response.status_code == 400
device_service.lookup.assert_not_called()
@staticmethod
def test_lookup_returns_public_validity_without_token_data(openapi_app: Flask, device_service: Mock) -> None:
device_service.lookup.return_value = DeviceLookup(valid=False, expires_in_remaining=0, client_id=None)
response = openapi_app.test_client().get(f"{_PREFIX}/lookup?user_code=ABCD-EFGH")
assert response.status_code == 200
assert response.json == {"valid": False, "expires_in_remaining": 0, "client_id": None}
device_service.lookup.assert_called_once_with(user_code="ABCD-EFGH")
@staticmethod
@pytest.mark.parametrize(
("path", "method", "error", "status", "code"),
[
("code", "start", UnsupportedClientError(), 400, "unsupported_client"),
("token", "poll", PollTooFastError(), 400, "slow_down"),
("token", "poll", ExpiredTokenError(), 400, "expired_token"),
("token", "poll", AuthorizationPendingError(), 400, "authorization_pending"),
("token", "poll", AccessDeniedError(), 400, "access_denied"),
("approve", "approve", ExpiredOrUnknownError(), 404, "expired_or_unknown"),
("approve", "approve", AlreadyResolvedError(), 409, "already_resolved"),
("approve", "approve", ApprovalInProgressError(), 409, "approve_in_progress"),
("approve", "approve", ApprovalOutcomeUnknownError(), 503, "approval_outcome_unknown"),
("deny", "deny", DeviceStateLostError(), 409, "state_lost"),
],
)
def test_device_errors_keep_oauth_response_contract(
openapi_app: Flask,
device_service: Mock,
path: str,
method: str,
error: OAuthDeviceError,
status: int,
code: str,
) -> None:
methods = {
"start": device_service.start,
"poll": device_service.poll,
"approve": device_service.approve,
"deny": device_service.deny,
}
methods[method].side_effect = error
response = openapi_app.test_client().post(
f"{_PREFIX}/{path}",
json={
"client_id": "difyctl",
"device_label": "CLI",
"device_code": "opaque-code",
"user_code": "ABCD-EFGH",
},
)
assert response.status_code == status
assert response.json == {"error": code}
@staticmethod
@pytest.mark.parametrize("external", [False, True], ids=["account", "external"])
def test_poll_serializes_subject_specific_token_payload(
openapi_app: Flask, device_service: Mock, external: bool
) -> None:
payload: dict[str, object] = {
"token": "secret-token",
"expires_at": "2030-01-01T00:00:00Z",
"token_id": "token-1",
"subject_type": "external_sso" if external else "account",
"account": None if external else {"id": "account-1", "email": "ada@example.com", "name": "Ada"},
"workspaces": [],
"default_workspace_id": None,
}
if external:
payload.update(subject_email="external@example.com", subject_issuer="https://idp.example")
device_service.poll.return_value = payload
response = openapi_app.test_client().post(
f"{_PREFIX}/token",
json={"device_code": "opaque-code", "client_id": "difyctl"},
environ_overrides={"REMOTE_ADDR": "192.0.2.10"},
)
assert response.status_code == 200
assert response.json == payload
device_service.poll.assert_called_once_with(device_code="opaque-code", poll_ip="192.0.2.10")
@staticmethod
@pytest.mark.parametrize("path", ["approve", "deny"])
def test_account_decision_uses_admitted_identity(openapi_app: Flask, device_service: Mock, path: str) -> None:
method = device_service.approve if path == "approve" else device_service.deny
method.return_value = DeviceMutation(status="approved" if path == "approve" else "denied")
response = openapi_app.test_client().post(
f"{_PREFIX}/{path}",
json={"user_code": "ABCD-EFGH", "account_id": "forged", "workspace_id": "foreign"},
)
assert response.status_code == 200
assert response.json == {"status": method.return_value.status}
assert method.call_args.kwargs == {"user_code": "ABCD-EFGH"}
if path == "approve":
context = method.call_args.args[0]
assert isinstance(context, RequestContext)
assert context.account_id == "account-1"
assert context.active_workspace_id == "workspace-1"
@staticmethod
@pytest.mark.parametrize("path", ["approve", "deny"])
def test_disabled_bearer_feature_blocks_account_decisions(
openapi_app: Flask, device_service: Mock, config_overrides: Callable[..., None], path: str
) -> None:
config_overrides(ENABLE_OAUTH_BEARER=False)
response = openapi_app.test_client().post(f"{_PREFIX}/{path}", json={"user_code": "ABCD-EFGH"})
assert response.status_code == 503
assert device_service.mock_calls == []
@staticmethod
def test_sso_initiation_clears_old_approval_cookie(openapi_app: Flask, device_service: Mock) -> None:
device_service.initiate_sso.return_value = DeviceSSOInitiation("https://idp.example/authorize")
response = openapi_app.test_client().get(f"{_PREFIX}/sso-initiate?user_code=%20ABCD-EFGH%20")
assert response.status_code == 302
assert response.headers["Location"] == "https://idp.example/authorize"
cookie = SimpleCookie(response.headers["Set-Cookie"])[_COOKIE]
assert cookie["max-age"] == "0"
assert cookie["path"] == _PREFIX
assert device_service.initiate_sso.call_args.kwargs == {"user_code": "ABCD-EFGH"}
@staticmethod
def test_sso_initiation_requires_user_code(openapi_app: Flask, device_service: Mock) -> None:
response = openapi_app.test_client().get(f"{_PREFIX}/sso-initiate?user_code=%20")
assert response.status_code == 400
device_service.initiate_sso.assert_not_called()
@staticmethod
def test_sso_completion_sets_scoped_http_only_cookie(openapi_app: Flask, device_service: Mock) -> None:
device_service.complete_sso.return_value = DeviceSSOCompletion(
user_code="ABCD-EFGH", approval_grant="signed-grant"
)
response = openapi_app.test_client().get(f"{_PREFIX}/sso-complete?sso_assertion=signed-assertion")
assert response.status_code == 302
assert response.headers["Location"] == "/device?sso_verified=1"
cookie = SimpleCookie(response.headers["Set-Cookie"])[_COOKIE]
assert cookie.value == "signed-grant"
assert cookie["path"] == _PREFIX
assert cookie["httponly"]
assert cookie["secure"]
assert cookie["samesite"] == "Lax"
assert cookie["max-age"] == "300"
assert device_service.complete_sso.call_args.kwargs == {
"inbound_error": None,
"inbound_user_code": None,
"assertion": "signed-assertion",
}
@staticmethod
@pytest.mark.parametrize("failure", ["exception", "missing-grant", "untrusted-error"])
def test_sso_completion_failure_redirects_without_exposing_details(
openapi_app: Flask, device_service: Mock, failure: str
) -> None:
device_service.complete_sso.return_value = DeviceSSOCompletion(
error_code="private-provider-error" if failure == "untrusted-error" else None,
)
if failure == "exception":
device_service.complete_sso.side_effect = RuntimeError("private-provider-error")
response = openapi_app.test_client().get(f"{_PREFIX}/sso-complete?sso_assertion=private-assertion")
assert response.status_code == 302
assert response.headers["Location"] == "/device?sso_error=sso_failed"
assert "Set-Cookie" not in response.headers
assert b"private-" not in response.data
@staticmethod
def test_approval_context_reads_cookie_and_serializes_expiry(openapi_app: Flask, device_service: Mock) -> None:
device_service.get_approval_context.return_value = DeviceApprovalContext(
subject_email="external@example.com",
subject_issuer="https://idp.example",
user_code="ABCD-EFGH",
csrf_token="csrf-token",
expires_at=datetime(2030, 1, 1, tzinfo=UTC),
)
client = openapi_app.test_client()
client.set_cookie(_COOKIE, "signed-grant", path=_PREFIX)
response = client.get(f"{_PREFIX}/approval-context")
assert response.status_code == 200
assert response.json == {
"subject_email": "external@example.com",
"subject_issuer": "https://idp.example",
"user_code": "ABCD-EFGH",
"csrf_token": "csrf-token",
"expires_at": "2030-01-01T00:00:00Z",
}
assert device_service.get_approval_context.call_args.kwargs == {"approval_grant": "signed-grant"}
@staticmethod
def test_external_approval_uses_cookie_and_csrf_header_then_clears_cookie(
openapi_app: Flask, device_service: Mock
) -> None:
device_service.approve_external.return_value = DeviceMutation("approved")
client = openapi_app.test_client()
client.set_cookie(_COOKIE, "signed-grant", path=_PREFIX)
response = client.post(
f"{_PREFIX}/approve-external",
headers={"X-CSRF-Token": "csrf-header"},
json={"user_code": "ABCD-EFGH", "csrf_token": "forged", "approval_grant": "forged"},
)
assert response.status_code == 200
assert response.json == {"status": "approved"}
assert isinstance(device_service.approve_external.call_args.args[0], DeviceRequestContext)
assert device_service.approve_external.call_args.kwargs == {
"approval_grant": "signed-grant",
"csrf_token": "csrf-header",
"user_code": "ABCD-EFGH",
}
cookie = SimpleCookie(response.headers["Set-Cookie"])[_COOKIE]
assert cookie["max-age"] == "0"
assert cookie["path"] == _PREFIX
@staticmethod
@pytest.mark.parametrize(
("path", "method", "error", "status", "message"),
[
("sso-initiate", "initiate_sso", InvalidUserCodeError(), 400, "invalid_user_code"),
("sso-initiate", "initiate_sso", OAuthDeviceSSOConfigurationError(), 502, "console_api_url_unset"),
("sso-initiate", "initiate_sso", OAuthDeviceSSOInitiationError(), 502, "sso_initiate_failed"),
("approval-context", "get_approval_context", InvalidApprovalSessionError(), 401, "no_session"),
("approve-external", "approve_external", InvalidApprovalSessionError(), 401, "invalid_session"),
("approve-external", "approve_external", ExternalApprovalRateLimitError(), 429, "rate_limited"),
("approve-external", "approve_external", ExternalApprovalCSRFError(), 403, "csrf_mismatch"),
("approve-external", "approve_external", ExternalUserCodeMismatchError(), 400, "user_code_mismatch"),
("approve-external", "approve_external", ExternalUserCodeNotFoundError(), 404, "user_code_not_pending"),
("approve-external", "approve_external", AlreadyResolvedError(), 409, "user_code_not_pending"),
("approve-external", "approve_external", ApprovalInProgressError(), 409, "approve_in_progress"),
("approve-external", "approve_external", ApprovalOutcomeUnknownError(), 503, "approval_outcome_unknown"),
(
"approve-external",
"approve_external",
ExternalIdentityConflictError(),
403,
"email_belongs_to_dify_account",
),
("approve-external", "approve_external", ApprovalSessionConsumedError(), 401, "session_already_consumed"),
("approve-external", "approve_external", DeviceStateLostError(), 409, "state_lost"),
],
)
def test_sso_errors_preserve_http_status_and_approval_cookie(
openapi_app: Flask,
device_service: Mock,
path: str,
method: str,
error: OAuthDeviceError,
status: int,
message: str,
) -> None:
methods = {
"initiate_sso": device_service.initiate_sso,
"get_approval_context": device_service.get_approval_context,
"approve_external": device_service.approve_external,
}
methods[method].side_effect = error
client = openapi_app.test_client()
client.set_cookie(_COOKIE, "signed-grant", path=_PREFIX)
if path == "approve-external":
response = client.post(f"{_PREFIX}/{path}", json={"user_code": "ABCD-EFGH"})
else:
response = client.get(f"{_PREFIX}/{path}?user_code=ABCD-EFGH")
assert response.status_code == status
assert message in response.get_data(as_text=True)
assert "Set-Cookie" not in response.headers
assert "signed-grant" not in response.get_data(as_text=True)