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:
2026-02-26 23:18:31 +05:45
parent f1fff22f3e
commit 1ba5738d52
14 changed files with 732 additions and 349 deletions
@@ -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"),
+91 -45
View File
@@ -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
],
+33 -19
View File
@@ -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,
)
+3 -2
View File
@@ -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: