Files
gatehouse-api/gatehouse_app/api/v1/external_auth.py
T
JamesBhattarai a0d4e59c24 Feat(Chore): Verify Flow, Invites, Suspend, Depart Cert Policy
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
2026-03-01 20:42:48 +05:45

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",
)