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:
2026-03-04 18:49:04 +05:45
parent ea1bacc794
commit 7cb522b590
63 changed files with 7896 additions and 10863 deletions
@@ -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
+109
View File
@@ -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")
+68
View File
@@ -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")
+244
View File
@@ -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)