From 71e9e8dda67195890e4faf6efb70cb77f3eb71bf Mon Sep 17 00:00:00 2001 From: GareArc Date: Mon, 27 Apr 2026 00:00:24 -0700 Subject: [PATCH] feat(api): lift SSO branch device-flow handlers to /openapi/v1 (Phase D.15-16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four EE-only SSO handlers (sso_initiate, sso_complete, approval_context, approve_external) move from controllers/oauth_device_sso.py to controllers/openapi/oauth_device/. Each is registered on openapi_bp via @bp.route at the canonical path: /openapi/v1/oauth/device/sso-initiate /openapi/v1/oauth/device/sso-complete /openapi/v1/oauth/device/approval-context /openapi/v1/oauth/device/approve-external sso-complete moves under /oauth/device/ from its previous orphan path /v1/device/sso-complete; the IdP-side ACS callback URL hardcoded in sso_initiate now points to the canonical path. Operators must re-register the ACS callback with each IdP before Phase F deletes the legacy alias. oauth_device_sso.py shrinks to a thin re-mount file: same legacy bp with attach_anti_framing applied, four bp.add_url_rule() calls binding the legacy paths to the imported view functions. Same handler runs for both mounts — no duplicated logic. attach_anti_framing(openapi_bp) added in controllers/openapi/__init__.py so X-Frame-Options + frame-ancestors CSP cover the canonical paths too. Plan: docs/superpowers/plans/2026-04-26-openapi-migration.md (in difyctl repo). --- api/controllers/oauth_device_sso.py | 296 +++--------------- api/controllers/openapi/__init__.py | 10 + .../openapi/oauth_device/approval_context.py | 46 +++ .../openapi/oauth_device/approve_external.py | 141 +++++++++ .../openapi/oauth_device/sso_complete.py | 69 ++++ .../openapi/oauth_device/sso_initiate.py | 83 +++++ .../controllers/openapi/test_device_sso.py | 120 +++++++ 7 files changed, 509 insertions(+), 256 deletions(-) create mode 100644 api/controllers/openapi/oauth_device/approval_context.py create mode 100644 api/controllers/openapi/oauth_device/approve_external.py create mode 100644 api/controllers/openapi/oauth_device/sso_complete.py create mode 100644 api/controllers/openapi/oauth_device/sso_initiate.py create mode 100644 api/tests/unit_tests/controllers/openapi/test_device_sso.py diff --git a/api/controllers/oauth_device_sso.py b/api/controllers/oauth_device_sso.py index 6f6555ac8f..37dfb4579b 100644 --- a/api/controllers/oauth_device_sso.py +++ b/api/controllers/oauth_device_sso.py @@ -1,264 +1,48 @@ -"""SSO-branch device-flow endpoints. Browser hits sso-initiate → API -signs an SSOState envelope → Enterprise inner-API returns IdP authorize -URL → 302. IdP → Enterprise ACS → DeviceFlowDispatcher mints a signed -external-subject assertion → 302 to /v1/device/sso-complete → API mints -the approval-grant cookie → /device → user clicks Approve → /approve- -external mints the OAuth token. All four endpoints are EE-only. +"""Legacy /v1/* mounts for SSO-branch device-flow endpoints. Canonical +handlers live in controllers/openapi/oauth_device/. This file just +re-registers them on the legacy blueprint until Phase F retires the +legacy paths entirely. + +Note: /v1/device/sso-complete (no /oauth/ in the path) is the existing +ACS callback. Its canonical home is /openapi/v1/oauth/device/sso-complete. +IdP-side ACS callback URLs need re-registration before Phase F. """ from __future__ import annotations -import logging -import secrets - -from extensions.ext_database import db -from extensions.ext_redis import redis_client -from flask import Blueprint, jsonify, make_response, redirect, request -from libs import jws -from libs.oauth_bearer import SubjectType -from libs.rate_limit import ( - LIMIT_APPROVE_EXT_PER_EMAIL, - LIMIT_SSO_INITIATE_PER_IP, - enforce, - rate_limit, -) -from libs.device_flow_security import (APPROVAL_GRANT_COOKIE_NAME, ApprovalGrantClaims, - approval_grant_cleared_cookie_kwargs, - approval_grant_cookie_kwargs, - attach_anti_framing, - consume_approval_grant_nonce, - consume_sso_assertion_nonce, - enterprise_only, mint_approval_grant, - verify_approval_grant) -from services.enterprise.enterprise_service import EnterpriseService -from services.oauth_device_flow import (PREFIX_OAUTH_EXTERNAL_SSO, - DeviceFlowRedis, DeviceFlowStatus, - InvalidTransition, StateNotFound, - mint_oauth_token, oauth_ttl_days) -from werkzeug.exceptions import (BadGateway, BadRequest, Conflict, Forbidden, - NotFound, Unauthorized) - -logger = logging.getLogger(__name__) +from flask import Blueprint +from controllers.openapi.oauth_device.approval_context import approval_context +from controllers.openapi.oauth_device.approve_external import approve_external +from controllers.openapi.oauth_device.sso_complete import sso_complete +from controllers.openapi.oauth_device.sso_initiate import sso_initiate +from libs.device_flow_security import attach_anti_framing bp = Blueprint("oauth_device_sso", __name__, url_prefix="/v1") attach_anti_framing(bp) - -# Matches DEVICE_FLOW_TTL_SECONDS so the signed state can't outlive the -# device_code it references. -STATE_ENVELOPE_TTL_SECONDS = 15 * 60 - - -@bp.route("/oauth/device/sso-initiate", methods=["GET"]) -@enterprise_only -@rate_limit(LIMIT_SSO_INITIATE_PER_IP) -def sso_initiate(): - user_code = (request.args.get("user_code") or "").strip().upper() - if not user_code: - raise BadRequest("user_code required") - - store = DeviceFlowRedis(redis_client) - found = store.load_by_user_code(user_code) - if found is None: - raise BadRequest("invalid_user_code") - _, state = found - if state.status is not DeviceFlowStatus.PENDING: - raise BadRequest("invalid_user_code") - - keyset = jws.KeySet.from_shared_secret() - signed_state = jws.sign( - keyset, - payload={ - "redirect_url": "", - "app_code": "", - "intent": "device_flow", - "user_code": user_code, - "nonce": secrets.token_urlsafe(16), - "return_to": "", - "idp_callback_url": f"{request.host_url.rstrip('/')}/v1/device/sso-complete", - }, - aud=jws.AUD_STATE_ENVELOPE, - ttl_seconds=STATE_ENVELOPE_TTL_SECONDS, - ) - - try: - reply = EnterpriseService.initiate_device_flow_sso(signed_state) - except Exception as e: - logger.warning("sso-initiate: enterprise call failed: %s", e) - raise BadGateway("sso_initiate_failed") from e - - url = (reply or {}).get("url") - if not url: - raise BadGateway("sso_initiate_missing_url") - - # Clear stale approval-grant — defends against cross-tab/back-button mixing. - resp = redirect(url, code=302) - resp.set_cookie(**approval_grant_cleared_cookie_kwargs()) - return resp - - -@bp.route("/device/sso-complete", methods=["GET"]) -@enterprise_only -def sso_complete(): - blob = request.args.get("sso_assertion") - if not blob: - raise BadRequest("sso_assertion required") - - keyset = jws.KeySet.from_shared_secret() - - try: - claims = jws.verify(keyset, blob, expected_aud=jws.AUD_EXT_SUBJECT_ASSERTION) - except jws.VerifyError as e: - logger.warning("sso-complete: rejected assertion: %s", e) - raise BadRequest("invalid_sso_assertion") from e - - if not consume_sso_assertion_nonce(redis_client, claims.get("nonce", "")): - raise BadRequest("invalid_sso_assertion") - - user_code = (claims.get("user_code") or "").strip().upper() - store = DeviceFlowRedis(redis_client) - found = store.load_by_user_code(user_code) - if found is None: - raise Conflict("user_code_not_pending") - _, state = found - if state.status is not DeviceFlowStatus.PENDING: - raise Conflict("user_code_not_pending") - - iss = request.host_url.rstrip("/") - cookie_value, _ = mint_approval_grant( - keyset=keyset, - iss=iss, - subject_email=claims["email"], - subject_issuer=claims["issuer"], - user_code=user_code, - ) - - resp = redirect("/device?sso_verified=1", code=302) - resp.set_cookie(**approval_grant_cookie_kwargs(cookie_value)) - return resp - - -@bp.route("/oauth/device/approval-context", methods=["GET"]) -@enterprise_only -def approval_context(): - token = request.cookies.get(APPROVAL_GRANT_COOKIE_NAME) - if not token: - raise Unauthorized("no_session") - - keyset = jws.KeySet.from_shared_secret() - try: - claims = verify_approval_grant(keyset, token) - except jws.VerifyError as e: - logger.warning("approval-context: bad cookie: %s", e) - raise Unauthorized("no_session") from e - - return jsonify({ - "subject_email": claims.subject_email, - "subject_issuer": claims.subject_issuer, - "user_code": claims.user_code, - "csrf_token": claims.csrf_token, - "expires_at": claims.expires_at.isoformat(), - }), 200 - - - -@bp.route("/oauth/device/approve-external", methods=["POST"]) -@enterprise_only -def approve_external(): - token = request.cookies.get(APPROVAL_GRANT_COOKIE_NAME) - if not token: - raise Unauthorized("invalid_session") - - keyset = jws.KeySet.from_shared_secret() - try: - claims: ApprovalGrantClaims = verify_approval_grant(keyset, token) - except jws.VerifyError as e: - logger.warning("approve-external: bad cookie: %s", e) - raise Unauthorized("invalid_session") from e - - enforce(LIMIT_APPROVE_EXT_PER_EMAIL, key=f"subject:{claims.subject_email}") - - csrf_header = request.headers.get("X-CSRF-Token", "") - if not csrf_header or csrf_header != claims.csrf_token: - raise Forbidden("csrf_mismatch") - - data = request.get_json(silent=True) or {} - body_user_code = (data.get("user_code") or "").strip().upper() - if body_user_code != claims.user_code: - raise BadRequest("user_code_mismatch") - - store = DeviceFlowRedis(redis_client) - found = store.load_by_user_code(claims.user_code) - if found is None: - raise NotFound("user_code_not_pending") - device_code, state = found - if state.status is not DeviceFlowStatus.PENDING: - raise Conflict("user_code_not_pending") - - if not consume_approval_grant_nonce(redis_client, claims.nonce): - raise Unauthorized("session_already_consumed") - - ttl_days = oauth_ttl_days(tenant_id=None) - mint = mint_oauth_token( - db.session, - redis_client, - subject_email=claims.subject_email, - subject_issuer=claims.subject_issuer, - account_id=None, - client_id=state.client_id, - device_label=state.device_label, - prefix=PREFIX_OAUTH_EXTERNAL_SSO, - ttl_days=ttl_days, - ) - - poll_payload = { - "token": mint.token, - "expires_at": mint.expires_at.isoformat(), - "subject_type": SubjectType.EXTERNAL_SSO, - "subject_email": claims.subject_email, - "subject_issuer": claims.subject_issuer, - "account": None, - "workspaces": [], - "default_workspace_id": None, - "token_id": str(mint.token_id), - } - - try: - store.approve( - device_code, - subject_email=claims.subject_email, - account_id=None, - subject_issuer=claims.subject_issuer, - minted_token=mint.token, - token_id=str(mint.token_id), - poll_payload=poll_payload, - ) - except (StateNotFound, InvalidTransition) as e: - logger.error("approve-external: state transition raced: %s", e) - raise Conflict("state_lost") from e - - _emit_approve_external_audit(state, claims, mint) - - resp = make_response(jsonify({"status": "approved"}), 200) - resp.set_cookie(**approval_grant_cleared_cookie_kwargs()) - return resp - - -def _emit_approve_external_audit(state, claims, mint) -> None: - logger.warning( - "audit: oauth.device_flow_approved subject_type=%s " - "subject_email=%s subject_issuer=%s token_id=%s", - SubjectType.EXTERNAL_SSO, claims.subject_email, claims.subject_issuer, mint.token_id, - extra={ - "audit": True, - "event": "oauth.device_flow_approved", - "subject_type": SubjectType.EXTERNAL_SSO, - "subject_email": claims.subject_email, - "subject_issuer": claims.subject_issuer, - "token_id": str(mint.token_id), - "client_id": state.client_id, - "device_label": state.device_label, - "scopes": ["apps:run"], - "expires_at": mint.expires_at.isoformat(), - }, - ) +# Legacy /v1/* mounts — handlers live in controllers/openapi/oauth_device/. +# Removed in Phase F. +bp.add_url_rule( + "/oauth/device/sso-initiate", + endpoint="sso_initiate", + view_func=sso_initiate, + methods=["GET"], +) +bp.add_url_rule( + "/device/sso-complete", + endpoint="sso_complete", + view_func=sso_complete, + methods=["GET"], +) +bp.add_url_rule( + "/oauth/device/approval-context", + endpoint="approval_context", + view_func=approval_context, + methods=["GET"], +) +bp.add_url_rule( + "/oauth/device/approve-external", + endpoint="approve_external", + view_func=approve_external, + methods=["POST"], +) diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index 1b169028fa..05618902f6 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -1,9 +1,11 @@ from flask import Blueprint from flask_restx import Namespace +from libs.device_flow_security import attach_anti_framing from libs.external_api import ExternalApi bp = Blueprint("openapi", __name__, url_prefix="/openapi/v1") +attach_anti_framing(bp) api = ExternalApi( bp, @@ -15,19 +17,27 @@ api = ExternalApi( openapi_ns = Namespace("openapi", description="User-scoped operations", path="/") from . import account, index +from .oauth_device import approval_context as oauth_device_approval_context from .oauth_device import approve as oauth_device_approve +from .oauth_device import approve_external as oauth_device_approve_external from .oauth_device import code as oauth_device_code from .oauth_device import deny as oauth_device_deny from .oauth_device import lookup as oauth_device_lookup +from .oauth_device import sso_complete as oauth_device_sso_complete +from .oauth_device import sso_initiate as oauth_device_sso_initiate from .oauth_device import token as oauth_device_token __all__ = [ "account", "index", + "oauth_device_approval_context", "oauth_device_approve", + "oauth_device_approve_external", "oauth_device_code", "oauth_device_deny", "oauth_device_lookup", + "oauth_device_sso_complete", + "oauth_device_sso_initiate", "oauth_device_token", ] diff --git a/api/controllers/openapi/oauth_device/approval_context.py b/api/controllers/openapi/oauth_device/approval_context.py new file mode 100644 index 0000000000..3de6bdc221 --- /dev/null +++ b/api/controllers/openapi/oauth_device/approval_context.py @@ -0,0 +1,46 @@ +"""GET /openapi/v1/oauth/device/approval-context — EE-only. SPA reads +the device_approval_grant cookie claims (subject email/issuer, csrf +token, user_code, expiry). Idempotent — does not consume the nonce. + +Also registered on the legacy /v1/oauth/device/approval-context path +from controllers/oauth_device_sso.py until Phase F retires that mount. +""" +from __future__ import annotations + +import logging + +from flask import jsonify, request +from werkzeug.exceptions import Unauthorized + +from controllers.openapi import bp +from libs import jws +from libs.device_flow_security import ( + APPROVAL_GRANT_COOKIE_NAME, + enterprise_only, + verify_approval_grant, +) + +logger = logging.getLogger(__name__) + + +@bp.route("/oauth/device/approval-context", methods=["GET"]) +@enterprise_only +def approval_context(): + token = request.cookies.get(APPROVAL_GRANT_COOKIE_NAME) + if not token: + raise Unauthorized("no_session") + + keyset = jws.KeySet.from_shared_secret() + try: + claims = verify_approval_grant(keyset, token) + except jws.VerifyError as e: + logger.warning("approval-context: bad cookie: %s", e) + raise Unauthorized("no_session") from e + + return jsonify({ + "subject_email": claims.subject_email, + "subject_issuer": claims.subject_issuer, + "user_code": claims.user_code, + "csrf_token": claims.csrf_token, + "expires_at": claims.expires_at.isoformat(), + }), 200 diff --git a/api/controllers/openapi/oauth_device/approve_external.py b/api/controllers/openapi/oauth_device/approve_external.py new file mode 100644 index 0000000000..fb1b214105 --- /dev/null +++ b/api/controllers/openapi/oauth_device/approve_external.py @@ -0,0 +1,141 @@ +"""POST /openapi/v1/oauth/device/approve-external — EE-only. User +clicks Approve in the SPA after federated SSO; cookie + CSRF gate +the request, then we mint a dfoe_ token and approve the device flow. + +Also registered on the legacy /v1/oauth/device/approve-external path +from controllers/oauth_device_sso.py until Phase F retires that mount. +""" +from __future__ import annotations + +import logging + +from flask import jsonify, make_response, request +from werkzeug.exceptions import BadRequest, Conflict, Forbidden, NotFound, Unauthorized + +from controllers.openapi import bp +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from libs import jws +from libs.device_flow_security import ( + APPROVAL_GRANT_COOKIE_NAME, + ApprovalGrantClaims, + approval_grant_cleared_cookie_kwargs, + consume_approval_grant_nonce, + enterprise_only, + verify_approval_grant, +) +from libs.oauth_bearer import SubjectType +from libs.rate_limit import LIMIT_APPROVE_EXT_PER_EMAIL, enforce +from services.oauth_device_flow import ( + PREFIX_OAUTH_EXTERNAL_SSO, + DeviceFlowRedis, + DeviceFlowStatus, + InvalidTransition, + StateNotFound, + mint_oauth_token, + oauth_ttl_days, +) + +logger = logging.getLogger(__name__) + + +@bp.route("/oauth/device/approve-external", methods=["POST"]) +@enterprise_only +def approve_external(): + token = request.cookies.get(APPROVAL_GRANT_COOKIE_NAME) + if not token: + raise Unauthorized("invalid_session") + + keyset = jws.KeySet.from_shared_secret() + try: + claims: ApprovalGrantClaims = verify_approval_grant(keyset, token) + except jws.VerifyError as e: + logger.warning("approve-external: bad cookie: %s", e) + raise Unauthorized("invalid_session") from e + + enforce(LIMIT_APPROVE_EXT_PER_EMAIL, key=f"subject:{claims.subject_email}") + + csrf_header = request.headers.get("X-CSRF-Token", "") + if not csrf_header or csrf_header != claims.csrf_token: + raise Forbidden("csrf_mismatch") + + data = request.get_json(silent=True) or {} + body_user_code = (data.get("user_code") or "").strip().upper() + if body_user_code != claims.user_code: + raise BadRequest("user_code_mismatch") + + store = DeviceFlowRedis(redis_client) + found = store.load_by_user_code(claims.user_code) + if found is None: + raise NotFound("user_code_not_pending") + device_code, state = found + if state.status is not DeviceFlowStatus.PENDING: + raise Conflict("user_code_not_pending") + + if not consume_approval_grant_nonce(redis_client, claims.nonce): + raise Unauthorized("session_already_consumed") + + ttl_days = oauth_ttl_days(tenant_id=None) + mint = mint_oauth_token( + db.session, + redis_client, + subject_email=claims.subject_email, + subject_issuer=claims.subject_issuer, + account_id=None, + client_id=state.client_id, + device_label=state.device_label, + prefix=PREFIX_OAUTH_EXTERNAL_SSO, + ttl_days=ttl_days, + ) + + poll_payload = { + "token": mint.token, + "expires_at": mint.expires_at.isoformat(), + "subject_type": SubjectType.EXTERNAL_SSO, + "subject_email": claims.subject_email, + "subject_issuer": claims.subject_issuer, + "account": None, + "workspaces": [], + "default_workspace_id": None, + "token_id": str(mint.token_id), + } + + try: + store.approve( + device_code, + subject_email=claims.subject_email, + account_id=None, + subject_issuer=claims.subject_issuer, + minted_token=mint.token, + token_id=str(mint.token_id), + poll_payload=poll_payload, + ) + except (StateNotFound, InvalidTransition) as e: + logger.error("approve-external: state transition raced: %s", e) + raise Conflict("state_lost") from e + + _emit_approve_external_audit(state, claims, mint) + + resp = make_response(jsonify({"status": "approved"}), 200) + resp.set_cookie(**approval_grant_cleared_cookie_kwargs()) + return resp + + +def _emit_approve_external_audit(state, claims, mint) -> None: + logger.warning( + "audit: oauth.device_flow_approved subject_type=%s " + "subject_email=%s subject_issuer=%s token_id=%s", + SubjectType.EXTERNAL_SSO, claims.subject_email, claims.subject_issuer, mint.token_id, + extra={ + "audit": True, + "event": "oauth.device_flow_approved", + "subject_type": SubjectType.EXTERNAL_SSO, + "subject_email": claims.subject_email, + "subject_issuer": claims.subject_issuer, + "token_id": str(mint.token_id), + "client_id": state.client_id, + "device_label": state.device_label, + "scopes": ["apps:run"], + "expires_at": mint.expires_at.isoformat(), + }, + ) diff --git a/api/controllers/openapi/oauth_device/sso_complete.py b/api/controllers/openapi/oauth_device/sso_complete.py new file mode 100644 index 0000000000..48a94be28f --- /dev/null +++ b/api/controllers/openapi/oauth_device/sso_complete.py @@ -0,0 +1,69 @@ +"""GET /openapi/v1/oauth/device/sso-complete — EE-only ACS callback. +The IdP redirects here with a signed external-subject assertion; +we verify, mint the approval-grant cookie, and redirect to /device. + +The handler is also registered on the legacy /v1/device/sso-complete +path from controllers/oauth_device_sso.py until Phase F retires that mount. +The legacy path lived under /v1/device/, not /v1/oauth/device/, so +existing IdP ACS configs need re-registration to the canonical path. +""" +from __future__ import annotations + +import logging + +from flask import redirect, request +from werkzeug.exceptions import BadRequest, Conflict + +from controllers.openapi import bp +from extensions.ext_redis import redis_client +from libs import jws +from libs.device_flow_security import ( + approval_grant_cookie_kwargs, + consume_sso_assertion_nonce, + enterprise_only, + mint_approval_grant, +) +from services.oauth_device_flow import DeviceFlowRedis, DeviceFlowStatus + +logger = logging.getLogger(__name__) + + +@bp.route("/oauth/device/sso-complete", methods=["GET"]) +@enterprise_only +def sso_complete(): + blob = request.args.get("sso_assertion") + if not blob: + raise BadRequest("sso_assertion required") + + keyset = jws.KeySet.from_shared_secret() + + try: + claims = jws.verify(keyset, blob, expected_aud=jws.AUD_EXT_SUBJECT_ASSERTION) + except jws.VerifyError as e: + logger.warning("sso-complete: rejected assertion: %s", e) + raise BadRequest("invalid_sso_assertion") from e + + if not consume_sso_assertion_nonce(redis_client, claims.get("nonce", "")): + raise BadRequest("invalid_sso_assertion") + + user_code = (claims.get("user_code") or "").strip().upper() + store = DeviceFlowRedis(redis_client) + found = store.load_by_user_code(user_code) + if found is None: + raise Conflict("user_code_not_pending") + _, state = found + if state.status is not DeviceFlowStatus.PENDING: + raise Conflict("user_code_not_pending") + + iss = request.host_url.rstrip("/") + cookie_value, _ = mint_approval_grant( + keyset=keyset, + iss=iss, + subject_email=claims["email"], + subject_issuer=claims["issuer"], + user_code=user_code, + ) + + resp = redirect("/device?sso_verified=1", code=302) + resp.set_cookie(**approval_grant_cookie_kwargs(cookie_value)) + return resp diff --git a/api/controllers/openapi/oauth_device/sso_initiate.py b/api/controllers/openapi/oauth_device/sso_initiate.py new file mode 100644 index 0000000000..a4d30cce18 --- /dev/null +++ b/api/controllers/openapi/oauth_device/sso_initiate.py @@ -0,0 +1,83 @@ +"""GET /openapi/v1/oauth/device/sso-initiate — EE-only. Browser hits +this with a user_code; we sign an SSOState envelope and call the +Enterprise inner API to get the IdP authorize URL, then 302 to the IdP. + +The handler is also registered on the legacy /v1/oauth/device/sso-initiate +path from controllers/oauth_device_sso.py until Phase F retires that mount. +""" +from __future__ import annotations + +import logging +import secrets + +from flask import redirect, request +from werkzeug.exceptions import BadGateway, BadRequest + +from controllers.openapi import bp +from extensions.ext_redis import redis_client +from libs import jws +from libs.device_flow_security import ( + approval_grant_cleared_cookie_kwargs, + enterprise_only, +) +from libs.rate_limit import LIMIT_SSO_INITIATE_PER_IP, rate_limit +from services.enterprise.enterprise_service import EnterpriseService +from services.oauth_device_flow import DeviceFlowRedis, DeviceFlowStatus + +logger = logging.getLogger(__name__) + + +# Matches DEVICE_FLOW_TTL_SECONDS so the signed state can't outlive the +# device_code it references. +STATE_ENVELOPE_TTL_SECONDS = 15 * 60 + +# Canonical sso-complete path. IdP-side ACS callback URL must point here. +_SSO_COMPLETE_PATH = "/openapi/v1/oauth/device/sso-complete" + + +@bp.route("/oauth/device/sso-initiate", methods=["GET"]) +@enterprise_only +@rate_limit(LIMIT_SSO_INITIATE_PER_IP) +def sso_initiate(): + user_code = (request.args.get("user_code") or "").strip().upper() + if not user_code: + raise BadRequest("user_code required") + + store = DeviceFlowRedis(redis_client) + found = store.load_by_user_code(user_code) + if found is None: + raise BadRequest("invalid_user_code") + _, state = found + if state.status is not DeviceFlowStatus.PENDING: + raise BadRequest("invalid_user_code") + + keyset = jws.KeySet.from_shared_secret() + signed_state = jws.sign( + keyset, + payload={ + "redirect_url": "", + "app_code": "", + "intent": "device_flow", + "user_code": user_code, + "nonce": secrets.token_urlsafe(16), + "return_to": "", + "idp_callback_url": f"{request.host_url.rstrip('/')}{_SSO_COMPLETE_PATH}", + }, + aud=jws.AUD_STATE_ENVELOPE, + ttl_seconds=STATE_ENVELOPE_TTL_SECONDS, + ) + + try: + reply = EnterpriseService.initiate_device_flow_sso(signed_state) + except Exception as e: + logger.warning("sso-initiate: enterprise call failed: %s", e) + raise BadGateway("sso_initiate_failed") from e + + url = (reply or {}).get("url") + if not url: + raise BadGateway("sso_initiate_missing_url") + + # Clear stale approval-grant — defends against cross-tab/back-button mixing. + resp = redirect(url, code=302) + resp.set_cookie(**approval_grant_cleared_cookie_kwargs()) + return resp diff --git a/api/tests/unit_tests/controllers/openapi/test_device_sso.py b/api/tests/unit_tests/controllers/openapi/test_device_sso.py new file mode 100644 index 0000000000..d699be9385 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_device_sso.py @@ -0,0 +1,120 @@ +"""Phase D steps 15-16: SSO-branch device-flow endpoints lifted to +/openapi/v1/oauth/device/. Legacy /v1/* mounts stay via re-registration +in controllers/oauth_device_sso.py. +""" +import builtins + +import pytest +from flask import Flask +from flask.views import MethodView + +from controllers.oauth_device_sso import bp as legacy_sso_bp +from controllers.openapi import bp as openapi_bp +from controllers.openapi.oauth_device.approval_context import approval_context +from controllers.openapi.oauth_device.approve_external import approve_external +from controllers.openapi.oauth_device.sso_complete import sso_complete +from controllers.openapi.oauth_device.sso_initiate import sso_initiate + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +@pytest.fixture +def dual_app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(legacy_sso_bp) + app.register_blueprint(openapi_bp) + return app + + +def _rule(app: Flask, path: str): + return next(r for r in app.url_map.iter_rules() if r.rule == path) + + +# Canonical /openapi/v1/* paths + + +def test_sso_initiate_canonical_path_registered(dual_app: Flask): + rules = {r.rule for r in dual_app.url_map.iter_rules()} + assert "/openapi/v1/oauth/device/sso-initiate" in rules + + +def test_sso_complete_canonical_path_registered(dual_app: Flask): + rules = {r.rule for r in dual_app.url_map.iter_rules()} + assert "/openapi/v1/oauth/device/sso-complete" in rules + + +def test_approval_context_canonical_path_registered(dual_app: Flask): + rules = {r.rule for r in dual_app.url_map.iter_rules()} + assert "/openapi/v1/oauth/device/approval-context" in rules + + +def test_approve_external_canonical_path_registered(dual_app: Flask): + rules = {r.rule for r in dual_app.url_map.iter_rules()} + assert "/openapi/v1/oauth/device/approve-external" in rules + + +# Legacy /v1/* paths + + +def test_sso_initiate_legacy_path_registered(dual_app: Flask): + rules = {r.rule for r in dual_app.url_map.iter_rules()} + assert "/v1/oauth/device/sso-initiate" in rules + + +def test_sso_complete_legacy_path_registered(dual_app: Flask): + """Legacy lived under /v1/device/, not /v1/oauth/device/.""" + rules = {r.rule for r in dual_app.url_map.iter_rules()} + assert "/v1/device/sso-complete" in rules + + +def test_approval_context_legacy_path_registered(dual_app: Flask): + rules = {r.rule for r in dual_app.url_map.iter_rules()} + assert "/v1/oauth/device/approval-context" in rules + + +def test_approve_external_legacy_path_registered(dual_app: Flask): + rules = {r.rule for r in dual_app.url_map.iter_rules()} + assert "/v1/oauth/device/approve-external" in rules + + +# Both paths point at the same view function + + +def test_sso_initiate_dual_mount_same_function(dual_app: Flask): + new_rule = _rule(dual_app, "/openapi/v1/oauth/device/sso-initiate") + legacy_rule = _rule(dual_app, "/v1/oauth/device/sso-initiate") + assert dual_app.view_functions[new_rule.endpoint] is sso_initiate + assert dual_app.view_functions[legacy_rule.endpoint] is sso_initiate + + +def test_sso_complete_dual_mount_same_function(dual_app: Flask): + new_rule = _rule(dual_app, "/openapi/v1/oauth/device/sso-complete") + legacy_rule = _rule(dual_app, "/v1/device/sso-complete") + assert dual_app.view_functions[new_rule.endpoint] is sso_complete + assert dual_app.view_functions[legacy_rule.endpoint] is sso_complete + + +def test_approval_context_dual_mount_same_function(dual_app: Flask): + new_rule = _rule(dual_app, "/openapi/v1/oauth/device/approval-context") + legacy_rule = _rule(dual_app, "/v1/oauth/device/approval-context") + assert dual_app.view_functions[new_rule.endpoint] is approval_context + assert dual_app.view_functions[legacy_rule.endpoint] is approval_context + + +def test_approve_external_dual_mount_same_function(dual_app: Flask): + new_rule = _rule(dual_app, "/openapi/v1/oauth/device/approve-external") + legacy_rule = _rule(dual_app, "/v1/oauth/device/approve-external") + assert dual_app.view_functions[new_rule.endpoint] is approve_external + assert dual_app.view_functions[legacy_rule.endpoint] is approve_external + + +def test_sso_complete_idp_callback_url_uses_canonical_path(): + """sso_initiate hardcodes the IdP callback URL — must point to the + canonical /openapi/v1/ path so IdPs are configured against the + forward-looking ACS endpoint, not the legacy alias. + """ + from controllers.openapi.oauth_device import sso_initiate as si + + assert si._SSO_COMPLETE_PATH == "/openapi/v1/oauth/device/sso-complete"