feat(api): lift SSO branch device-flow handlers to /openapi/v1 (Phase D.15-16)

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).
This commit is contained in:
GareArc
2026-04-27 00:00:24 -07:00
parent 772f450b29
commit 71e9e8dda6
7 changed files with 509 additions and 256 deletions

View File

@ -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",
]

View File

@ -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

View File

@ -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(),
},
)

View File

@ -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

View File

@ -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