mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
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:
parent
772f450b29
commit
71e9e8dda6
@ -1,264 +1,48 @@
|
|||||||
"""SSO-branch device-flow endpoints. Browser hits sso-initiate → API
|
"""Legacy /v1/* mounts for SSO-branch device-flow endpoints. Canonical
|
||||||
signs an SSOState envelope → Enterprise inner-API returns IdP authorize
|
handlers live in controllers/openapi/oauth_device/. This file just
|
||||||
URL → 302. IdP → Enterprise ACS → DeviceFlowDispatcher mints a signed
|
re-registers them on the legacy blueprint until Phase F retires the
|
||||||
external-subject assertion → 302 to /v1/device/sso-complete → API mints
|
legacy paths entirely.
|
||||||
the approval-grant cookie → /device → user clicks Approve → /approve-
|
|
||||||
external mints the OAuth token. All four endpoints are EE-only.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
from flask import Blueprint
|
||||||
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 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")
|
bp = Blueprint("oauth_device_sso", __name__, url_prefix="/v1")
|
||||||
attach_anti_framing(bp)
|
attach_anti_framing(bp)
|
||||||
|
|
||||||
|
# Legacy /v1/* mounts — handlers live in controllers/openapi/oauth_device/.
|
||||||
# Matches DEVICE_FLOW_TTL_SECONDS so the signed state can't outlive the
|
# Removed in Phase F.
|
||||||
# device_code it references.
|
bp.add_url_rule(
|
||||||
STATE_ENVELOPE_TTL_SECONDS = 15 * 60
|
"/oauth/device/sso-initiate",
|
||||||
|
endpoint="sso_initiate",
|
||||||
|
view_func=sso_initiate,
|
||||||
@bp.route("/oauth/device/sso-initiate", methods=["GET"])
|
methods=["GET"],
|
||||||
@enterprise_only
|
)
|
||||||
@rate_limit(LIMIT_SSO_INITIATE_PER_IP)
|
bp.add_url_rule(
|
||||||
def sso_initiate():
|
"/device/sso-complete",
|
||||||
user_code = (request.args.get("user_code") or "").strip().upper()
|
endpoint="sso_complete",
|
||||||
if not user_code:
|
view_func=sso_complete,
|
||||||
raise BadRequest("user_code required")
|
methods=["GET"],
|
||||||
|
)
|
||||||
store = DeviceFlowRedis(redis_client)
|
bp.add_url_rule(
|
||||||
found = store.load_by_user_code(user_code)
|
"/oauth/device/approval-context",
|
||||||
if found is None:
|
endpoint="approval_context",
|
||||||
raise BadRequest("invalid_user_code")
|
view_func=approval_context,
|
||||||
_, state = found
|
methods=["GET"],
|
||||||
if state.status is not DeviceFlowStatus.PENDING:
|
)
|
||||||
raise BadRequest("invalid_user_code")
|
bp.add_url_rule(
|
||||||
|
"/oauth/device/approve-external",
|
||||||
keyset = jws.KeySet.from_shared_secret()
|
endpoint="approve_external",
|
||||||
signed_state = jws.sign(
|
view_func=approve_external,
|
||||||
keyset,
|
methods=["POST"],
|
||||||
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(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
from flask_restx import Namespace
|
from flask_restx import Namespace
|
||||||
|
|
||||||
|
from libs.device_flow_security import attach_anti_framing
|
||||||
from libs.external_api import ExternalApi
|
from libs.external_api import ExternalApi
|
||||||
|
|
||||||
bp = Blueprint("openapi", __name__, url_prefix="/openapi/v1")
|
bp = Blueprint("openapi", __name__, url_prefix="/openapi/v1")
|
||||||
|
attach_anti_framing(bp)
|
||||||
|
|
||||||
api = ExternalApi(
|
api = ExternalApi(
|
||||||
bp,
|
bp,
|
||||||
@ -15,19 +17,27 @@ api = ExternalApi(
|
|||||||
openapi_ns = Namespace("openapi", description="User-scoped operations", path="/")
|
openapi_ns = Namespace("openapi", description="User-scoped operations", path="/")
|
||||||
|
|
||||||
from . import account, index
|
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 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 code as oauth_device_code
|
||||||
from .oauth_device import deny as oauth_device_deny
|
from .oauth_device import deny as oauth_device_deny
|
||||||
from .oauth_device import lookup as oauth_device_lookup
|
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
|
from .oauth_device import token as oauth_device_token
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"account",
|
"account",
|
||||||
"index",
|
"index",
|
||||||
|
"oauth_device_approval_context",
|
||||||
"oauth_device_approve",
|
"oauth_device_approve",
|
||||||
|
"oauth_device_approve_external",
|
||||||
"oauth_device_code",
|
"oauth_device_code",
|
||||||
"oauth_device_deny",
|
"oauth_device_deny",
|
||||||
"oauth_device_lookup",
|
"oauth_device_lookup",
|
||||||
|
"oauth_device_sso_complete",
|
||||||
|
"oauth_device_sso_initiate",
|
||||||
"oauth_device_token",
|
"oauth_device_token",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
46
api/controllers/openapi/oauth_device/approval_context.py
Normal file
46
api/controllers/openapi/oauth_device/approval_context.py
Normal 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
|
||||||
141
api/controllers/openapi/oauth_device/approve_external.py
Normal file
141
api/controllers/openapi/oauth_device/approve_external.py
Normal 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(),
|
||||||
|
},
|
||||||
|
)
|
||||||
69
api/controllers/openapi/oauth_device/sso_complete.py
Normal file
69
api/controllers/openapi/oauth_device/sso_complete.py
Normal 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
|
||||||
83
api/controllers/openapi/oauth_device/sso_initiate.py
Normal file
83
api/controllers/openapi/oauth_device/sso_initiate.py
Normal 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
|
||||||
120
api/tests/unit_tests/controllers/openapi/test_device_sso.py
Normal file
120
api/tests/unit_tests/controllers/openapi/test_device_sso.py
Normal file
@ -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"
|
||||||
Loading…
Reference in New Issue
Block a user