can link google accounts!

This commit is contained in:
2026-01-20 15:54:00 +10:30
parent 900722d695
commit 4cf4a27c9a
17 changed files with 5325 additions and 4 deletions
+706
View File
@@ -0,0 +1,706 @@
"""External authentication provider endpoints."""
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
# 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]
# =============================================================================
# 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 import Organization
from gatehouse_app.services.external_auth_service import ExternalProviderConfig
# 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
# Get all configured providers for organization
configs = ExternalProviderConfig.query.filter_by(
organization_id=organization_id,
).all()
configured_providers = {c.provider_type.lower(): c for c in configs}
# Provider definitions
providers = [
{
"id": "google",
"name": "Google",
"type": "google",
"is_configured": "google" in configured_providers,
"is_active": configured_providers.get("google", {}).is_active if "google" in configured_providers else False,
"settings": {
"requires_domain": False,
"supports_refresh_tokens": True,
},
},
{
"id": "github",
"name": "GitHub",
"type": "github",
"is_configured": "github" in configured_providers,
"is_active": configured_providers.get("github", {}).is_active if "github" in configured_providers else False,
"settings": {
"requires_domain": False,
"supports_refresh_tokens": True,
},
},
{
"id": "microsoft",
"name": "Microsoft",
"type": "microsoft",
"is_configured": "microsoft" in configured_providers,
"is_active": configured_providers.get("microsoft", {}).is_active if "microsoft" in configured_providers else False,
"settings": {
"requires_domain": False,
"supports_refresh_tokens": True,
},
},
]
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.
Args:
provider: Provider type (google, github, microsoft)
Query parameters:
flow: 'login' or 'register'
redirect_uri: Optional redirect URI
organization_id: Optional organization context
Returns:
302: Redirect to provider authorization page
400: Validation error or provider not configured
"""
provider_type = get_provider_type(provider)
# Get query parameters
flow = request.args.get("flow", "login")
redirect_uri = request.args.get("redirect_uri")
organization_id = request.args.get("organization_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:
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,
)
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,
)
@api_v1_bp.route("/auth/external/<provider>/callback", methods=["GET"])
def handle_oauth_callback(provider: str):
"""
Handle OAuth callback from provider.
Args:
provider: Provider type (google, github, microsoft)
Query parameters:
code: Authorization code from provider
state: State parameter
error: Error code if auth failed
error_description: Human-readable error description
Returns:
200: OAuth flow completed successfully
302: Redirect with error
400: Validation error or OAuth error
"""
provider_type = get_provider_type(provider)
# Get callback parameters
authorization_code = request.args.get("code")
state = request.args.get("state")
error = request.args.get("error")
error_description = request.args.get("error_description")
# Get redirect URI from state if available
redirect_uri = request.args.get("redirect_uri")
try:
result = OAuthFlowService.handle_callback(
provider_type=provider_type,
authorization_code=authorization_code,
state=state,
redirect_uri=redirect_uri,
error=error,
error_description=error_description,
)
if result.get("success"):
if result.get("flow_type") == "login":
return api_response(
data={
"token": result["session"]["token"],
"expires_in": result["session"].get("expires_in", 86400),
"token_type": "Bearer",
"user": result["user"],
},
message="Login successful",
)
elif result.get("flow_type") == "register":
return api_response(
data={
"token": result["session"]["token"],
"expires_in": result["session"].get("expires_in", 86400),
"token_type": "Bearer",
"user": result["user"],
},
message="Registration successful",
)
elif result.get("flow_type") == "link":
return api_response(
data={
"linked_account": result["linked_account"],
},
message="Account linked successfully",
)
return api_response(
data=result,
message="OAuth flow completed",
)
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,
)