Feat(Chore, Fix): Refractor, Half Baked Deletion + Admin Privilege

Refractor Codes into sub file/folders
Admin can remove users'/members mfa/2fa, unlink account from  oauth provider
Admin can  add/reset password
Different Email (OIDC + Manual)-Same Account; (Block Linking and authorize if available)
This commit is contained in:
2026-03-04 18:49:04 +05:45
parent ea1bacc794
commit 7cb522b590
63 changed files with 7896 additions and 10863 deletions
+150
View File
@@ -0,0 +1,150 @@
"""OIDCService — public facade over the oidc sub-package."""
import logging
from typing import Dict, List, Optional, Tuple
from gatehouse_app.exceptions.auth_exceptions import InvalidTokenError
logger = logging.getLogger(__name__)
class OIDCError(Exception):
def __init__(self, error: str, error_description: str = None, status_code: int = 400):
self.error = error
self.error_description = error_description
self.status_code = status_code
class InvalidClientError(OIDCError):
def __init__(self, error_description: str = "Invalid client"):
super().__init__("invalid_client", error_description, 401)
class InvalidGrantError(OIDCError):
def __init__(self, error_description: str = "Invalid grant"):
super().__init__("invalid_grant", error_description, 400)
class InvalidRequestError(OIDCError):
def __init__(self, error_description: str = "Invalid request"):
super().__init__("invalid_request", error_description, 400)
from gatehouse_app.services.oidc import auth_code as _auth_code
from gatehouse_app.services.oidc import tokens as _tokens
from gatehouse_app.services.oidc import userinfo as _userinfo
class OIDCService:
"""Main OIDC service handling all OpenID Connect operations."""
@staticmethod
def _generate_code() -> str:
import secrets
return secrets.token_urlsafe(32)
@staticmethod
def _hash_value(value: str) -> str:
import hashlib
return hashlib.sha256(value.encode()).hexdigest()
@classmethod
def generate_authorization_code(
cls,
client_id: str,
user_id: str,
redirect_uri: str,
scope: list,
state: str,
nonce: str,
code_challenge: str = None,
code_challenge_method: str = None,
ip_address: str = None,
user_agent: str = None,
) -> str:
return _auth_code.generate_authorization_code(
client_id, user_id, redirect_uri, scope, state, nonce,
code_challenge, code_challenge_method, ip_address, user_agent,
)
@classmethod
def validate_authorization_code(
cls,
code: str,
client_id: str,
redirect_uri: str,
code_verifier: str = None,
ip_address: str = None,
user_agent: str = None,
) -> Tuple[Dict, object]:
return _auth_code.validate_authorization_code(
code, client_id, redirect_uri, code_verifier, ip_address, user_agent
)
@classmethod
def _compute_code_challenge(cls, verifier: str, method: str = "S256") -> str:
return _auth_code._compute_code_challenge(verifier, method)
@classmethod
def generate_tokens(
cls,
client_id: str,
user_id: str,
scope: list,
nonce: str = None,
refresh_token: str = None,
ip_address: str = None,
user_agent: str = None,
auth_time: int = None,
) -> Dict:
return _tokens.generate_tokens(
client_id, user_id, scope, nonce, refresh_token, ip_address, user_agent, auth_time
)
@classmethod
def refresh_access_token(
cls,
refresh_token: str,
client_id: str,
scope: list = None,
ip_address: str = None,
user_agent: str = None,
) -> Dict:
return _tokens.refresh_access_token(refresh_token, client_id, scope, ip_address, user_agent)
@classmethod
def validate_access_token(cls, token: str, client_id: str = None) -> Dict:
return _tokens.validate_access_token(token, client_id)
@classmethod
def revoke_token(
cls,
token: str,
client_id: str,
token_type_hint: str = None,
ip_address: str = None,
user_agent: str = None,
) -> bool:
return _tokens.revoke_token(token, client_id, token_type_hint, ip_address, user_agent)
@classmethod
def introspect_token(
cls,
token: str,
client_id: str = None,
ip_address: str = None,
user_agent: str = None,
) -> Dict:
return _tokens.introspect_token(token, client_id, ip_address, user_agent)
@classmethod
def get_jwks(cls) -> Dict:
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
return OIDCJWKSService().get_jwks()
@classmethod
def get_userinfo(cls, access_token: str) -> Dict:
return _userinfo.get_userinfo(access_token, cls.validate_access_token)
@staticmethod
def _get_user_roles(user) -> list:
return _userinfo._get_user_roles(user)
+196
View File
@@ -0,0 +1,196 @@
"""OIDC authorization code generation and validation."""
import logging
from datetime import datetime, timezone
from typing import Dict, Tuple
from flask import current_app
from gatehouse_app.models import User, OIDCAuthCode
from gatehouse_app.exceptions.validation_exceptions import ValidationError, NotFoundError
from gatehouse_app.services.oidc_audit_service import OIDCAuditService
logger = logging.getLogger(__name__)
def _hash_value(value: str) -> str:
import hashlib
return hashlib.sha256(value.encode()).hexdigest()
def _compute_code_challenge(verifier: str, method: str = "S256") -> str:
import hashlib
import base64
if method == "S256":
digest = hashlib.sha256(verifier.encode()).digest()
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
return verifier
def generate_authorization_code(
client_id: str,
user_id: str,
redirect_uri: str,
scope: list,
state: str,
nonce: str,
code_challenge: str = None,
code_challenge_method: str = None,
ip_address: str = None,
user_agent: str = None,
) -> str:
import secrets
from gatehouse_app.models import OIDCClient
logger.debug("[OIDC SERVICE] generate_authorization_code called")
logger.debug("[OIDC SERVICE] client_id=%s, user_id=%s", client_id, user_id)
client = OIDCClient.query.filter_by(client_id=client_id).first()
if current_app.config.get('ENV') == 'development':
logger.debug(f"[OIDC] Generate auth code - Client validation: client_id={client_id}, exists={client is not None}")
if not client:
raise NotFoundError("Client not found")
if not client.is_active:
raise ValidationError("Client is not active")
if not client.is_redirect_uri_allowed(redirect_uri):
raise ValidationError("Invalid redirect_uri")
allowed_scopes = client.scopes or []
valid_scopes = [s for s in scope if s in allowed_scopes]
if not valid_scopes:
raise ValidationError("Invalid scopes")
code = secrets.token_urlsafe(32)
code_hash = _hash_value(code)
auth_code = OIDCAuthCode.create_code(
client_id=client.id,
user_id=user_id,
code_hash=code_hash,
redirect_uri=redirect_uri,
scope=valid_scopes,
nonce=nonce,
code_verifier=code_challenge,
ip_address=ip_address,
user_agent=user_agent,
lifetime_seconds=600,
)
logger.debug("[OIDC SERVICE] Auth code created, expires_at=%s", auth_code.expires_at.isoformat())
OIDCAuditService.log_authorization_event(
client_id=client.id,
user_id=user_id,
success=True,
redirect_uri=redirect_uri,
scope=valid_scopes,
)
return code
def validate_authorization_code(
code: str,
client_id: str,
redirect_uri: str,
code_verifier: str = None,
ip_address: str = None,
user_agent: str = None,
) -> Tuple[Dict, User]:
from gatehouse_app.models import OIDCClient
from gatehouse_app.exceptions.auth_exceptions import InvalidTokenError
logger.debug("[OIDC SERVICE] validate_authorization_code called, client_id=%s", client_id)
client = OIDCClient.query.filter_by(client_id=client_id).first()
if not client:
logger.error(f"[OIDC] Validate auth code - Client not found: client_id={client_id}")
from gatehouse_app.services.oidc import InvalidGrantError
raise InvalidGrantError("Invalid client")
code_hash = _hash_value(code)
auth_code = OIDCAuthCode.query.filter_by(
code_hash=code_hash,
client_id=client.id,
deleted_at=None,
).first()
if not auth_code:
OIDCAuditService.log_authorization_event(
client_id=client.id,
success=False,
error_code="invalid_grant",
error_description="Invalid or expired authorization code",
)
from gatehouse_app.services.oidc import InvalidGrantError
raise InvalidGrantError("Invalid or expired authorization code")
if auth_code.is_used:
OIDCAuditService.log_authorization_event(
client_id=client.id,
user_id=auth_code.user_id,
success=False,
error_code="invalid_grant",
error_description="Authorization code already used",
)
from gatehouse_app.services.oidc import InvalidGrantError
raise InvalidGrantError("Authorization code already used")
expires_at = auth_code.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
logger.debug(
"[OIDC SERVICE] Time until expiration (seconds): %s",
(expires_at - datetime.now(timezone.utc)).total_seconds(),
)
if auth_code.is_expired():
OIDCAuditService.log_authorization_event(
client_id=client.id,
user_id=auth_code.user_id,
success=False,
error_code="invalid_grant",
error_description="Authorization code expired",
)
from gatehouse_app.services.oidc import InvalidGrantError
raise InvalidGrantError("Authorization code expired")
if auth_code.redirect_uri != redirect_uri:
from gatehouse_app.services.oidc import InvalidGrantError
raise InvalidGrantError("Invalid redirect_uri")
if client.require_pkce and auth_code.code_verifier:
if not code_verifier:
raise ValidationError("code_verifier is required")
expected_challenge = _compute_code_challenge(code_verifier, "S256")
if expected_challenge != auth_code.code_verifier:
OIDCAuditService.log_authorization_event(
client_id=client.id,
user_id=auth_code.user_id,
success=False,
error_code="invalid_grant",
error_description="Invalid code_verifier",
)
from gatehouse_app.services.oidc import InvalidGrantError
raise InvalidGrantError("Invalid code_verifier")
auth_code.mark_as_used()
user = User.query.get(auth_code.user_id)
if not user:
from gatehouse_app.services.oidc import InvalidGrantError
raise InvalidGrantError("User not found")
claims = {
"user_id": auth_code.user_id,
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": auth_code.scope,
"nonce": auth_code.nonce,
}
return claims, user
+321
View File
@@ -0,0 +1,321 @@
"""OIDC token generation, refresh, validation, revocation, and introspection."""
import hashlib
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional
from flask import current_app
from gatehouse_app.models import OIDCClient, OIDCRefreshToken, OIDCTokenMetadata
from gatehouse_app.services.oidc_token_service import OIDCTokenService
from gatehouse_app.services.oidc_audit_service import OIDCAuditService
from gatehouse_app.exceptions.auth_exceptions import InvalidTokenError
logger = logging.getLogger(__name__)
def generate_tokens(
client_id: str,
user_id: str,
scope: list,
nonce: str = None,
refresh_token: str = None,
ip_address: str = None,
user_agent: str = None,
auth_time: int = None,
) -> Dict:
logger.debug("[OIDC SERVICE] generate_tokens called: client_id=%s, user_id=%s", client_id, user_id)
client = OIDCClient.query.filter_by(client_id=client_id).first()
if not client:
from gatehouse_app.services.oidc import InvalidClientError
raise InvalidClientError()
access_token_jti = OIDCTokenService._generate_jti()
access_token = OIDCTokenService.create_access_token(
client_id=client_id,
user_id=user_id,
scope=scope,
jti=access_token_jti,
)
id_token = OIDCTokenService.create_id_token(
client_id=client_id,
user_id=user_id,
nonce=nonce,
scope=scope,
access_token=access_token,
auth_time=auth_time,
)
final_refresh_token = None
if "refresh_token" in (client.grant_types or []):
if refresh_token:
refresh_token_obj = OIDCRefreshToken.query.filter_by(
token_hash=hashlib.sha256(refresh_token.encode()).hexdigest(),
deleted_at=None,
).first()
if refresh_token_obj and refresh_token_obj.is_valid():
new_refresh, new_hash = OIDCTokenService.create_refresh_token(
client_id=client_id,
user_id=user_id,
scope=scope,
access_token_id=access_token_jti,
)
refresh_token_obj.rotate(new_hash)
final_refresh_token = new_refresh
else:
final_refresh_token, refresh_hash = OIDCTokenService.create_refresh_token(
client_id=client_id,
user_id=user_id,
scope=scope,
access_token_id=access_token_jti,
)
OIDCRefreshToken.create_token(
client_id=client.id,
user_id=user_id,
token_hash=refresh_hash,
scope=scope,
access_token_id=access_token_jti,
ip_address=ip_address,
user_agent=user_agent,
lifetime_seconds=client.refresh_token_lifetime or 2592000,
)
access_token_expires_at = datetime.now(timezone.utc) + timedelta(
seconds=client.access_token_lifetime or 3600
)
OIDCTokenMetadata.create_metadata(
client_id=client.id,
user_id=user_id,
token_type="access_token",
token_jti=access_token_jti,
expires_at=access_token_expires_at,
)
id_token_jti = OIDCTokenService._generate_jti()
id_token_expires_at = datetime.now(timezone.utc) + timedelta(
seconds=client.id_token_lifetime or 3600
)
OIDCTokenMetadata.create_metadata(
client_id=client.id,
user_id=user_id,
token_type="id_token",
token_jti=id_token_jti,
expires_at=id_token_expires_at,
)
OIDCAuditService.log_token_event(
client_id=client.id,
user_id=user_id,
token_type="access_token",
success=True,
grant_type="authorization_code",
scopes=scope,
)
result = {
"access_token": access_token,
"token_type": "Bearer",
"expires_in": client.access_token_lifetime or 3600,
"id_token": id_token,
}
if final_refresh_token:
result["refresh_token"] = final_refresh_token
return result
def refresh_access_token(
refresh_token: str,
client_id: str,
scope: list = None,
ip_address: str = None,
user_agent: str = None,
) -> Dict:
logger.debug("[OIDC SERVICE] refresh_access_token called, client_id=%s", client_id)
client = OIDCClient.query.filter_by(client_id=client_id).first()
if not client:
from gatehouse_app.services.oidc import InvalidClientError
raise InvalidClientError()
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
refresh_token_obj = OIDCRefreshToken.query.filter_by(
token_hash=token_hash,
deleted_at=None,
).first()
if not refresh_token_obj:
OIDCAuditService.log_token_event(
client_id=client.id,
success=False,
error_code="invalid_grant",
error_description="Invalid refresh token",
)
from gatehouse_app.services.oidc import InvalidGrantError
raise InvalidGrantError("Invalid refresh token")
if not refresh_token_obj.is_valid():
OIDCAuditService.log_token_event(
client_id=client.id,
user_id=refresh_token_obj.user_id,
success=False,
error_code="invalid_grant",
error_description="Refresh token expired or revoked",
)
from gatehouse_app.services.oidc import InvalidGrantError
raise InvalidGrantError("Refresh token expired or revoked")
if refresh_token_obj.client_id != client.id:
from gatehouse_app.services.oidc import InvalidGrantError
raise InvalidGrantError("Client mismatch")
granted_scope = scope or (refresh_token_obj.scope or [])
access_token_jti = OIDCTokenService._generate_jti()
access_token = OIDCTokenService.create_access_token(
client_id=client_id,
user_id=refresh_token_obj.user_id,
scope=granted_scope,
jti=access_token_jti,
)
id_token = OIDCTokenService.create_id_token(
client_id=client_id,
user_id=refresh_token_obj.user_id,
scope=granted_scope,
access_token=access_token,
)
new_refresh, new_hash = OIDCTokenService.create_refresh_token(
client_id=client_id,
user_id=refresh_token_obj.user_id,
scope=granted_scope,
access_token_id=access_token_jti,
)
refresh_token_obj.rotate(new_hash)
access_token_expires_at = datetime.now(timezone.utc) + timedelta(
seconds=client.access_token_lifetime or 3600
)
OIDCTokenMetadata.create_metadata(
client_id=client.id,
user_id=refresh_token_obj.user_id,
token_type="access_token",
token_jti=access_token_jti,
expires_at=access_token_expires_at,
)
OIDCAuditService.log_token_event(
client_id=client.id,
user_id=refresh_token_obj.user_id,
token_type="access_token",
success=True,
grant_type="refresh_token",
scopes=granted_scope,
)
return {
"access_token": access_token,
"token_type": "Bearer",
"expires_in": client.access_token_lifetime or 3600,
"id_token": id_token,
"refresh_token": new_refresh,
}
def validate_access_token(token: str, client_id: str = None) -> Dict:
logger.debug("[OIDC SERVICE] validate_access_token() called")
try:
claims = OIDCTokenService.validate_access_token(token, client_id)
logger.debug("[OIDC SERVICE] Token validation successful")
return claims
except Exception as e:
logger.error("[OIDC SERVICE] Token validation failed: %s: %s", type(e).__name__, str(e))
_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_db_id,
success=False,
error_code="invalid_token",
error_description=str(e),
)
raise InvalidTokenError(str(e))
def revoke_token(
token: str,
client_id: str,
token_type_hint: str = None,
ip_address: str = None,
user_agent: str = None,
) -> bool:
client = OIDCClient.query.filter_by(client_id=client_id).first()
if not client:
from gatehouse_app.services.oidc import InvalidClientError
raise InvalidClientError()
revoked = False
token_hash = hashlib.sha256(token.encode()).hexdigest()
if token_type_hint in (None, "refresh_token"):
refresh_token_obj = OIDCRefreshToken.query.filter_by(
token_hash=token_hash,
deleted_at=None,
).first()
if refresh_token_obj:
refresh_token_obj.revoke(reason="revoked_by_client")
revoked = True
OIDCAuditService.log_token_revocation_event(
client_id=client.id,
user_id=refresh_token_obj.user_id,
token_type="refresh_token",
reason="revoked_by_client",
)
if not revoked or token_type_hint in (None, "access_token"):
try:
claims = OIDCTokenService.decode_token(token)
jti = claims.get("jti")
if jti:
revoked_at = OIDCTokenMetadata.revoke_by_jti(jti, reason="revoked_by_client")
if revoked_at:
revoked = True
OIDCAuditService.log_token_revocation_event(
client_id=client.id,
user_id=claims.get("sub"),
token_type="access_token",
reason="revoked_by_client",
)
except Exception:
pass
return revoked
def introspect_token(
token: str,
client_id: str = None,
ip_address: str = None,
user_agent: str = None,
) -> Dict:
result = OIDCTokenService.introspect_token(token, client_id)
_client_db_id = None
if client_id:
_ic = OIDCClient.query.filter_by(client_id=client_id).first()
_client_db_id = _ic.id if _ic else None
OIDCAuditService.log_event(
event_type="token_introspection",
client_id=_client_db_id,
user_id=result.get("sub"),
success=result.get("active", False),
metadata={"active": result.get("active")},
)
return result
+65
View File
@@ -0,0 +1,65 @@
"""OIDC userinfo endpoint logic."""
import logging
from typing import Dict
from gatehouse_app.models import User
from gatehouse_app.exceptions.validation_exceptions import NotFoundError
from gatehouse_app.services.oidc_audit_service import OIDCAuditService
logger = logging.getLogger(__name__)
def get_userinfo(access_token: str, validate_access_token_fn) -> Dict:
logger.debug("[OIDC SERVICE] get_userinfo() called")
claims = validate_access_token_fn(access_token)
user_id = claims.get("sub")
user = User.query.get(user_id)
if not user:
logger.error("[OIDC SERVICE] User not found in database: user_id=%s", user_id)
raise NotFoundError("User not found")
scope_str = claims.get("scope", "")
scopes = scope_str.split() if scope_str else []
userinfo = {"sub": user_id}
if "profile" in scopes and user.full_name:
userinfo["name"] = user.full_name
if "email" in scopes:
userinfo["email"] = user.email
userinfo["email_verified"] = user.email_verified
if "roles" in scopes:
userinfo["roles"] = _get_user_roles(user)
_userinfo_client_id_str = claims.get("client_id")
_userinfo_client_db_id = None
if _userinfo_client_id_str:
from gatehouse_app.models import OIDCClient
_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=_userinfo_client_db_id,
success=True,
scopes_claimed=scopes,
)
return userinfo
def _get_user_roles(user: User) -> list:
roles = []
if not user or not user.organization_memberships:
return roles
for member in user.organization_memberships:
roles.append({
"organization_id": str(member.organization_id),
"role": member.role.value,
})
return roles