google login works

This commit is contained in:
2026-01-21 03:09:46 +10:30
parent 4cf4a27c9a
commit ae2421763a
8 changed files with 2744 additions and 173 deletions
+537 -38
View File
@@ -1,20 +1,22 @@
"""OAuth flow service for handling external authentication flows."""
import hashlib
import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple
from flask import current_app, request, g
from flask import current_app, request, g, redirect
from gatehouse_app.extensions import db
from gatehouse_app.models import User, AuthenticationMethod
from gatehouse_app.models.authentication_method import OAuthState
from gatehouse_app.models.base import BaseModel
from gatehouse_app.models.oidc_authorization_code import OIDCAuthCode
from gatehouse_app.utils.constants import AuthMethodType
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services.external_auth_service import (
ExternalAuthService,
ExternalAuthError,
OAuthState,
ExternalProviderConfig,
)
@@ -43,11 +45,14 @@ class OAuthFlowService:
state_data: dict = None,
) -> Tuple[str, str]:
"""
Initiate OAuth login flow.
Initiate OAuth login flow without requiring organization_id upfront.
This method initiates the OAuth flow using application-wide provider configuration.
The organization context is determined after successful authentication.
Args:
provider_type: The authentication provider type
organization_id: Optional organization context for SSO
organization_id: Optional organization hint for SSO discovery
redirect_uri: Optional custom redirect URI
state_data: Additional state data to include
@@ -65,8 +70,8 @@ class OAuthFlowService:
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
try:
# Get provider config
config = ExternalAuthService.get_provider_config(organization_id, provider_type)
# Get provider config (application-wide, no organization required)
config = ExternalAuthService.get_provider_config(provider_type, organization_id)
# Validate redirect URI
if redirect_uri and not config.is_redirect_uri_allowed(redirect_uri):
@@ -76,9 +81,19 @@ class OAuthFlowService:
400,
)
# Generate PKCE
code_verifier = secrets.token_urlsafe(32)
code_challenge = ExternalAuthService._compute_s256_challenge(code_verifier)
# Generate PKCE parameters (Google web applications don't use PKCE)
code_verifier = None
code_challenge = None
if provider_type_str not in ['google']:
code_verifier = secrets.token_urlsafe(32)
code_challenge = ExternalAuthService._compute_s256_challenge(code_verifier)
# DIAGNOSTIC LOGGING: Show PKCE decision
logger.info(
f"[PKCE DEBUG] Provider type check: provider_type_str='{provider_type_str}', "
f"is_google={provider_type_str in ['google']}, "
f"will_skip_pkce={provider_type_str in ['google']}"
)
# Create OAuth state for login flow
state = OAuthState.create_state(
@@ -92,6 +107,15 @@ class OAuthFlowService:
lifetime_seconds=600,
)
# DIAGNOSTIC LOGGING: Verify state object
logger.info(
f"[PKCE DEBUG] Created OAuthState object:\n"
f" state.id: {state.id}\n"
f" state.provider_type: {state.provider_type}\n"
f" state.code_challenge: {state.code_challenge}\n"
f" state.code_verifier: {state.code_verifier[:20] if state.code_verifier else None}..."
)
# Build authorization URL
auth_url = ExternalAuthService._build_authorization_url(
config=config,
@@ -100,7 +124,13 @@ class OAuthFlowService:
logger.info(
f"OAuth login flow initiated for provider={provider_type_str}, "
f"org_id={organization_id}, state_id={state.id}"
f"org_id={organization_id}, state_token={state.state}, state_record_id={state.id}"
)
logger.info(
f"[PKCE DEBUG] FINAL CHECK: code_challenge={code_challenge}, "
f"code_verifier={code_verifier[:20] if code_verifier else None}..., "
f"auth_url_has_challenge={'code_challenge=' in auth_url}, "
f"returned_auth_url={auth_url}"
)
return auth_url, state.state
@@ -129,11 +159,11 @@ class OAuthFlowService:
redirect_uri: str = None,
) -> Tuple[str, str]:
"""
Initiate OAuth registration flow.
Initiate OAuth registration flow without requiring organization_id upfront.
Args:
provider_type: The authentication provider type
organization_id: Optional organization context
organization_id: Optional organization hint
redirect_uri: Optional custom redirect URI
Returns:
@@ -142,8 +172,8 @@ class OAuthFlowService:
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
try:
# Get provider config
config = ExternalAuthService.get_provider_config(organization_id, provider_type)
# Get provider config (application-wide, no organization required)
config = ExternalAuthService.get_provider_config(provider_type, organization_id)
# Validate redirect URI
if redirect_uri and not config.is_redirect_uri_allowed(redirect_uri):
@@ -153,9 +183,19 @@ class OAuthFlowService:
400,
)
# Generate PKCE
code_verifier = secrets.token_urlsafe(32)
code_challenge = ExternalAuthService._compute_s256_challenge(code_verifier)
# Generate PKCE parameters (Google web applications don't use PKCE)
code_verifier = None
code_challenge = None
if provider_type_str not in ['google']:
code_verifier = secrets.token_urlsafe(32)
code_challenge = ExternalAuthService._compute_s256_challenge(code_verifier)
# DIAGNOSTIC LOGGING: Show PKCE decision for register flow
logger.info(
f"[PKCE DEBUG] Register flow - Provider type check: provider_type_str='{provider_type_str}', "
f"is_google={provider_type_str in ['google']}, "
f"will_skip_pkce={provider_type_str in ['google']}"
)
# Create OAuth state for register flow
state = OAuthState.create_state(
@@ -168,6 +208,14 @@ class OAuthFlowService:
lifetime_seconds=600,
)
# DIAGNOSTIC LOGGING: Verify state object for register flow
logger.info(
f"[PKCE DEBUG] Register flow - Created OAuthState:\n"
f" state.id: {state.id}\n"
f" state.code_challenge: {state.code_challenge}\n"
f" state.code_verifier: {state.code_verifier[:20] if state.code_verifier else None}..."
)
# Build authorization URL
auth_url = ExternalAuthService._build_authorization_url(
config=config,
@@ -178,6 +226,9 @@ class OAuthFlowService:
f"OAuth register flow initiated for provider={provider_type_str}, "
f"org_id={organization_id}, state_id={state.id}"
)
logger.info(
f"[PKCE DEBUG] Register flow - FINAL: auth_url_has_challenge={'code_challenge=' in auth_url}"
)
return auth_url, state.state
@@ -245,6 +296,17 @@ class OAuthFlowService:
# Validate state
state_record = OAuthState.query.filter_by(state=state).first()
# Log validation details for debugging
if state_record:
logger.debug(
f"State validation: found=True, used={state_record.used}, "
f"expires_at={state_record.expires_at}, now={datetime.now(timezone.utc)}, "
f"is_valid={state_record.is_valid()}"
)
else:
logger.warning(f"State validation: state token not found in database: {state}")
if not state_record or not state_record.is_valid():
AuditService.log_external_auth_login_failed(
organization_id=state_record.organization_id if state_record else None,
@@ -299,24 +361,175 @@ class OAuthFlowService:
ip_address: str = None,
user_agent: str = None,
) -> dict:
"""Handle login flow callback."""
"""
Handle login flow callback with organization discovery.
This method:
1. Exchanges the authorization code for tokens
2. Gets user info from the OAuth provider
3. Looks up the user by provider_user_id
4. Determines which organization(s) the user belongs to
5. Creates a session or returns org selection needed
"""
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
try:
# Authenticate with provider
user, session_data = ExternalAuthService.authenticate_with_provider(
provider_type=provider_type,
organization_id=state_record.organization_id,
authorization_code=authorization_code,
state=state_record.state,
# Get provider config (application-wide)
config = ExternalAuthService.get_provider_config(
provider_type, state_record.organization_id
)
logger.debug(
f"Exchanging code with PKCE: state_record.code_verifier={state_record.code_verifier[:20] if state_record.code_verifier else None}..."
)
# Exchange code for tokens
tokens = ExternalAuthService._exchange_code(
config=config,
code=authorization_code,
redirect_uri=redirect_uri,
code_verifier=state_record.code_verifier,
)
# Get user info from provider
user_info = ExternalAuthService._get_user_info(
config=config,
access_token=tokens["access_token"],
)
# Look up user by provider_user_id
auth_method = AuthenticationMethod.query.filter_by(
method_type=provider_type,
provider_user_id=user_info["provider_user_id"],
).first()
if not auth_method:
# User doesn't exist - check if email matches existing user
existing_user = User.query.filter_by(
email=user_info["email"]
).first()
if existing_user:
AuditService.log_external_auth_login_failed(
organization_id=state_record.organization_id,
provider_type=provider_type_str,
provider_user_id=user_info["provider_user_id"],
email=user_info["email"],
failure_reason="email_exists",
error_message=f"An account with email {user_info['email']} already exists",
)
raise OAuthFlowError(
f"An account with email {user_info['email']} already exists. "
"Please log in with your password and link your account from settings.",
"EMAIL_EXISTS",
400,
)
AuditService.log_external_auth_login_failed(
organization_id=state_record.organization_id,
provider_type=provider_type_str,
provider_user_id=user_info["provider_user_id"],
email=user_info["email"],
failure_reason="account_not_found",
error_message="No Gatehouse account matches this external account",
)
raise OAuthFlowError(
"No Gatehouse account matches this external account. Please register first.",
"ACCOUNT_NOT_FOUND",
404,
)
user = auth_method.user
# Update provider data
auth_method.provider_data = ExternalAuthService._encrypt_provider_data(
tokens, user_info
)
auth_method.last_used_at = datetime.utcnow()
auth_method.save()
# Get user's organizations
user_orgs = user.get_organizations()
# Determine target organization
target_org = None
# Priority 1: Use organization_id from state if provided (org hint)
if state_record.organization_id:
target_org = next(
(org for org in user_orgs if org.id == state_record.organization_id),
None
)
# Priority 2: If user has exactly one organization, use it
if not target_org and len(user_orgs) == 1:
target_org = user_orgs[0]
# Priority 3: No organization or multiple organizations - need selection
if not target_org:
# Mark state as used
state_record.mark_used()
logger.info(
f"OAuth login requires org selection for user={user.id}, "
f"provider={provider_type_str}, org_count={len(user_orgs)}"
)
return {
"success": True,
"flow_type": "login",
"requires_org_selection": True,
"user": {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
},
"available_organizations": [
{
"id": org.id,
"name": org.name,
"slug": org.slug if hasattr(org, 'slug') else None,
}
for org in user_orgs
],
"state": state_record.state,
}
# Create session for the target org
from gatehouse_app.services.auth_service import AuthService
session = AuthService.create_session(
user=user,
is_compliance_only=False,
)
# Mark state as used
state_record.mark_used()
# Audit log - login success
AuditService.log_external_auth_login(
user_id=user.id,
organization_id=target_org.id,
provider_type=provider_type_str,
provider_user_id=user_info["provider_user_id"],
auth_method_id=auth_method.id,
session_id=session.id,
)
logger.info(
f"OAuth login successful for user={user.id}, "
f"provider={provider_type_str}, org_id={state_record.organization_id}"
f"provider={provider_type_str}, org_id={target_org.id}"
)
# Build session dict with token (to_dict() excludes token for security)
session_dict = session.to_dict()
session_dict["token"] = session.token
# Calculate expires_in handling naive datetime from database
expires_at = session.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
session_dict["expires_in"] = int((expires_at - now).total_seconds())
return {
"success": True,
"flow_type": "login",
@@ -324,9 +537,9 @@ class OAuthFlowService:
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"organization_id": state_record.organization_id,
"organization_id": target_org.id,
},
"session": session_data,
"session": session_dict,
}
except ExternalAuthError as e:
@@ -335,6 +548,19 @@ class OAuthFlowService:
f"provider={provider_type_str}, error={e.message}"
)
raise
except OAuthFlowError:
# Re-raise OAuthFlowError as-is
raise
except Exception as e:
logger.error(
f"Unexpected error in OAuth login callback: {str(e)}",
exc_info=True
)
raise OAuthFlowError(
"An unexpected error occurred during login",
"INTERNAL_ERROR",
500,
)
@classmethod
def _handle_link_callback(
@@ -387,13 +613,17 @@ class OAuthFlowService:
authorization_code: str,
redirect_uri: str,
) -> dict:
"""Handle registration flow callback."""
"""
Handle registration flow callback.
Creates a new user account and prompts for organization creation/selection.
"""
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
try:
# Get provider config
# Get provider config (application-wide)
config = ExternalAuthService.get_provider_config(
state_record.organization_id, provider_type
provider_type, state_record.organization_id
)
# Exchange code for tokens
@@ -429,6 +659,7 @@ class OAuthFlowService:
email=user_info["email"],
full_name=user_info.get("name", ""),
status="active",
email_verified=user_info.get("email_verified", False),
)
user.save()
@@ -440,6 +671,7 @@ class OAuthFlowService:
provider_data=ExternalAuthService._encrypt_provider_data(tokens, user_info),
verified=user_info.get("email_verified", False),
is_primary=True,
last_used_at=datetime.utcnow(),
)
auth_method.save()
@@ -475,23 +707,48 @@ class OAuthFlowService:
f"provider={provider_type_str}, user_id={user.id}"
)
# Create session
from gatehouse_app.services.auth_service import AuthService
session = AuthService.create_session(
user=user,
organization_id=state_record.organization_id,
)
# If organization_id hint was provided and valid, create session for that org
if state_record.organization_id:
from gatehouse_app.models.organization import Organization
org = Organization.query.get(state_record.organization_id)
if org:
from gatehouse_app.services.auth_service import AuthService
session = AuthService.create_session(
user=user,
is_compliance_only=False,
)
# Build session dict with token (to_dict() excludes token for security)
session_dict = session.to_dict()
session_dict["token"] = session.token
# Calculate expires_in handling naive datetime from database
expires_at = session.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
session_dict["expires_in"] = int((expires_at - now).total_seconds())
return {
"success": True,
"flow_type": "register",
"user": {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"organization_id": org.id,
},
"session": session_dict,
}
# No organization hint or invalid - need to create/select org
return {
"success": True,
"flow_type": "register",
"requires_org_creation": True,
"user": {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"organization_id": state_record.organization_id,
},
"session": session.to_dict(),
"state": state_record.state,
}
except ExternalAuthError as e:
@@ -500,6 +757,19 @@ class OAuthFlowService:
f"provider={provider_type_str}, error={e.message}"
)
raise
except OAuthFlowError:
# Re-raise OAuthFlowError as-is
raise
except Exception as e:
logger.error(
f"Unexpected error in OAuth registration callback: {str(e)}",
exc_info=True
)
raise OAuthFlowError(
"An unexpected error occurred during registration",
"INTERNAL_ERROR",
500,
)
@classmethod
def validate_state(cls, state: str) -> Optional[OAuthState]:
@@ -522,3 +792,232 @@ class OAuthFlowService:
"""Remove expired OAuth states."""
OAuthState.cleanup_expired()
logger.info("Expired OAuth states cleaned up")
@classmethod
def generate_authorization_code(
cls,
user_id: str,
client_id: str,
redirect_uri: str,
scope: list = None,
nonce: str = None,
ip_address: str = None,
user_agent: str = None,
lifetime_seconds: int = 600,
) -> str:
"""
Generate an authorization code for external OAuth applications.
This method creates a short-lived, single-use authorization code that can be
exchanged for a session token by external applications like oauth2-proxy.
Args:
user_id: The user ID
client_id: The client ID (e.g., 'oauth2-proxy', 'bookstack')
redirect_uri: The redirect URI
scope: Requested scopes
nonce: OIDC nonce for validation
ip_address: Client IP address
user_agent: Client user agent
lifetime_seconds: Code lifetime in seconds (default 10 minutes)
Returns:
The authorization code (plain text, not hashed)
"""
# Generate a secure random code
code = secrets.token_urlsafe(32)
code_hash = hashlib.sha256(code.encode()).hexdigest()
# Create the authorization code record
OIDCAuthCode.create_code(
client_id=client_id,
user_id=user_id,
code_hash=code_hash,
redirect_uri=redirect_uri,
scope=scope,
nonce=nonce,
ip_address=ip_address,
user_agent=user_agent,
lifetime_seconds=lifetime_seconds,
)
logger.info(
f"Generated authorization code for user={user_id}, client={client_id}"
)
return code
@classmethod
def exchange_authorization_code(
cls,
code: str,
client_id: str,
redirect_uri: str,
ip_address: str = None,
) -> dict:
"""
Exchange an authorization code for a session token.
This method validates and consumes the authorization code, then creates
a session for the user.
Args:
code: The authorization code
client_id: The client ID
redirect_uri: The redirect URI (must match original request)
ip_address: Client IP address
Returns:
Dict with session token and user info
"""
# Hash the provided code for lookup
code_hash = hashlib.sha256(code.encode()).hexdigest()
# Find the authorization code record
auth_code = OIDCAuthCode.query.filter_by(
client_id=client_id,
code_hash=code_hash,
).first()
if not auth_code:
raise OAuthFlowError(
"Invalid authorization code",
"INVALID_CODE",
400,
)
# Validate the code
if not auth_code.is_valid():
if auth_code.is_used:
raise OAuthFlowError(
"Authorization code has already been used",
"CODE_USED",
400,
)
else:
raise OAuthFlowError(
"Authorization code has expired",
"CODE_EXPIRED",
400,
)
# Validate redirect URI
if auth_code.redirect_uri != redirect_uri:
raise OAuthFlowError(
"Redirect URI mismatch",
"INVALID_REDIRECT_URI",
400,
)
# Get the user
from gatehouse_app.models import User
user = User.query.get(auth_code.user_id)
if not user:
raise OAuthFlowError(
"User not found",
"USER_NOT_FOUND",
404,
)
# Determine organization
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization_member import OrganizationMember
# Get user's organizations
user_orgs = user.get_organizations()
# Determine target organization
target_org = None
# Priority 1: Use organization_id from auth code if available
# Priority 2: If user has exactly one organization, use it
if not target_org and len(user_orgs) == 1:
target_org = user_orgs[0]
if not target_org:
raise OAuthFlowError(
"User does not have a default organization. Organization selection required.",
"ORG_SELECTION_REQUIRED",
400,
)
# Create session
from gatehouse_app.services.auth_service import AuthService
session = AuthService.create_session(
user=user,
is_compliance_only=False,
)
# Mark the code as used
auth_code.mark_as_used()
# Build session dict
session_dict = session.to_dict()
session_dict["token"] = session.token
expires_at = session.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
session_dict["expires_in"] = int((expires_at - now).total_seconds())
logger.info(
f"Authorization code exchanged for session: user={user.id}, "
f"org_id={target_org.id}, client={client_id}"
)
return {
"success": True,
"token": session_dict["token"],
"expires_in": session_dict["expires_in"],
"token_type": "Bearer",
"user": {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"organization_id": target_org.id,
},
}
@classmethod
def create_redirect_response(
cls,
redirect_uri: str,
authorization_code: str,
state: str = None,
):
"""
Create a redirect response with authorization code.
Args:
redirect_uri: The redirect URI
authorization_code: The authorization code
state: Optional state parameter
Returns:
Flask redirect response
"""
from urllib.parse import urlencode, urlparse, urlunparse
# Parse the redirect URI
parsed = urlparse(redirect_uri)
# Build query parameters
params = {"code": authorization_code}
if state:
params["state"] = state
# Reconstruct URL with query parameters
redirect_url = urlunparse((
parsed.scheme,
parsed.netloc,
parsed.path,
parsed.params,
urlencode(params),
parsed.fragment,
))
logger.info(
f"Redirecting to {parsed.scheme}://{parsed.netloc} with authorization code"
)
return redirect(redirect_url)