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:
@@ -1106,6 +1106,7 @@ class ExternalAuthService:
|
||||
def _build_authorization_url(config: ProviderConfigAdapter, state: OAuthState) -> str:
|
||||
"""Build authorization URL using the provider config adapter."""
|
||||
from urllib.parse import urlencode
|
||||
provider = (config.provider_type or "").lower()
|
||||
|
||||
params = {
|
||||
"client_id": config.client_id,
|
||||
@@ -1113,10 +1114,26 @@ class ExternalAuthService:
|
||||
"response_type": "code",
|
||||
"scope": " ".join(config.scopes or ["openid", "profile", "email"]),
|
||||
"state": state.state,
|
||||
"access_type": config.settings.get("access_type", "offline") if config.settings else "offline",
|
||||
"prompt": config.settings.get("prompt", "consent") if config.settings else "consent",
|
||||
}
|
||||
|
||||
if provider == "google":
|
||||
params["access_type"] = (
|
||||
config.settings.get("access_type", "offline") if config.settings else "offline"
|
||||
)
|
||||
params["prompt"] = (
|
||||
config.settings.get("prompt", "consent") if config.settings else "consent"
|
||||
)
|
||||
elif provider == "microsoft":
|
||||
params["prompt"] = (
|
||||
config.settings.get("prompt", "select_account") if config.settings else "select_account"
|
||||
)
|
||||
else:
|
||||
if config.settings:
|
||||
if "prompt" in config.settings:
|
||||
params["prompt"] = config.settings["prompt"]
|
||||
if "access_type" in config.settings:
|
||||
params["access_type"] = config.settings["access_type"]
|
||||
|
||||
if state.nonce:
|
||||
params["nonce"] = state.nonce
|
||||
|
||||
@@ -1178,17 +1195,26 @@ class ExternalAuthService:
|
||||
"""Get user info from provider using the provider config adapter."""
|
||||
import requests
|
||||
|
||||
provider = (config.provider_type or "").lower()
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
response = requests.get(config.userinfo_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Microsoft's /oidc/userinfo endpoint returns verified email addresses
|
||||
# (all AAD accounts are verified) but may omit the email_verified claim.
|
||||
# Default to True for Microsoft so users aren't stuck with unverified state.
|
||||
if provider == "microsoft":
|
||||
email_verified = data.get("email_verified", True)
|
||||
else:
|
||||
email_verified = data.get("email_verified", False)
|
||||
|
||||
# Standardize user info
|
||||
return {
|
||||
"provider_user_id": data.get("sub"),
|
||||
"email": data.get("email"),
|
||||
"email_verified": data.get("email_verified", False),
|
||||
"email_verified": email_verified,
|
||||
"name": data.get("name"),
|
||||
"first_name": data.get("given_name"),
|
||||
"last_name": data.get("family_name"),
|
||||
|
||||
@@ -81,10 +81,11 @@ class OAuthFlowService:
|
||||
400,
|
||||
)
|
||||
|
||||
# Generate PKCE parameters (Google web applications don't use PKCE)
|
||||
# Generate PKCE parameters (Google and Microsoft web applications don't use PKCE
|
||||
# when a client_secret is present — they are confidential clients)
|
||||
code_verifier = None
|
||||
code_challenge = None
|
||||
if provider_type_str not in ['google']:
|
||||
if provider_type_str not in ['google', 'microsoft']:
|
||||
code_verifier = secrets.token_urlsafe(32)
|
||||
code_challenge = ExternalAuthService._compute_s256_challenge(code_verifier)
|
||||
|
||||
@@ -92,7 +93,7 @@ class OAuthFlowService:
|
||||
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']}"
|
||||
f"will_skip_pkce={provider_type_str in ['google', 'microsoft']}"
|
||||
)
|
||||
|
||||
# Create OAuth state for login flow
|
||||
@@ -183,10 +184,11 @@ class OAuthFlowService:
|
||||
400,
|
||||
)
|
||||
|
||||
# Generate PKCE parameters (Google web applications don't use PKCE)
|
||||
# Generate PKCE parameters (Google and Microsoft web applications don't use PKCE
|
||||
# when a client_secret is present — they are confidential clients)
|
||||
code_verifier = None
|
||||
code_challenge = None
|
||||
if provider_type_str not in ['google']:
|
||||
if provider_type_str not in ['google', 'microsoft']:
|
||||
code_verifier = secrets.token_urlsafe(32)
|
||||
code_challenge = ExternalAuthService._compute_s256_challenge(code_verifier)
|
||||
|
||||
@@ -194,7 +196,7 @@ class OAuthFlowService:
|
||||
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']}"
|
||||
f"will_skip_pkce={provider_type_str in ['google', 'microsoft']}"
|
||||
)
|
||||
|
||||
# Create OAuth state for register flow
|
||||
@@ -404,77 +406,121 @@ class OAuthFlowService:
|
||||
).first()
|
||||
|
||||
if not auth_method:
|
||||
# User doesn't exist - check if email matches existing user
|
||||
# No linked account found — check if email matches an 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,
|
||||
# Email exists but no OAuth link — auto-link and log in
|
||||
logger.info(
|
||||
f"OAuth login: email {user_info['email']} matches existing user "
|
||||
f"{existing_user.id}, auto-linking {provider_type_str} account"
|
||||
)
|
||||
auth_method = AuthenticationMethod(
|
||||
user_id=existing_user.id,
|
||||
method_type=provider_type,
|
||||
provider_user_id=user_info["provider_user_id"],
|
||||
provider_data=ExternalAuthService._encrypt_provider_data(tokens, user_info),
|
||||
verified=user_info.get("email_verified", False),
|
||||
is_primary=False,
|
||||
last_used_at=datetime.utcnow(),
|
||||
)
|
||||
auth_method.save()
|
||||
user = existing_user
|
||||
else:
|
||||
# Brand-new user — auto-register via OAuth (standard behaviour)
|
||||
logger.info(
|
||||
f"OAuth login: no account for {user_info['email']}, "
|
||||
f"auto-creating user via {provider_type_str}"
|
||||
)
|
||||
user = User(
|
||||
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,
|
||||
full_name=user_info.get("name", ""),
|
||||
status="active",
|
||||
email_verified=user_info.get("email_verified", False),
|
||||
)
|
||||
user.save()
|
||||
|
||||
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,
|
||||
auth_method = AuthenticationMethod(
|
||||
user_id=user.id,
|
||||
method_type=provider_type,
|
||||
provider_user_id=user_info["provider_user_id"],
|
||||
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()
|
||||
|
||||
AuditService.log_action(
|
||||
action="user.register",
|
||||
user_id=user.id,
|
||||
organization_id=state_record.organization_id,
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
metadata={
|
||||
"provider_type": provider_type_str,
|
||||
"provider_user_id": user_info["provider_user_id"],
|
||||
"auto_registered": True,
|
||||
},
|
||||
description=f"User auto-registered via {provider_type_str} OAuth",
|
||||
success=True,
|
||||
)
|
||||
else:
|
||||
# Existing linked account — update provider data
|
||||
auth_method.provider_data = ExternalAuthService._encrypt_provider_data(
|
||||
tokens, user_info
|
||||
)
|
||||
auth_method.last_used_at = datetime.utcnow()
|
||||
auth_method.save()
|
||||
|
||||
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
|
||||
|
||||
# Priority 3: No orgs at all — auto-create a personal org and log in
|
||||
if not target_org and len(user_orgs) == 0:
|
||||
import re
|
||||
import uuid
|
||||
from gatehouse_app.services.organization_service import OrganizationService
|
||||
org_name = f"{user_info.get('name') or user.email.split('@')[0]}'s Workspace"
|
||||
# Build a URL-safe slug and ensure uniqueness with a short suffix
|
||||
base_slug = re.sub(r"[^a-z0-9]+", "-", org_name.lower()).strip("-")[:40]
|
||||
slug = f"{base_slug}-{uuid.uuid4().hex[:6]}"
|
||||
org = OrganizationService.create_organization(
|
||||
name=org_name,
|
||||
slug=slug,
|
||||
owner_user_id=user.id,
|
||||
)
|
||||
target_org = org
|
||||
logger.info(
|
||||
f"OAuth login: auto-created org '{org.name}' (id={org.id}) "
|
||||
f"for new user {user.id}"
|
||||
)
|
||||
|
||||
# Priority 4: Multiple orgs — need user to pick one
|
||||
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",
|
||||
@@ -488,7 +534,7 @@ class OAuthFlowService:
|
||||
{
|
||||
"id": org.id,
|
||||
"name": org.name,
|
||||
"slug": org.slug if hasattr(org, 'slug') else None,
|
||||
"slug": org.slug if hasattr(org, "slug") else None,
|
||||
}
|
||||
for org in user_orgs
|
||||
],
|
||||
|
||||
@@ -190,9 +190,9 @@ class OIDCService:
|
||||
logger.debug("[OIDC SERVICE] Auth code expires_at (UTC): %s", auth_code.expires_at.isoformat() + "Z")
|
||||
logger.debug("[OIDC SERVICE] Current UTC time after creating auth code: %s", datetime.now(timezone.utc).isoformat() + "Z")
|
||||
|
||||
# Log authorization event
|
||||
# Log authorization event — use client.id (UUID) not client_id (string) for FK
|
||||
OIDCAuditService.log_authorization_event(
|
||||
client_id=client_id,
|
||||
client_id=client.id,
|
||||
user_id=user_id,
|
||||
success=True,
|
||||
redirect_uri=redirect_uri,
|
||||
@@ -264,7 +264,7 @@ class OIDCService:
|
||||
if not auth_code:
|
||||
logger.error(f"[OIDC] Validate auth code - Code not found or deleted: code_hash={code_hash[:20]}...")
|
||||
OIDCAuditService.log_authorization_event(
|
||||
client_id=client_id,
|
||||
client_id=client.id,
|
||||
success=False,
|
||||
error_code="invalid_grant",
|
||||
error_description="Invalid or expired authorization code",
|
||||
@@ -275,7 +275,7 @@ class OIDCService:
|
||||
if auth_code.is_used:
|
||||
logger.error(f"[OIDC] Validate auth code - Code already used: code_hash={code_hash[:20]}..., user_id={auth_code.user_id}")
|
||||
OIDCAuditService.log_authorization_event(
|
||||
client_id=client_id,
|
||||
client_id=client.id,
|
||||
user_id=auth_code.user_id,
|
||||
success=False,
|
||||
error_code="invalid_grant",
|
||||
@@ -297,7 +297,7 @@ class OIDCService:
|
||||
logger.error("[OIDC] Validate auth code - Code expired: code_hash=%s..., expires_at (UTC)=%s, current UTC time=%s",
|
||||
code_hash[:20], auth_code.expires_at.isoformat() + "Z", datetime.now(timezone.utc).isoformat() + "Z")
|
||||
OIDCAuditService.log_authorization_event(
|
||||
client_id=client_id,
|
||||
client_id=client.id,
|
||||
user_id=auth_code.user_id,
|
||||
success=False,
|
||||
error_code="invalid_grant",
|
||||
@@ -324,7 +324,7 @@ class OIDCService:
|
||||
if expected_challenge != auth_code.code_verifier:
|
||||
logger.error(f"[OIDC] Validate auth code - Invalid code_verifier: expected={expected_challenge[:20]}..., got={auth_code.code_verifier[:20]}...")
|
||||
OIDCAuditService.log_authorization_event(
|
||||
client_id=client_id,
|
||||
client_id=client.id,
|
||||
user_id=auth_code.user_id,
|
||||
success=False,
|
||||
error_code="invalid_grant",
|
||||
@@ -526,9 +526,9 @@ class OIDCService:
|
||||
expires_at=id_token_expires_at,
|
||||
)
|
||||
|
||||
# Log token event
|
||||
# Log token event — use client.id (UUID) not client_id (string) for FK
|
||||
OIDCAuditService.log_token_event(
|
||||
client_id=client_id,
|
||||
client_id=client.id,
|
||||
user_id=user_id,
|
||||
token_type="access_token",
|
||||
success=True,
|
||||
@@ -607,7 +607,7 @@ class OIDCService:
|
||||
|
||||
if not refresh_token_obj:
|
||||
OIDCAuditService.log_token_event(
|
||||
client_id=client_id,
|
||||
client_id=client.id,
|
||||
success=False,
|
||||
error_code="invalid_grant",
|
||||
error_description="Invalid refresh token",
|
||||
@@ -627,7 +627,7 @@ class OIDCService:
|
||||
|
||||
if not refresh_token_obj.is_valid():
|
||||
OIDCAuditService.log_token_event(
|
||||
client_id=client_id,
|
||||
client_id=client.id,
|
||||
user_id=refresh_token_obj.user_id,
|
||||
success=False,
|
||||
error_code="invalid_grant",
|
||||
@@ -694,9 +694,9 @@ class OIDCService:
|
||||
expires_at=access_token_expires_at,
|
||||
)
|
||||
|
||||
# Log refresh event
|
||||
# Log refresh event — use client.id (UUID) not client_id (string) for FK
|
||||
OIDCAuditService.log_token_event(
|
||||
client_id=client_id,
|
||||
client_id=client.id,
|
||||
user_id=refresh_token_obj.user_id,
|
||||
token_type="access_token",
|
||||
success=True,
|
||||
@@ -754,9 +754,14 @@ class OIDCService:
|
||||
logger.error("[OIDC SERVICE] Token validation failed: %s: %s", type(e).__name__, str(e))
|
||||
import traceback
|
||||
logger.error("[OIDC SERVICE] Traceback: %s", traceback.format_exc())
|
||||
# Resolve internal client UUID for FK if possible
|
||||
_client_db_id = None
|
||||
if client_id:
|
||||
_c = OIDCClient.query.filter_by(client_id=client_id).first()
|
||||
_client_db_id = _c.id if _c else None
|
||||
OIDCAuditService.log_event(
|
||||
event_type="token_validation",
|
||||
client_id=client_id,
|
||||
client_id=_client_db_id,
|
||||
success=False,
|
||||
error_code="invalid_token",
|
||||
error_description=str(e),
|
||||
@@ -806,7 +811,7 @@ class OIDCService:
|
||||
revoked = True
|
||||
|
||||
OIDCAuditService.log_token_revocation_event(
|
||||
client_id=client_id,
|
||||
client_id=client.id,
|
||||
user_id=refresh_token.user_id,
|
||||
token_type="refresh_token",
|
||||
reason="revoked_by_client",
|
||||
@@ -828,7 +833,7 @@ class OIDCService:
|
||||
revoked = True
|
||||
|
||||
OIDCAuditService.log_token_revocation_event(
|
||||
client_id=client_id,
|
||||
client_id=client.id,
|
||||
user_id=claims.get("sub"),
|
||||
token_type="access_token",
|
||||
reason="revoked_by_client",
|
||||
@@ -859,10 +864,14 @@ class OIDCService:
|
||||
"""
|
||||
result = OIDCTokenService.introspect_token(token, client_id)
|
||||
|
||||
# Log introspection
|
||||
# Log introspection — resolve internal client UUID for FK
|
||||
_introspect_client_db_id = None
|
||||
if client_id:
|
||||
_ic = OIDCClient.query.filter_by(client_id=client_id).first()
|
||||
_introspect_client_db_id = _ic.id if _ic else None
|
||||
OIDCAuditService.log_event(
|
||||
event_type="token_introspection",
|
||||
client_id=client_id,
|
||||
client_id=_introspect_client_db_id,
|
||||
user_id=result.get("sub"),
|
||||
success=result.get("active", False),
|
||||
metadata={"active": result.get("active")},
|
||||
@@ -949,12 +958,17 @@ class OIDCService:
|
||||
|
||||
logger.debug("[OIDC SERVICE] Final userinfo: %s", userinfo)
|
||||
|
||||
# Log userinfo access
|
||||
# Log userinfo access — resolve internal client UUID for FK
|
||||
logger.debug("[OIDC SERVICE] Logging userinfo access event...")
|
||||
_userinfo_client_id_str = claims.get("client_id")
|
||||
_userinfo_client_db_id = None
|
||||
if _userinfo_client_id_str:
|
||||
_uc = OIDCClient.query.filter_by(client_id=_userinfo_client_id_str).first()
|
||||
_userinfo_client_db_id = _uc.id if _uc else None
|
||||
OIDCAuditService.log_userinfo_event(
|
||||
access_token=access_token,
|
||||
user_id=user_id,
|
||||
client_id=claims.get("client_id"),
|
||||
client_id=_userinfo_client_db_id,
|
||||
success=True,
|
||||
scopes_claimed=scopes,
|
||||
)
|
||||
|
||||
@@ -164,9 +164,10 @@ class TOTPService:
|
||||
be used again. This ensures each code is single-use.
|
||||
"""
|
||||
remaining_codes = []
|
||||
matched = False
|
||||
|
||||
for hashed_code in hashed_codes:
|
||||
if bcrypt.check_password_hash(hashed_code, code):
|
||||
if not matched and bcrypt.check_password_hash(hashed_code, code):
|
||||
# Code found and valid - mark as matched but don't add to remaining codes
|
||||
matched = True
|
||||
else:
|
||||
@@ -176,7 +177,7 @@ class TOTPService:
|
||||
if matched:
|
||||
return True, remaining_codes
|
||||
else:
|
||||
return False, remaining_codes
|
||||
return False, hashed_codes
|
||||
|
||||
@staticmethod
|
||||
def generate_qr_code_data_uri(provisioning_uri: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user