a0d4e59c24
feat: add password reset and email verification flow feat: add org invite listing, cancellation, and invite link fallback feat: add user suspend/unsuspend with audit logging feat: add department certificate policy (expiry, extensions) feat: enforce dept cert policy on SSH certificate signing feat: wire up OIDC consent and token flow (replace mocks) feat: rework CLI auth bridge to use frontend login flow feat: add admin OAuth provider management (CRUD) chore: refactor model import paths after module reorganisation chore: clean up config, decorators, and dev tooling
1442 lines
48 KiB
Python
1442 lines
48 KiB
Python
"""External authentication provider endpoints."""
|
|
import json
|
|
import logging
|
|
from flask import request, g
|
|
from marshmallow import ValidationError
|
|
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.utils.constants import AuthMethodType
|
|
from gatehouse_app.services.external_auth_service import (
|
|
ExternalAuthService,
|
|
ExternalAuthError,
|
|
)
|
|
from gatehouse_app.services.oauth_flow_service import (
|
|
OAuthFlowService,
|
|
OAuthFlowError,
|
|
)
|
|
from gatehouse_app.services.audit_service import AuditService
|
|
|
|
_OAUTH_BRIDGE_TTL = 600 # 10 minutes
|
|
|
|
|
|
def _store_oidc_bridge(oauth_state: str, oidc_session_id: str) -> None:
|
|
"""Store oidc_session_id keyed by OAuth state for retrieval in callback."""
|
|
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:
|
|
"""Retrieve and delete oidc_session_id for the given OAuth state."""
|
|
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:
|
|
"""Store CLI redirect_url keyed by OAuth state (for /token_please flow)."""
|
|
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:
|
|
"""Retrieve and delete CLI redirect_url for the given OAuth state."""
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Provider type mapping
|
|
PROVIDER_TYPE_MAP = {
|
|
"google": AuthMethodType.GOOGLE,
|
|
"github": AuthMethodType.GITHUB,
|
|
"microsoft": AuthMethodType.MICROSOFT,
|
|
}
|
|
|
|
|
|
def get_provider_type(provider: str) -> AuthMethodType:
|
|
"""Get AuthMethodType from provider string."""
|
|
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]
|
|
|
|
|
|
@api_v1_bp.route("/token_please", methods=["GET"])
|
|
def token_please():
|
|
"""
|
|
CLI token acquisition endpoint.
|
|
|
|
Redirects the user's browser to the Gatehouse login page so they can
|
|
authenticate using any method (password, OAuth, passkey, TOTP, etc.).
|
|
On successful login the frontend delivers the session token directly to
|
|
the CLI's local callback server.
|
|
|
|
This endpoint is designed for CLI clients that:
|
|
1. Start a local HTTP server on LISTENER_SERVER_PORT (e.g. 8250)
|
|
2. Open a browser to /api/v1/token_please?redirect_url=http://127.0.0.1:8250/?token=
|
|
3. Wait for the browser to deliver the token to their local server
|
|
|
|
Query parameters:
|
|
redirect_url: Local callback URL where the token will be appended
|
|
"""
|
|
import secrets
|
|
from urllib.parse import urlencode, quote
|
|
from flask import current_app, redirect as flask_redirect
|
|
|
|
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",
|
|
)
|
|
|
|
# Validate redirect_url is localhost/127.0.0.1 (security: prevent open redirect)
|
|
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",
|
|
)
|
|
|
|
# Store the CLI redirect URL in Redis keyed by a short-lived token so the
|
|
# frontend can retrieve it after login without it being visible in the 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:
|
|
# Pass an opaque token; the frontend exchanges it for the real URL via
|
|
# GET /api/v1/cli/redirect-url?token=<cli_token>
|
|
login_url = f"{frontend_url}/login?cli_token={cli_token}"
|
|
else:
|
|
# Fallback: put the redirect URL directly (still localhost-only, validated above)
|
|
login_url = f"{frontend_url}/login?cli_redirect={quote(redirect_url, safe='')}"
|
|
|
|
logger.info(f"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():
|
|
"""
|
|
Exchange a short-lived cli_token for the CLI's local redirect URL.
|
|
|
|
Called by the frontend LoginPage after it detects the cli_token query
|
|
param so it can obtain the actual CLI callback URL from Redis without
|
|
exposing it in the browser URL bar.
|
|
|
|
Query parameters:
|
|
token: The cli_token issued by /token_please
|
|
|
|
Returns:
|
|
200: { "redirect_url": "http://127.0.0.1:8250/?token=" }
|
|
400: Missing token
|
|
404: Token not found or expired
|
|
"""
|
|
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",
|
|
)
|
|
# Keep the key alive until the login actually completes (consume on use
|
|
# would break multi-step auth like TOTP), so we leave it as-is.
|
|
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",
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Provider Configuration Endpoints (Admin)
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route("/auth/external/providers", methods=["GET"])
|
|
@login_required
|
|
def list_providers():
|
|
"""
|
|
List available external authentication providers for current organization.
|
|
|
|
Returns:
|
|
200: List of providers with their configuration status
|
|
401: Not authenticated
|
|
"""
|
|
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
|
from gatehouse_app.services.external_auth_service import ExternalProviderConfig
|
|
|
|
# Check app-level provider configs (ApplicationProviderConfig)
|
|
app_configs = {
|
|
c.provider_type.lower(): c
|
|
for c in ApplicationProviderConfig.query.filter_by(is_enabled=True).all()
|
|
}
|
|
|
|
# Get user's primary organization — check for org-level overrides too
|
|
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: str, name: str) -> dict:
|
|
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 = False
|
|
if app_cfg:
|
|
is_active = bool(app_cfg.is_enabled)
|
|
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):
|
|
"""
|
|
Get provider configuration (admin only).
|
|
|
|
Args:
|
|
provider: Provider type (google, github, microsoft)
|
|
|
|
Returns:
|
|
200: Provider configuration
|
|
401: Not authenticated
|
|
403: Not authorized (not admin)
|
|
404: Provider not configured
|
|
"""
|
|
from gatehouse_app.models import OrganizationMember
|
|
from gatehouse_app.utils.constants import OrganizationRole
|
|
from gatehouse_app.services.external_auth_service import ExternalProviderConfig
|
|
|
|
provider_type = get_provider_type(provider)
|
|
|
|
# Get user's primary organization
|
|
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
|
|
|
|
# Check if user is admin
|
|
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",
|
|
)
|
|
|
|
# Get provider config
|
|
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):
|
|
"""
|
|
Create or update provider configuration (admin only).
|
|
|
|
Args:
|
|
provider: Provider type (google, github, microsoft)
|
|
|
|
Request body:
|
|
client_id: OAuth client ID
|
|
client_secret: OAuth client secret
|
|
scopes: List of OAuth scopes
|
|
redirect_uris: List of allowed redirect URIs
|
|
settings: Provider-specific settings
|
|
is_active: Whether the provider is active
|
|
|
|
Returns:
|
|
200: Provider configuration updated
|
|
201: Provider configuration created
|
|
400: Validation error
|
|
401: Not authenticated
|
|
403: Not authorized (not admin)
|
|
"""
|
|
from gatehouse_app.models import OrganizationMember
|
|
from gatehouse_app.utils.constants import OrganizationRole
|
|
from gatehouse_app.services.external_auth_service import ExternalProviderConfig
|
|
|
|
provider_type = get_provider_type(provider)
|
|
|
|
# Get user's primary organization
|
|
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
|
|
|
|
# Check if user is admin
|
|
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",
|
|
)
|
|
|
|
# Validate request data
|
|
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",
|
|
)
|
|
|
|
# Get or create config
|
|
config = ExternalProviderConfig.query.filter_by(
|
|
organization_id=organization_id,
|
|
provider_type=provider_type.value,
|
|
).first()
|
|
|
|
is_new = config is None
|
|
|
|
if config:
|
|
# Update existing
|
|
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()
|
|
|
|
# Audit log - config update
|
|
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:
|
|
# Create new - get provider endpoints
|
|
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()
|
|
|
|
# Audit log - config create
|
|
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):
|
|
"""
|
|
Delete provider configuration (admin only).
|
|
|
|
Args:
|
|
provider: Provider type (google, github, microsoft)
|
|
|
|
Returns:
|
|
200: Provider configuration deleted
|
|
401: Not authenticated
|
|
403: Not authorized (not admin)
|
|
404: Provider not configured
|
|
"""
|
|
from gatehouse_app.models import OrganizationMember
|
|
from gatehouse_app.utils.constants import OrganizationRole
|
|
from gatehouse_app.services.external_auth_service import ExternalProviderConfig
|
|
|
|
provider_type = get_provider_type(provider)
|
|
|
|
# Get user's primary organization
|
|
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
|
|
|
|
# Check if user is admin
|
|
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",
|
|
)
|
|
|
|
# Get and delete config
|
|
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()
|
|
|
|
# Audit log - 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",
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Account Linking Endpoints
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route("/auth/external/linked-accounts", methods=["GET"])
|
|
@login_required
|
|
def list_linked_accounts():
|
|
"""
|
|
List all linked external accounts for the current user.
|
|
|
|
Returns:
|
|
200: List of linked accounts
|
|
401: Not authenticated
|
|
"""
|
|
linked_accounts = ExternalAuthService.get_linked_accounts(g.current_user.id)
|
|
|
|
# Check if user has other auth methods (for unlink availability)
|
|
from gatehouse_app.models import AuthenticationMethod
|
|
other_methods = AuthenticationMethod.query.filter_by(
|
|
user_id=g.current_user.id,
|
|
).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):
|
|
"""
|
|
Initiate OAuth flow to link an external account.
|
|
|
|
Args:
|
|
provider: Provider type (google, github, microsoft)
|
|
|
|
Request body:
|
|
redirect_uri: Optional redirect URI after linking
|
|
|
|
Returns:
|
|
302: Redirect to provider authorization page
|
|
400: Validation error or provider not configured
|
|
401: Not authenticated
|
|
"""
|
|
provider_type = get_provider_type(provider)
|
|
|
|
# Get user's organization
|
|
user_orgs = g.current_user.get_organizations()
|
|
organization_id = user_orgs[0].id if user_orgs else None
|
|
|
|
# Get optional redirect URI
|
|
data = request.json or {}
|
|
redirect_uri = data.get("redirect_uri")
|
|
|
|
try:
|
|
# Initiate link flow
|
|
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):
|
|
"""
|
|
Unlink an external account from the user's profile.
|
|
|
|
Args:
|
|
provider: Provider type (google, github, microsoft)
|
|
|
|
Returns:
|
|
200: Account unlinked successfully
|
|
400: Validation error or cannot unlink last method
|
|
401: Not authenticated
|
|
404: Provider not linked
|
|
"""
|
|
provider_type = get_provider_type(provider)
|
|
|
|
# Get user's organization
|
|
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,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# OAuth Flow Endpoints
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route("/auth/external/<provider>/authorize", methods=["GET"])
|
|
def initiate_oauth_authorize(provider: str):
|
|
"""
|
|
Initiate OAuth authentication or account registration flow.
|
|
|
|
This endpoint initiates OAuth flows without requiring organization_id upfront.
|
|
The organization context is determined after successful authentication based on
|
|
the user's memberships.
|
|
|
|
Args:
|
|
provider: Provider type (google, github, microsoft)
|
|
|
|
Query parameters:
|
|
flow: 'login' or 'register' (default: 'login')
|
|
redirect_uri: Optional redirect URI after OAuth completion
|
|
organization_id: Optional organization hint (for SSO discovery)
|
|
|
|
Returns:
|
|
200: Authorization URL and state token
|
|
400: Validation error or provider not configured at application level
|
|
|
|
Response:
|
|
{
|
|
"authorization_url": "https://...",
|
|
"state": "state_token"
|
|
}
|
|
"""
|
|
# Get query parameters - organization_id is now optional
|
|
flow = request.args.get("flow", "login")
|
|
redirect_uri = request.args.get("redirect_uri")
|
|
organization_id = request.args.get("organization_id") # Optional hint
|
|
oidc_session_id = request.args.get("oidc_session_id") # OIDC bridge passthrough
|
|
|
|
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, # Optional hint
|
|
redirect_uri=redirect_uri,
|
|
)
|
|
else:
|
|
auth_url, state = OAuthFlowService.initiate_register_flow(
|
|
provider_type=provider_type,
|
|
organization_id=organization_id, # Optional hint
|
|
redirect_uri=redirect_uri,
|
|
)
|
|
|
|
# If this authorize was triggered during an OIDC bridge flow, remember
|
|
# the oidc_session_id so we can hand it back in the callback.
|
|
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):
|
|
"""
|
|
Handle OAuth callback from provider.
|
|
|
|
Google (and other providers) redirect the browser here after authentication.
|
|
On success, this endpoint redirects the browser to the frontend
|
|
/oauth/callback page carrying the session token as a URL parameter so the
|
|
frontend SPA can store it without needing a second API call.
|
|
|
|
Success redirect:
|
|
{FRONTEND_URL}/oauth/callback?token=TOKEN&expires_in=86400&state=STATE&flow=login&provider=google
|
|
|
|
Error redirect:
|
|
{FRONTEND_URL}/oauth/callback?error=MESSAGE&error_type=TYPE&state=STATE
|
|
|
|
Args:
|
|
provider: Provider type (google, github, microsoft)
|
|
|
|
Query parameters from provider:
|
|
code: Authorization code
|
|
state: State parameter (CSRF token from OAuth flow)
|
|
error: Error code if auth failed at provider
|
|
error_description: Human-readable error description
|
|
"""
|
|
from urllib.parse import urlencode
|
|
from flask import current_app, redirect as flask_redirect
|
|
|
|
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"
|
|
|
|
# Check if this is a CLI /token_please flow — retrieve stored redirect_url
|
|
cli_redirect_url = _pop_cli_redirect(state) if state else None
|
|
|
|
def redirect_error(message: str, error_type: str = "OAUTH_ERROR"):
|
|
"""Redirect to frontend (or CLI) with error params."""
|
|
if cli_redirect_url:
|
|
# CLI flow: return a plain error page instead of redirecting back
|
|
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)
|
|
|
|
# Handle errors returned by the provider (e.g. user denied)
|
|
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, # backend handles the full flow
|
|
error=None,
|
|
error_description=None,
|
|
)
|
|
|
|
if not result.get("success"):
|
|
return redirect_error("Authentication failed.", "AUTH_FAILED")
|
|
|
|
flow_type = result.get("flow_type", "login")
|
|
|
|
# ── Link flow: redirect to linked-accounts page ──────────────────────
|
|
if flow_type == "link":
|
|
params = {"flow": "link", "provider": provider, "linked": "1"}
|
|
return flask_redirect(f"{frontend_url}/linked-accounts?{urlencode(params)}", code=302)
|
|
|
|
# ── Login / Register flow ─────────────────────────────────────────────
|
|
|
|
# Recover oidc_session_id if this was triggered from an OIDC bridge flow
|
|
oidc_session_id = _pop_oidc_bridge(state)
|
|
|
|
# Organization selection / creation flows are not supported in CLI mode
|
|
# (fall through to token redirect with whatever session we have)
|
|
|
|
# Organization selection needed (user belongs to multiple orgs)
|
|
if result.get("requires_org_selection") and not cli_redirect_url:
|
|
import json
|
|
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)
|
|
|
|
# Organization creation needed (new user via OAuth with no org)
|
|
if result.get("requires_org_creation") and not cli_redirect_url:
|
|
params = {
|
|
"requires_org_creation": "1",
|
|
"state": result["state"],
|
|
"provider": provider,
|
|
"flow": flow_type,
|
|
}
|
|
if oidc_session_id:
|
|
params["oidc_session_id"] = oidc_session_id
|
|
return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302)
|
|
|
|
# Normal success — carry token to frontend via URL
|
|
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"]
|
|
|
|
# ── CLI /token_please flow: redirect to the CLI's local callback ─────
|
|
if cli_redirect_url:
|
|
# The CLI expects: http://127.0.0.1:8250/?token=<TOKEN>
|
|
# cli_redirect_url already ends with "token=" so just append the value
|
|
cli_final_url = cli_redirect_url + token
|
|
logger.info(
|
|
f"CLI token_please success: provider={provider}, user={user_info.get('email')}, "
|
|
f"redirecting to CLI callback"
|
|
)
|
|
return flask_redirect(cli_final_url, code=302)
|
|
|
|
# ── Frontend flow ─────────────────────────────────────────────────────
|
|
# Pass oidc_session_id through so the frontend can complete the OIDC flow
|
|
if oidc_session_id:
|
|
params["oidc_session_id"] = oidc_session_id
|
|
|
|
logger.info(
|
|
f"OAuth callback success: provider={provider}, flow={flow_type}, "
|
|
f"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():
|
|
"""
|
|
Complete OAuth flow by selecting an organization.
|
|
|
|
This endpoint is called after OAuth callback when the user needs to select
|
|
which organization to log in to (when user belongs to multiple orgs).
|
|
|
|
Request body:
|
|
state: The state token from the OAuth callback
|
|
organization_id: The selected organization ID
|
|
|
|
Returns:
|
|
200: Session created successfully
|
|
400: Invalid state or organization
|
|
404: Organization not found or user not a member
|
|
|
|
Response:
|
|
{
|
|
"token": "session_token",
|
|
"expires_in": 86400,
|
|
"token_type": "Bearer",
|
|
"user": {
|
|
"id": "...",
|
|
"email": "...",
|
|
"full_name": "...",
|
|
"organization_id": "..."
|
|
}
|
|
}
|
|
"""
|
|
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:
|
|
# Validate state and get OAuth state record
|
|
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",
|
|
)
|
|
|
|
# The state should have user information from the OAuth callback
|
|
# We need to find the user that was authenticated
|
|
from gatehouse_app.models import User, AuthenticationMethod, Organization, OrganizationMember
|
|
|
|
# Find user by provider authentication
|
|
# The state record should have provider info in extra_data if set by callback
|
|
# Otherwise, we need to find the most recently created auth method
|
|
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
|
|
|
|
# Verify user is member of selected organization
|
|
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",
|
|
)
|
|
|
|
# Create session for the selected organization
|
|
from gatehouse_app.services.session_service import SessionService
|
|
session = SessionService.create_session(
|
|
user=user,
|
|
organization_id=organization_id,
|
|
)
|
|
|
|
# Mark state as used
|
|
state_record.mark_used()
|
|
|
|
# Audit log - login success with org selection
|
|
AuditService.log_external_auth_login(
|
|
user_id=user.id,
|
|
organization_id=organization_id,
|
|
provider_type=state_record.provider_type.value if isinstance(state_record.provider_type, AuthMethodType) else state_record.provider_type,
|
|
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",
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Authorization Code Exchange Endpoint
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route("/auth/external/token", methods=["POST"])
|
|
def exchange_authorization_code():
|
|
"""
|
|
Exchange an authorization code for a session token.
|
|
|
|
This endpoint is used by external applications (like oauth2-proxy, BookStack)
|
|
to exchange the authorization code received from the OAuth callback for a
|
|
session token.
|
|
|
|
Request body (form-encoded or JSON):
|
|
grant_type: Must be "authorization_code"
|
|
code: The authorization code from the callback
|
|
redirect_uri: The redirect URI used in the original request
|
|
client_id: The client ID (optional, defaults to "external-app")
|
|
|
|
Returns:
|
|
200: Session token exchanged successfully
|
|
400: Invalid or expired authorization code
|
|
404: User not found
|
|
|
|
Response:
|
|
{
|
|
"token": "session_token",
|
|
"expires_in": 86400,
|
|
"token_type": "Bearer",
|
|
"user": {
|
|
"id": "...",
|
|
"email": "...",
|
|
"full_name": "...",
|
|
"organization_id": "..."
|
|
}
|
|
}
|
|
"""
|
|
# Support both JSON and form-encoded requests
|
|
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")
|
|
|
|
# Validate required parameters
|
|
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,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Helper Functions
|
|
# =============================================================================
|
|
|
|
def _get_provider_endpoints(provider_type: AuthMethodType):
|
|
"""Get OAuth endpoints for a provider."""
|
|
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,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Admin: Application-level OAuth Provider Management
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route("/admin/oauth/providers", methods=["GET"])
|
|
@login_required
|
|
def admin_list_app_providers():
|
|
"""List all application-level OAuth provider configurations (admin only).
|
|
|
|
Returns:
|
|
200: List of providers with client_id and enabled status
|
|
401: Not authenticated
|
|
403: Not an admin
|
|
"""
|
|
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
|
from gatehouse_app.models import OrganizationMember
|
|
from gatehouse_app.utils.constants import OrganizationRole
|
|
|
|
# Verify caller is admin in any org
|
|
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):
|
|
"""Create or update an application-level OAuth provider config (admin only).
|
|
|
|
Args:
|
|
provider: Provider type (google, github, microsoft)
|
|
|
|
Request body:
|
|
client_id: OAuth client ID
|
|
client_secret: OAuth client secret (optional — omit to keep existing)
|
|
is_enabled: Whether the provider is enabled (default: true)
|
|
|
|
Returns:
|
|
200: Provider configuration updated
|
|
400: Validation error
|
|
401: Not authenticated
|
|
403: Not an admin
|
|
"""
|
|
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",
|
|
)
|
|
|
|
# Verify caller is admin in any org
|
|
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):
|
|
"""Delete an application-level OAuth provider config (admin only).
|
|
|
|
Args:
|
|
provider: Provider type (google, github, microsoft)
|
|
|
|
Returns:
|
|
200: Provider configuration deleted
|
|
404: Provider not found
|
|
401: Not authenticated
|
|
403: Not an admin
|
|
"""
|
|
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
|
|
|
|
# Verify caller is admin in any org
|
|
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",
|
|
)
|