Feat: OIDC UI bridge, Microsoft SSO,, and schema session flaws
- OAuth Callback to Use Gatehouse UI to login instead of Backend Served dull ui - Setup Autoregister of user + org, on oauth - Microsoft Oauth Support - OIDCRefreshToken.access_token_id had a narrow Column increased to VAR(255) and remove FK to sessions.id which had no use - client_id and client.id mismatch ,backup-code consumption
This commit is contained in:
+194
-53
@@ -19,10 +19,53 @@ from gatehouse_app.services.auth_service import AuthService
|
||||
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.extensions import bcrypt as flask_bcrypt
|
||||
from gatehouse_app.extensions import redis_client as _redis_client_ref # may be None until app init
|
||||
from gatehouse_app.models import User, OIDCClient
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for Redis-backed OIDC pending state
|
||||
# (avoids Flask session / cookie dependency for cross-origin /oidc/complete)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OIDC_PENDING_TTL = 600 # 10 minutes
|
||||
|
||||
|
||||
def _oidc_redis():
|
||||
"""Return the shared Redis client, or None if not yet initialised."""
|
||||
import gatehouse_app.extensions as _ext
|
||||
return _ext.redis_client
|
||||
|
||||
|
||||
def _stash_oidc_params(oidc_session_id: str, params: dict) -> None:
|
||||
"""Store OIDC params in Redis with a TTL. Falls back to Flask session."""
|
||||
rc = _oidc_redis()
|
||||
key = f"oidc_pending:{oidc_session_id}"
|
||||
if rc is not None:
|
||||
rc.setex(key, _OIDC_PENDING_TTL, json.dumps(params))
|
||||
else:
|
||||
session[f"oidc_pending_{oidc_session_id}"] = params
|
||||
|
||||
|
||||
def _fetch_oidc_params(oidc_session_id: str, *, consume: bool = False) -> dict | None:
|
||||
"""Retrieve (and optionally delete) OIDC params from Redis / Flask session."""
|
||||
rc = _oidc_redis()
|
||||
key = f"oidc_pending:{oidc_session_id}"
|
||||
if rc is not None:
|
||||
raw = rc.get(key)
|
||||
if raw is None:
|
||||
return None
|
||||
params = json.loads(raw)
|
||||
if consume:
|
||||
rc.delete(key)
|
||||
return params
|
||||
else:
|
||||
params = session.get(f"oidc_pending_{oidc_session_id}")
|
||||
if params and consume:
|
||||
session.pop(f"oidc_pending_{oidc_session_id}", None)
|
||||
return params
|
||||
|
||||
|
||||
# Create OIDC blueprint registered at root level
|
||||
oidc_bp = Blueprint("oidc", __name__)
|
||||
@@ -217,6 +260,134 @@ def oidc_discovery():
|
||||
return response, 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# OIDC UI Bridge — lets the React frontend drive the OIDC login flow
|
||||
# ============================================================================
|
||||
|
||||
@oidc_bp.route("/oidc/begin", methods=["POST"])
|
||||
def oidc_begin():
|
||||
"""Stash OIDC authorize params server-side, return a one-time session ID.
|
||||
|
||||
Called by the React UI after being redirected from _show_login_page.
|
||||
The UI cannot hold OIDC params itself (XSS risk, URL length limits), so
|
||||
the backend stashes them in the server-side session store and hands back
|
||||
an opaque ID the UI passes along during login.
|
||||
|
||||
Request body (JSON):
|
||||
oidc_session_id: ID previously issued by _show_login_page
|
||||
|
||||
Returns:
|
||||
200: { oidc_session_id, client_name, scopes } — context for the UI
|
||||
400: missing / expired session
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
oidc_session_id = data.get("oidc_session_id") or request.args.get("oidc_session_id")
|
||||
if not oidc_session_id:
|
||||
return api_response(success=False, message="oidc_session_id required", status=400)
|
||||
|
||||
params = _fetch_oidc_params(oidc_session_id)
|
||||
if not params:
|
||||
return api_response(success=False, message="OIDC session expired or invalid", status=400)
|
||||
|
||||
# Look up client name for display
|
||||
client = OIDCClient.query.filter_by(client_id=params.get("client_id"), is_active=True).first()
|
||||
client_name = client.name if client else params.get("client_id", "Unknown Application")
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"oidc_session_id": oidc_session_id,
|
||||
"client_name": client_name,
|
||||
"scopes": params.get("scope", "").split(),
|
||||
"redirect_uri": params.get("redirect_uri"),
|
||||
},
|
||||
message="OIDC session found",
|
||||
)
|
||||
|
||||
|
||||
@oidc_bp.route("/oidc/complete", methods=["POST"])
|
||||
def oidc_complete():
|
||||
"""Complete an OIDC authorization flow after the UI has authenticated the user.
|
||||
|
||||
Called by the React UI after a successful login. The UI sends its Bearer
|
||||
token + the oidc_session_id. The backend:
|
||||
1. Validates the Bearer token → resolves the user
|
||||
2. Retrieves the stashed OIDC params
|
||||
3. Generates an authorization code
|
||||
4. Returns the redirect URL (client app redirect_uri + ?code=...&state=...)
|
||||
|
||||
The UI then does window.location.href = redirect_url.
|
||||
|
||||
Request body (JSON):
|
||||
oidc_session_id: ID from oidc_begin
|
||||
token: Gatehouse Bearer token (from /api/v1/auth/login response)
|
||||
|
||||
Returns:
|
||||
200: { redirect_url }
|
||||
400: invalid request
|
||||
401: invalid token
|
||||
"""
|
||||
from gatehouse_app.models.session import Session as GHSession
|
||||
from gatehouse_app.utils.constants import SessionStatus
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
oidc_session_id = data.get("oidc_session_id")
|
||||
bearer_token = data.get("token")
|
||||
|
||||
if not oidc_session_id or not bearer_token:
|
||||
return api_response(success=False, message="oidc_session_id and token required", status=400)
|
||||
|
||||
# Validate the Bearer token
|
||||
gh_session = GHSession.query.filter_by(token=bearer_token, status=SessionStatus.ACTIVE).first()
|
||||
if not gh_session or gh_session.is_expired():
|
||||
return api_response(success=False, message="Invalid or expired token", status=401)
|
||||
|
||||
user_id = str(gh_session.user_id)
|
||||
|
||||
# Retrieve stashed OIDC params (consume = True removes from Redis atomically)
|
||||
params = _fetch_oidc_params(oidc_session_id, consume=True)
|
||||
if not params:
|
||||
return api_response(success=False, message="OIDC session expired or invalid", status=400)
|
||||
|
||||
client_id = params["client_id"]
|
||||
redirect_uri = params["redirect_uri"]
|
||||
state = params.get("state", "")
|
||||
nonce = params.get("nonce", "")
|
||||
scope = params.get("scope", "openid")
|
||||
response_type = params.get("response_type", "code")
|
||||
|
||||
# Validate client still exists
|
||||
client = OIDCClient.query.filter_by(client_id=client_id, is_active=True).first()
|
||||
if not client:
|
||||
return api_response(success=False, message="OIDC client not found", status=400)
|
||||
|
||||
# Generate authorization code
|
||||
try:
|
||||
valid_scopes = [s for s in scope.split() if s in (client.scopes or [])]
|
||||
if not valid_scopes:
|
||||
valid_scopes = ["openid"]
|
||||
|
||||
code = OIDCService.generate_authorization_code(
|
||||
client_id=client_id,
|
||||
user_id=user_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=valid_scopes,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("[OIDC complete] Code generation failed: %s", str(e))
|
||||
return api_response(success=False, message=f"Failed to generate authorization code: {e}", status=500)
|
||||
|
||||
redirect_params = {"code": code}
|
||||
if state:
|
||||
redirect_params["state"] = state
|
||||
redirect_url = f"{redirect_uri}?{urlencode(redirect_params)}"
|
||||
|
||||
return api_response(data={"redirect_url": redirect_url}, message="Authorization complete")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Authorization Endpoint
|
||||
# ============================================================================
|
||||
@@ -495,59 +666,30 @@ def _redirect_with_error(redirect_uri, error, error_description, state=None):
|
||||
|
||||
|
||||
def _show_login_page(client_id, redirect_uri, scope, state, nonce, response_type, error=None):
|
||||
"""Show the login page for authorization."""
|
||||
# Simple HTML login page
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Sign In - OIDC Authorization</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }}
|
||||
.container {{ max-width: 400px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
|
||||
h1 {{ color: #333; font-size: 24px; margin-bottom: 20px; }}
|
||||
.form-group {{ margin-bottom: 15px; }}
|
||||
label {{ display: block; margin-bottom: 5px; color: #555; font-weight: bold; }}
|
||||
input[type="email"], input[type="password"] {{ width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }}
|
||||
button {{ width: 100%; padding: 12px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; }}
|
||||
button:hover {{ background: #0056b3; }}
|
||||
.error {{ color: #dc3545; margin-bottom: 15px; }}
|
||||
.cancel {{ text-align: center; margin-top: 15px; }}
|
||||
.cancel a {{ color: #666; text-decoration: none; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Sign In</h1>
|
||||
{"<p class='error'>" + error + "</p>" if error else ""}
|
||||
<form method="POST">
|
||||
<input type="hidden" name="client_id" value="{client_id}">
|
||||
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
|
||||
<input type="hidden" name="scope" value="{scope}">
|
||||
<input type="hidden" name="state" value="{state}">
|
||||
<input type="hidden" name="nonce" value="{nonce}">
|
||||
<input type="hidden" name="response_type" value="{response_type}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
<p class="cancel">
|
||||
<a href="{redirect_uri}">Cancel</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""Redirect to the Gatehouse React UI login page for a proper login experience.
|
||||
|
||||
Stashes the OIDC params in the server-side session keyed by a random ID,
|
||||
then sends the browser to the React UI at /login?oidc_session_id=<id>.
|
||||
The UI logs the user in and calls /oidc/complete to finish the flow.
|
||||
"""
|
||||
return Response(html, mimetype="text/html"), 200
|
||||
ui_base_url = current_app.config.get("OIDC_UI_URL", "http://localhost:8080")
|
||||
|
||||
# Stash OIDC params in Redis (TTL 10 min) — cookie-free, cross-origin safe
|
||||
oidc_session_id = secrets.token_urlsafe(32)
|
||||
_stash_oidc_params(oidc_session_id, {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": scope,
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"response_type": response_type,
|
||||
})
|
||||
|
||||
params = {"oidc_session_id": oidc_session_id}
|
||||
if error:
|
||||
params["error"] = error
|
||||
|
||||
return redirect(f"{ui_base_url}/login?{urlencode(params)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -1224,7 +1366,6 @@ def oidc_register():
|
||||
grant_types=data.get("grant_types", ["authorization_code", "refresh_token"]),
|
||||
response_types=data.get("response_types", ["code"]),
|
||||
scopes=data.get("scope", "openid profile email roles").split(),
|
||||
token_endpoint_auth_method=data.get("token_endpoint_auth_method", "client_secret_basic"),
|
||||
is_active=True,
|
||||
is_confidential=True,
|
||||
require_pkce=True,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""External authentication provider endpoints."""
|
||||
import json
|
||||
import logging
|
||||
from flask import request, g
|
||||
from marshmallow import ValidationError
|
||||
@@ -16,6 +17,35 @@ from gatehouse_app.services.oauth_flow_service import (
|
||||
)
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -53,63 +83,50 @@ def list_providers():
|
||||
200: List of providers with their configuration status
|
||||
401: Not authenticated
|
||||
"""
|
||||
from gatehouse_app.models import Organization
|
||||
from gatehouse_app.models.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.services.external_auth_service import ExternalProviderConfig
|
||||
|
||||
# Get user's primary organization
|
||||
# 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()
|
||||
if not user_orgs:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="No organizations found for user",
|
||||
status=400,
|
||||
error_type="BAD_REQUEST",
|
||||
)
|
||||
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}
|
||||
|
||||
organization_id = user_orgs[0].id
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
# 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,
|
||||
},
|
||||
},
|
||||
provider_info("google", "Google"),
|
||||
provider_info("github", "GitHub"),
|
||||
provider_info("microsoft", "Microsoft"),
|
||||
]
|
||||
|
||||
return api_response(
|
||||
@@ -564,6 +581,7 @@ def initiate_oauth_authorize(provider: str):
|
||||
flow = request.args.get("flow", "login")
|
||||
redirect_uri = request.args.get("redirect_uri")
|
||||
organization_id = request.args.get("organization_id") # Optional hint
|
||||
oidc_session_id = request.args.get("oidc_session_id") # OIDC bridge passthrough
|
||||
|
||||
if flow not in ["login", "register"]:
|
||||
return api_response(
|
||||
@@ -588,6 +606,11 @@ def initiate_oauth_authorize(provider: str):
|
||||
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,
|
||||
@@ -609,189 +632,141 @@ def initiate_oauth_authorize(provider: str):
|
||||
def handle_oauth_callback(provider: str):
|
||||
"""
|
||||
Handle OAuth callback from provider.
|
||||
|
||||
This endpoint handles the redirect from the OAuth provider after authentication.
|
||||
It processes the response and handles different scenarios:
|
||||
- Successful login/register with redirect_uri: Redirects with authorization code
|
||||
- Successful login/register without redirect_uri: Returns session token
|
||||
- Login with multiple orgs: Returns list of organizations for user to select
|
||||
- Register with no org: Prompts for organization creation/selection
|
||||
|
||||
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:
|
||||
code: Authorization code from provider
|
||||
state: State parameter from OAuth flow
|
||||
redirect_uri: Optional redirect URI for OAuth 2.0 Authorization Code flow
|
||||
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
|
||||
|
||||
Returns:
|
||||
302: Redirect with authorization code (if redirect_uri provided)
|
||||
200: OAuth flow completed successfully (JSON response)
|
||||
400: Validation error, OAuth error, or invalid state
|
||||
404: User account not found (for login flows)
|
||||
|
||||
Response formats (when redirect_uri NOT provided):
|
||||
|
||||
Success with session:
|
||||
{
|
||||
"token": "session_token",
|
||||
"expires_in": 86400,
|
||||
"token_type": "Bearer",
|
||||
"user": {...}
|
||||
}
|
||||
|
||||
Requires organization selection (login flow):
|
||||
{
|
||||
"requires_org_selection": true,
|
||||
"user": {...},
|
||||
"available_organizations": [...],
|
||||
"state": "state_token"
|
||||
}
|
||||
|
||||
Requires organization creation (register flow):
|
||||
{
|
||||
"requires_org_creation": true,
|
||||
"user": {...},
|
||||
"state": "state_token"
|
||||
}
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
from flask import current_app, redirect as flask_redirect
|
||||
|
||||
provider_type = get_provider_type(provider)
|
||||
|
||||
# Get callback parameters
|
||||
authorization_code = request.args.get("code")
|
||||
state = request.args.get("state")
|
||||
authorization_code = request.args.get("code")
|
||||
error = request.args.get("error")
|
||||
error_description = request.args.get("error_description")
|
||||
|
||||
# Get redirect URI from query parameter (for OAuth 2.0 Authorization Code flow)
|
||||
redirect_uri = request.args.get("redirect_uri")
|
||||
frontend_url = current_app.config.get("FRONTEND_URL", "http://localhost:8080")
|
||||
frontend_callback = f"{frontend_url}/oauth/callback"
|
||||
|
||||
def redirect_error(message: str, error_type: str = "OAUTH_ERROR"):
|
||||
"""Redirect to frontend with error params."""
|
||||
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=redirect_uri,
|
||||
error=error,
|
||||
error_description=error_description,
|
||||
redirect_uri=None, # backend handles the full flow
|
||||
error=None,
|
||||
error_description=None,
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
flow_type = result.get("flow_type")
|
||||
|
||||
# Check if we should redirect with authorization code
|
||||
if redirect_uri and flow_type in ["login", "register"]:
|
||||
# Generate authorization code for external application
|
||||
user_id = result.get("user", {}).get("id")
|
||||
if not user_id:
|
||||
# For org selection/creation flows, we can't redirect
|
||||
pass
|
||||
else:
|
||||
# Determine organization_id
|
||||
organization_id = result.get("user", {}).get("organization_id")
|
||||
if not organization_id:
|
||||
# Can't redirect without organization
|
||||
pass
|
||||
else:
|
||||
# Generate authorization code
|
||||
auth_code = OAuthFlowService.generate_authorization_code(
|
||||
user_id=user_id,
|
||||
client_id="external-app",
|
||||
redirect_uri=redirect_uri,
|
||||
scope=["openid", "profile", "email"],
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
lifetime_seconds=600, # 10 minutes
|
||||
)
|
||||
|
||||
# Mark state as used
|
||||
state_record = OAuthFlowService.validate_state(state)
|
||||
if state_record:
|
||||
state_record.mark_used()
|
||||
|
||||
# Redirect with authorization code
|
||||
return OAuthFlowService.create_redirect_response(
|
||||
redirect_uri=redirect_uri,
|
||||
authorization_code=auth_code,
|
||||
state=state,
|
||||
)
|
||||
|
||||
# Handle login flow responses (no redirect_uri or org selection required)
|
||||
if flow_type == "login":
|
||||
# Check if organization selection is required
|
||||
if result.get("requires_org_selection"):
|
||||
return api_response(
|
||||
data={
|
||||
"requires_org_selection": True,
|
||||
"user": result["user"],
|
||||
"available_organizations": result["available_organizations"],
|
||||
"state": result["state"],
|
||||
},
|
||||
message="Please select an organization to continue",
|
||||
status=200,
|
||||
)
|
||||
|
||||
# Normal login with session
|
||||
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",
|
||||
)
|
||||
|
||||
# Handle register flow responses
|
||||
elif flow_type == "register":
|
||||
# Check if organization creation is required
|
||||
if result.get("requires_org_creation"):
|
||||
return api_response(
|
||||
data={
|
||||
"requires_org_creation": True,
|
||||
"user": result["user"],
|
||||
"state": result["state"],
|
||||
},
|
||||
message="Please create or select an organization to continue",
|
||||
status=200,
|
||||
)
|
||||
|
||||
# Normal registration with session
|
||||
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",
|
||||
)
|
||||
|
||||
# Handle link flow responses
|
||||
elif flow_type == "link":
|
||||
return api_response(
|
||||
data={
|
||||
"linked_account": result["linked_account"],
|
||||
},
|
||||
message="Account linked successfully",
|
||||
)
|
||||
if not result.get("success"):
|
||||
return redirect_error("Authentication failed.", "AUTH_FAILED")
|
||||
|
||||
# Fallback for unexpected result format
|
||||
return api_response(
|
||||
data=result,
|
||||
message="OAuth flow completed",
|
||||
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 needed (user belongs to multiple orgs)
|
||||
if result.get("requires_org_selection"):
|
||||
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"):
|
||||
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"]
|
||||
# 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:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=e.message,
|
||||
status=e.status_code,
|
||||
error_type=e.error_type,
|
||||
)
|
||||
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"])
|
||||
|
||||
Reference in New Issue
Block a user