mirror of
https://github.com/langgenius/dify.git
synced 2026-05-21 01:07:03 +08:00
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).
70 lines
2.3 KiB
Python
70 lines
2.3 KiB
Python
"""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
|