Feat(Chore, Fix): Refractor, Half Baked Deletion + Admin Privilege
Refractor Codes into sub file/folders Admin can remove users'/members mfa/2fa, unlink account from oauth provider Admin can add/reset password Different Email (OIDC + Manual)-Same Account; (Block Linking and authorize if available)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""External auth blueprint subpackage."""
|
||||
from gatehouse_app.api.v1.external_auth import cli, providers, oauth, admin
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Shared helpers for external_auth subpackage."""
|
||||
import logging
|
||||
from gatehouse_app.utils.constants import AuthMethodType
|
||||
from gatehouse_app.services.external_auth.models import ExternalAuthError
|
||||
|
||||
_OAUTH_BRIDGE_TTL = 600 # 10 minutes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROVIDER_TYPE_MAP = {
|
||||
"google": AuthMethodType.GOOGLE,
|
||||
"github": AuthMethodType.GITHUB,
|
||||
"microsoft": AuthMethodType.MICROSOFT,
|
||||
}
|
||||
|
||||
|
||||
def get_provider_type(provider: str) -> AuthMethodType:
|
||||
provider_lower = provider.lower()
|
||||
if provider_lower not in PROVIDER_TYPE_MAP:
|
||||
raise ExternalAuthError(f"Unsupported provider: {provider}", "UNSUPPORTED_PROVIDER", 400)
|
||||
return PROVIDER_TYPE_MAP[provider_lower]
|
||||
|
||||
|
||||
def _get_provider_endpoints(provider_type: AuthMethodType):
|
||||
if provider_type == AuthMethodType.GOOGLE:
|
||||
return (
|
||||
"https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"https://oauth2.googleapis.com/token",
|
||||
"https://www.googleapis.com/oauth2/v3/userinfo",
|
||||
)
|
||||
elif provider_type == AuthMethodType.GITHUB:
|
||||
return (
|
||||
"https://github.com/login/oauth/authorize",
|
||||
"https://github.com/login/oauth/access_token",
|
||||
"https://api.github.com/user",
|
||||
)
|
||||
elif provider_type == AuthMethodType.MICROSOFT:
|
||||
return (
|
||||
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
"https://graph.microsoft.com/oidc/userinfo",
|
||||
)
|
||||
else:
|
||||
raise ExternalAuthError(f"Unsupported provider: {provider_type}", "UNSUPPORTED_PROVIDER", 400)
|
||||
|
||||
|
||||
def _store_oidc_bridge(oauth_state: str, oidc_session_id: str) -> None:
|
||||
try:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
rc.setex(f"oauth_oidc_bridge:{oauth_state}", _OAUTH_BRIDGE_TTL, oidc_session_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _pop_oidc_bridge(oauth_state: str) -> str | None:
|
||||
try:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
key = f"oauth_oidc_bridge:{oauth_state}"
|
||||
val = rc.get(key)
|
||||
if val:
|
||||
rc.delete(key)
|
||||
return val.decode() if isinstance(val, bytes) else val
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _store_cli_redirect(oauth_state: str, redirect_url: str) -> None:
|
||||
try:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
rc.setex(f"oauth_cli_redirect:{oauth_state}", _OAUTH_BRIDGE_TTL, redirect_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _pop_cli_redirect(oauth_state: str) -> str | None:
|
||||
try:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
key = f"oauth_cli_redirect:{oauth_state}"
|
||||
val = rc.get(key)
|
||||
if val:
|
||||
rc.delete(key)
|
||||
return val.decode() if isinstance(val, bytes) else val
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Admin application-level OAuth provider management."""
|
||||
from flask import g, request
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/oauth/providers", methods=["GET"])
|
||||
@login_required
|
||||
def admin_list_app_providers():
|
||||
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
admin_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == g.current_user.id,
|
||||
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
|
||||
).all()
|
||||
|
||||
if not admin_memberships:
|
||||
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
|
||||
|
||||
PROVIDERS = [{"id": "google", "name": "Google"}, {"id": "github", "name": "GitHub"}, {"id": "microsoft", "name": "Microsoft"}]
|
||||
db_configs = {c.provider_type: c for c in ApplicationProviderConfig.query.all()}
|
||||
|
||||
result = []
|
||||
for p in PROVIDERS:
|
||||
cfg = db_configs.get(p["id"])
|
||||
result.append({
|
||||
"id": p["id"], "name": p["name"],
|
||||
"is_configured": cfg is not None,
|
||||
"is_enabled": cfg.is_enabled if cfg else False,
|
||||
"client_id": cfg.client_id if cfg else None,
|
||||
})
|
||||
|
||||
return api_response(data={"providers": result}, message="OAuth providers retrieved successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/oauth/providers/<provider>", methods=["PUT"])
|
||||
@login_required
|
||||
def admin_configure_app_provider(provider: str):
|
||||
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
SUPPORTED = ["google", "github", "microsoft"]
|
||||
if provider not in SUPPORTED:
|
||||
return api_response(success=False, message=f"Unsupported provider. Must be one of: {', '.join(SUPPORTED)}", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
admin_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == g.current_user.id,
|
||||
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
|
||||
).all()
|
||||
|
||||
if not admin_memberships:
|
||||
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
|
||||
|
||||
data = request.json or {}
|
||||
client_id = (data.get("client_id") or "").strip()
|
||||
client_secret = (data.get("client_secret") or "").strip()
|
||||
is_enabled = data.get("is_enabled", True)
|
||||
|
||||
if not client_id:
|
||||
return api_response(success=False, message="client_id is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider).first()
|
||||
if cfg:
|
||||
cfg.client_id = client_id
|
||||
if client_secret:
|
||||
cfg.set_client_secret(client_secret)
|
||||
cfg.is_enabled = bool(is_enabled)
|
||||
db.session.commit()
|
||||
else:
|
||||
cfg = ApplicationProviderConfig(provider_type=provider, client_id=client_id, is_enabled=bool(is_enabled))
|
||||
if client_secret:
|
||||
cfg.set_client_secret(client_secret)
|
||||
db.session.add(cfg)
|
||||
db.session.commit()
|
||||
|
||||
return api_response(
|
||||
data={"provider": {"id": provider, "client_id": cfg.client_id, "is_enabled": cfg.is_enabled}},
|
||||
message=f"{provider.capitalize()} OAuth provider configured successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/oauth/providers/<provider>", methods=["DELETE"])
|
||||
@login_required
|
||||
def admin_delete_app_provider(provider: str):
|
||||
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
admin_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == g.current_user.id,
|
||||
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
|
||||
).all()
|
||||
|
||||
if not admin_memberships:
|
||||
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
|
||||
|
||||
cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider).first()
|
||||
if not cfg:
|
||||
return api_response(success=False, message=f"Provider '{provider}' is not configured", status=404, error_type="NOT_FOUND")
|
||||
|
||||
db.session.delete(cfg)
|
||||
db.session.commit()
|
||||
return api_response(message=f"{provider.capitalize()} OAuth provider configuration removed")
|
||||
@@ -0,0 +1,68 @@
|
||||
"""CLI token acquisition endpoints."""
|
||||
import secrets
|
||||
import logging
|
||||
from urllib.parse import quote
|
||||
from flask import request, current_app, redirect as flask_redirect
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.api.v1.external_auth._helpers import _OAUTH_BRIDGE_TTL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_v1_bp.route("/token_please", methods=["GET"])
|
||||
def token_please():
|
||||
redirect_url = request.args.get("redirect_url", "").strip()
|
||||
|
||||
if not redirect_url:
|
||||
return api_response(success=False, message="redirect_url query parameter is required", status=400, error_type="MISSING_REDIRECT_URL")
|
||||
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
parsed = _urlparse(redirect_url)
|
||||
if parsed.hostname not in ("localhost", "127.0.0.1"):
|
||||
return api_response(success=False, message="redirect_url must point to localhost", status=400, error_type="INVALID_REDIRECT_URL")
|
||||
|
||||
cli_token = secrets.token_urlsafe(32)
|
||||
try:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
rc.setex(f"cli_redirect:{cli_token}", _OAUTH_BRIDGE_TTL, redirect_url)
|
||||
else:
|
||||
logger.warning("Redis not available; passing cli_redirect directly in URL")
|
||||
cli_token = None
|
||||
except Exception:
|
||||
cli_token = None
|
||||
|
||||
frontend_url = current_app.config.get("FRONTEND_URL", "http://localhost:8080")
|
||||
|
||||
if cli_token:
|
||||
login_url = f"{frontend_url}/login?cli_token={cli_token}"
|
||||
else:
|
||||
login_url = f"{frontend_url}/login?cli_redirect={quote(redirect_url, safe='')}"
|
||||
|
||||
logger.info("CLI token_please: redirecting browser to Gatehouse login page")
|
||||
return flask_redirect(login_url, code=302)
|
||||
|
||||
|
||||
@api_v1_bp.route("/cli/redirect-url", methods=["GET"])
|
||||
def cli_redirect_url_lookup():
|
||||
cli_token = request.args.get("token", "").strip()
|
||||
if not cli_token:
|
||||
return api_response(success=False, message="token query parameter is required", status=400, error_type="MISSING_TOKEN")
|
||||
|
||||
try:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
key = f"cli_redirect:{cli_token}"
|
||||
val = rc.get(key)
|
||||
if val is None:
|
||||
return api_response(success=False, message="CLI token not found or expired", status=404, error_type="TOKEN_NOT_FOUND")
|
||||
redirect_url = val.decode() if isinstance(val, bytes) else val
|
||||
return api_response(data={"redirect_url": redirect_url})
|
||||
except Exception as e:
|
||||
logger.error(f"cli_redirect_url_lookup error: {e}")
|
||||
return api_response(success=False, message="Internal error looking up CLI token", status=500, error_type="INTERNAL_ERROR")
|
||||
|
||||
return api_response(success=False, message="Redis not available", status=503, error_type="SERVICE_UNAVAILABLE")
|
||||
@@ -0,0 +1,244 @@
|
||||
"""OAuth authorization and callback endpoints."""
|
||||
import json
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
from flask import request, current_app, redirect as flask_redirect
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.services.external_auth.models import ExternalAuthError
|
||||
from gatehouse_app.services.oauth_flow import OAuthFlowService, OAuthFlowError
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
from gatehouse_app.api.v1.external_auth._helpers import (
|
||||
get_provider_type, _store_oidc_bridge, _pop_oidc_bridge, _pop_cli_redirect,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/<provider>/authorize", methods=["GET"])
|
||||
def initiate_oauth_authorize(provider: str):
|
||||
flow = request.args.get("flow", "login")
|
||||
redirect_uri = request.args.get("redirect_uri")
|
||||
organization_id = request.args.get("organization_id")
|
||||
oidc_session_id = request.args.get("oidc_session_id")
|
||||
|
||||
if flow not in ["login", "register"]:
|
||||
return api_response(success=False, message="Invalid flow type. Must be 'login' or 'register'", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
try:
|
||||
provider_type = get_provider_type(provider)
|
||||
if flow == "login":
|
||||
auth_url, state = OAuthFlowService.initiate_login_flow(
|
||||
provider_type=provider_type, organization_id=organization_id, redirect_uri=redirect_uri,
|
||||
)
|
||||
else:
|
||||
auth_url, state = OAuthFlowService.initiate_register_flow(
|
||||
provider_type=provider_type, organization_id=organization_id, redirect_uri=redirect_uri,
|
||||
)
|
||||
|
||||
if oidc_session_id:
|
||||
_store_oidc_bridge(state, oidc_session_id)
|
||||
|
||||
return api_response(data={"authorization_url": auth_url, "state": state}, message=f"OAuth {flow} flow initiated")
|
||||
|
||||
except OAuthFlowError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
except ExternalAuthError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/<provider>/callback", methods=["GET"])
|
||||
def handle_oauth_callback(provider: str):
|
||||
provider_type = get_provider_type(provider)
|
||||
|
||||
state = request.args.get("state")
|
||||
authorization_code = request.args.get("code")
|
||||
error = request.args.get("error")
|
||||
error_description = request.args.get("error_description")
|
||||
|
||||
frontend_url = current_app.config.get("FRONTEND_URL", "http://localhost:8080")
|
||||
frontend_callback = f"{frontend_url}/oauth/callback"
|
||||
|
||||
cli_redirect_url = _pop_cli_redirect(state) if state else None
|
||||
|
||||
def redirect_error(message: str, error_type: str = "OAUTH_ERROR"):
|
||||
if cli_redirect_url:
|
||||
from flask import make_response
|
||||
return make_response(
|
||||
f"<html><body><h2>Authentication Error</h2><p>{message}</p>"
|
||||
f"<p>You may close this window.</p></body></html>", 400,
|
||||
)
|
||||
params = {"error": message, "error_type": error_type}
|
||||
if state:
|
||||
params["state"] = state
|
||||
return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302)
|
||||
|
||||
if error:
|
||||
msg = error_description or f"Authorization failed: {error}"
|
||||
return redirect_error(msg, error.upper())
|
||||
|
||||
if not authorization_code or not state:
|
||||
return redirect_error("Missing authorization code or state parameter.")
|
||||
|
||||
try:
|
||||
result = OAuthFlowService.handle_callback(
|
||||
provider_type=provider_type,
|
||||
authorization_code=authorization_code,
|
||||
state=state,
|
||||
redirect_uri=None,
|
||||
error=None,
|
||||
error_description=None,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return redirect_error("Authentication failed.", "AUTH_FAILED")
|
||||
|
||||
flow_type = result.get("flow_type", "login")
|
||||
|
||||
if flow_type == "link":
|
||||
params = {"flow": "link", "provider": provider, "linked": "1"}
|
||||
return flask_redirect(f"{frontend_url}/linked-accounts?{urlencode(params)}", code=302)
|
||||
|
||||
oidc_session_id = _pop_oidc_bridge(state)
|
||||
|
||||
if result.get("requires_org_selection") and not cli_redirect_url:
|
||||
orgs = json.dumps(result.get("available_organizations", []))
|
||||
params = {"requires_org_selection": "1", "state": result["state"], "provider": provider, "flow": flow_type, "orgs": orgs}
|
||||
if oidc_session_id:
|
||||
params["oidc_session_id"] = oidc_session_id
|
||||
return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302)
|
||||
|
||||
if result.get("requires_org_creation") and not cli_redirect_url:
|
||||
import json as _json
|
||||
session_data = result.get("session", {})
|
||||
token = session_data.get("token", "")
|
||||
expires_in = session_data.get("expires_in", 86400)
|
||||
pending_invites = result.get("pending_invites", [])
|
||||
params = {
|
||||
"requires_org_creation": "1", "state": result["state"], "provider": provider,
|
||||
"flow": flow_type, "token": token, "expires_in": str(expires_in),
|
||||
"pending_invites": _json.dumps(pending_invites),
|
||||
}
|
||||
if oidc_session_id:
|
||||
params["oidc_session_id"] = oidc_session_id
|
||||
return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302)
|
||||
|
||||
session_data = result.get("session", {})
|
||||
token = session_data.get("token")
|
||||
expires_in = session_data.get("expires_in", 86400)
|
||||
|
||||
if not token:
|
||||
return redirect_error("No session token returned by server.", "NO_TOKEN")
|
||||
|
||||
params = {"token": token, "expires_in": str(expires_in), "flow": flow_type, "provider": provider, "state": state}
|
||||
user_info = result.get("user", {})
|
||||
if user_info.get("email"):
|
||||
params["email"] = user_info["email"]
|
||||
|
||||
if cli_redirect_url:
|
||||
cli_final_url = cli_redirect_url + token
|
||||
logger.info(f"CLI token_please success: provider={provider}, user={user_info.get('email')}, redirecting to CLI callback")
|
||||
return flask_redirect(cli_final_url, code=302)
|
||||
|
||||
if oidc_session_id:
|
||||
params["oidc_session_id"] = oidc_session_id
|
||||
|
||||
logger.info(f"OAuth callback success: provider={provider}, flow={flow_type}, user={user_info.get('email')}, redirecting to frontend")
|
||||
return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302)
|
||||
|
||||
except OAuthFlowError as e:
|
||||
logger.warning(f"OAuth callback OAuthFlowError: {e.message}")
|
||||
return redirect_error(e.message, e.error_type)
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth callback unexpected error: {str(e)}", exc_info=True)
|
||||
return redirect_error("An unexpected error occurred. Please try again.", "INTERNAL_ERROR")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/select-organization", methods=["POST"])
|
||||
def select_organization():
|
||||
from gatehouse_app.utils.constants import AuthMethodType as _AuthMethodType
|
||||
from gatehouse_app.models import User, AuthenticationMethod, Organization, OrganizationMember
|
||||
|
||||
data = request.json or {}
|
||||
state_token = data.get("state")
|
||||
organization_id = data.get("organization_id")
|
||||
|
||||
if not state_token:
|
||||
return api_response(success=False, message="state is required", status=400, error_type="VALIDATION_ERROR")
|
||||
if not organization_id:
|
||||
return api_response(success=False, message="organization_id is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
try:
|
||||
state_record = OAuthFlowService.validate_state(state_token)
|
||||
if not state_record or state_record.used:
|
||||
return api_response(success=False, message="Invalid or expired state token", status=400, error_type="INVALID_STATE")
|
||||
|
||||
auth_method = AuthenticationMethod.query.filter_by(
|
||||
method_type=state_record.provider_type,
|
||||
).order_by(AuthenticationMethod.created_at.desc()).first()
|
||||
|
||||
if not auth_method:
|
||||
return api_response(success=False, message="Authentication session not found", status=400, error_type="SESSION_NOT_FOUND")
|
||||
|
||||
user = auth_method.user
|
||||
|
||||
org = Organization.query.get(organization_id)
|
||||
if not org:
|
||||
return api_response(success=False, message="Organization not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
member = OrganizationMember.query.filter_by(user_id=user.id, organization_id=organization_id).first()
|
||||
if not member:
|
||||
return api_response(success=False, message="You are not a member of this organization", status=403, error_type="FORBIDDEN")
|
||||
|
||||
from gatehouse_app.services.session_service import SessionService
|
||||
session = SessionService.create_session(user=user, organization_id=organization_id)
|
||||
state_record.mark_used()
|
||||
|
||||
provider_type_val = state_record.provider_type.value if isinstance(state_record.provider_type, _AuthMethodType) else state_record.provider_type
|
||||
AuditService.log_external_auth_login(
|
||||
user_id=user.id, organization_id=organization_id, provider_type=provider_type_val,
|
||||
provider_user_id=auth_method.provider_user_id,
|
||||
auth_method_id=auth_method.id, session_id=session.id,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"token": session.token, "expires_in": session.lifetime_seconds, "token_type": "Bearer",
|
||||
"user": {"id": user.id, "email": user.email, "full_name": user.full_name, "organization_id": organization_id},
|
||||
},
|
||||
message="Organization selected and session created successfully",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in select_organization: {str(e)}", exc_info=True)
|
||||
return api_response(success=False, message="An error occurred while selecting organization", status=500, error_type="INTERNAL_ERROR")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/token", methods=["POST"])
|
||||
def exchange_authorization_code():
|
||||
if request.is_json:
|
||||
data = request.json or {}
|
||||
else:
|
||||
data = request.form or {}
|
||||
|
||||
grant_type = data.get("grant_type")
|
||||
code = data.get("code")
|
||||
redirect_uri = data.get("redirect_uri")
|
||||
client_id = data.get("client_id", "external-app")
|
||||
|
||||
if grant_type and grant_type != "authorization_code":
|
||||
return api_response(success=False, message="Invalid grant_type. Must be 'authorization_code'", status=400, error_type="INVALID_GRANT_TYPE")
|
||||
if not code:
|
||||
return api_response(success=False, message="code is required", status=400, error_type="VALIDATION_ERROR")
|
||||
if not redirect_uri:
|
||||
return api_response(success=False, message="redirect_uri is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
try:
|
||||
result = OAuthFlowService.exchange_authorization_code(
|
||||
code=code, client_id=client_id, redirect_uri=redirect_uri, ip_address=request.remote_addr,
|
||||
)
|
||||
return api_response(
|
||||
data={"token": result["token"], "expires_in": result["expires_in"], "token_type": result["token_type"], "user": result["user"]},
|
||||
message="Token exchanged successfully",
|
||||
)
|
||||
except OAuthFlowError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""External auth provider config endpoints (admin and user)."""
|
||||
from flask import g, request
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
from gatehouse_app.services.external_auth import ExternalAuthService
|
||||
from gatehouse_app.services.external_auth.models import ExternalAuthError, ExternalProviderConfig
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
from gatehouse_app.api.v1.external_auth._helpers import get_provider_type, _get_provider_endpoints
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/providers", methods=["GET"])
|
||||
@login_required
|
||||
def list_providers():
|
||||
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
||||
|
||||
app_configs = {c.provider_type.lower(): c for c in ApplicationProviderConfig.query.filter_by(is_enabled=True).all()}
|
||||
|
||||
user_orgs = g.current_user.get_organizations()
|
||||
org_configs = {}
|
||||
if user_orgs:
|
||||
organization_id = user_orgs[0].id
|
||||
org_level = ExternalProviderConfig.query.filter_by(organization_id=organization_id).all()
|
||||
org_configs = {c.provider_type.lower(): c for c in org_level}
|
||||
|
||||
def provider_info(provider_id, name):
|
||||
app_cfg = app_configs.get(provider_id)
|
||||
org_cfg = org_configs.get(provider_id)
|
||||
is_configured = app_cfg is not None or org_cfg is not None
|
||||
is_active = bool(app_cfg.is_enabled) if app_cfg else False
|
||||
if org_cfg and hasattr(org_cfg, "is_active"):
|
||||
is_active = bool(org_cfg.is_active)
|
||||
return {"id": provider_id, "name": name, "type": provider_id, "is_configured": is_configured, "is_active": is_active,
|
||||
"settings": {"requires_domain": False, "supports_refresh_tokens": True}}
|
||||
|
||||
providers = [provider_info("google", "Google"), provider_info("github", "GitHub"), provider_info("microsoft", "Microsoft")]
|
||||
return api_response(data={"providers": providers}, message="Providers retrieved successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/providers/<provider>/config", methods=["GET"])
|
||||
@login_required
|
||||
def get_provider_config(provider: str):
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
provider_type = get_provider_type(provider)
|
||||
|
||||
user_orgs = g.current_user.get_organizations()
|
||||
if not user_orgs:
|
||||
return api_response(success=False, message="No organizations found for user", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
organization_id = user_orgs[0].id
|
||||
member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id).first()
|
||||
if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
|
||||
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
|
||||
|
||||
config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value).first()
|
||||
if not config:
|
||||
return api_response(success=False, message=f"{provider.title()} OAuth is not configured", status=404, error_type="NOT_FOUND")
|
||||
|
||||
return api_response(data=config.to_dict(include_secrets=False), message="Provider configuration retrieved successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/providers/<provider>/config", methods=["POST"])
|
||||
@login_required
|
||||
def create_or_update_provider_config(provider: str):
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
provider_type = get_provider_type(provider)
|
||||
|
||||
user_orgs = g.current_user.get_organizations()
|
||||
if not user_orgs:
|
||||
return api_response(success=False, message="No organizations found for user", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
organization_id = user_orgs[0].id
|
||||
member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id).first()
|
||||
if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
|
||||
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
|
||||
|
||||
data = request.json or {}
|
||||
client_id = data.get("client_id")
|
||||
client_secret = data.get("client_secret")
|
||||
|
||||
if not client_id:
|
||||
return api_response(success=False, message="client_id is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value).first()
|
||||
is_new = config is None
|
||||
|
||||
if config:
|
||||
config.client_id = client_id
|
||||
if client_secret:
|
||||
config.set_client_secret(client_secret)
|
||||
config.scopes = data.get("scopes", ["openid", "profile", "email"])
|
||||
config.redirect_uris = data.get("redirect_uris", [])
|
||||
config.settings = data.get("settings", {})
|
||||
config.is_active = data.get("is_active", True)
|
||||
config.save()
|
||||
AuditService.log_external_auth_config_update(
|
||||
user_id=g.current_user.id, organization_id=organization_id, provider_type=provider_type.value,
|
||||
config_id=config.id,
|
||||
changes={"client_id": "updated", "client_secret": "updated" if client_secret else None,
|
||||
"scopes": data.get("scopes"), "redirect_uris": data.get("redirect_uris"), "is_active": config.is_active},
|
||||
)
|
||||
else:
|
||||
auth_url, token_url, userinfo_url = _get_provider_endpoints(provider_type)
|
||||
config = ExternalProviderConfig(
|
||||
organization_id=organization_id, provider_type=provider_type.value,
|
||||
client_id=client_id, client_secret_encrypted=None,
|
||||
auth_url=auth_url, token_url=token_url, userinfo_url=userinfo_url,
|
||||
scopes=data.get("scopes", ["openid", "profile", "email"]),
|
||||
redirect_uris=data.get("redirect_uris", []), settings=data.get("settings", {}),
|
||||
is_active=data.get("is_active", True),
|
||||
)
|
||||
if client_secret:
|
||||
config.set_client_secret(client_secret)
|
||||
config.save()
|
||||
AuditService.log_external_auth_config_create(
|
||||
user_id=g.current_user.id, organization_id=organization_id,
|
||||
provider_type=provider_type.value, config_id=config.id,
|
||||
)
|
||||
|
||||
return api_response(data=config.to_dict(include_secrets=False), message="Provider configuration saved successfully", status=201 if is_new else 200)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/providers/<provider>/config", methods=["DELETE"])
|
||||
@login_required
|
||||
def delete_provider_config(provider: str):
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
provider_type = get_provider_type(provider)
|
||||
|
||||
user_orgs = g.current_user.get_organizations()
|
||||
if not user_orgs:
|
||||
return api_response(success=False, message="No organizations found for user", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
organization_id = user_orgs[0].id
|
||||
member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id).first()
|
||||
if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
|
||||
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
|
||||
|
||||
config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value).first()
|
||||
if not config:
|
||||
return api_response(success=False, message=f"{provider.title()} OAuth is not configured", status=404, error_type="NOT_FOUND")
|
||||
|
||||
config_id = config.id
|
||||
config.delete()
|
||||
AuditService.log_external_auth_config_delete(
|
||||
user_id=g.current_user.id, organization_id=organization_id,
|
||||
provider_type=provider_type.value, config_id=config_id,
|
||||
)
|
||||
return api_response(message=f"{provider.title()} provider configuration deleted successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/linked-accounts", methods=["GET"])
|
||||
@login_required
|
||||
def list_linked_accounts():
|
||||
from gatehouse_app.models import AuthenticationMethod
|
||||
|
||||
linked_accounts = ExternalAuthService.get_linked_accounts(g.current_user.id)
|
||||
other_methods = AuthenticationMethod.query.filter_by(user_id=g.current_user.id, deleted_at=None).count()
|
||||
return api_response(data={"linked_accounts": linked_accounts, "unlink_available": other_methods > 1}, message="Linked accounts retrieved successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/<provider>/link", methods=["POST"])
|
||||
@login_required
|
||||
def initiate_link_account(provider: str):
|
||||
provider_type = get_provider_type(provider)
|
||||
|
||||
user_orgs = g.current_user.get_organizations()
|
||||
organization_id = user_orgs[0].id if user_orgs else None
|
||||
data = request.json or {}
|
||||
redirect_uri = data.get("redirect_uri")
|
||||
|
||||
try:
|
||||
auth_url, state = ExternalAuthService.initiate_link_flow(
|
||||
user_id=g.current_user.id, provider_type=provider_type,
|
||||
organization_id=organization_id, redirect_uri=redirect_uri,
|
||||
)
|
||||
return api_response(data={"authorization_url": auth_url, "state": state}, message="Link flow initiated. Redirect to authorization URL.")
|
||||
except ExternalAuthError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/<provider>/unlink", methods=["DELETE"])
|
||||
@login_required
|
||||
def unlink_account(provider: str):
|
||||
provider_type = get_provider_type(provider)
|
||||
|
||||
user_orgs = g.current_user.get_organizations()
|
||||
organization_id = user_orgs[0].id if user_orgs else None
|
||||
|
||||
try:
|
||||
ExternalAuthService.unlink_provider(
|
||||
user_id=g.current_user.id, provider_type=provider_type, organization_id=organization_id,
|
||||
)
|
||||
return api_response(message=f"{provider.title()} account unlinked successfully")
|
||||
except ExternalAuthError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
Reference in New Issue
Block a user