2026-01-08 15:59:53 +10:30
|
|
|
"""OIDC Session model for OIDC session tracking."""
|
2026-03-01 12:40:48 +05:45
|
|
|
import hashlib
|
|
|
|
|
import base64
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
2026-01-15 03:40:29 +10:30
|
|
|
from gatehouse_app.extensions import db
|
|
|
|
|
from gatehouse_app.models.base import BaseModel
|
2026-01-08 15:59:53 +10:30
|
|
|
|
|
|
|
|
|
|
|
|
|
class OIDCSession(BaseModel):
|
|
|
|
|
"""OIDC Session model for tracking OIDC authentication sessions.
|
|
|
|
|
|
2026-03-01 12:40:48 +05:45
|
|
|
Tracks the state during the OIDC authorization flow, including PKCE
|
|
|
|
|
parameters and nonce validation.
|
2026-01-08 15:59:53 +10:30
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "oidc_sessions"
|
|
|
|
|
|
|
|
|
|
# User reference
|
|
|
|
|
user_id = db.Column(
|
|
|
|
|
db.String(36), db.ForeignKey("users.id"), nullable=False, index=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Client reference
|
|
|
|
|
client_id = db.Column(
|
|
|
|
|
db.String(255), db.ForeignKey("oidc_clients.id"), nullable=False, index=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# State management
|
|
|
|
|
state = db.Column(db.String(255), nullable=False, index=True)
|
2026-03-01 12:40:48 +05:45
|
|
|
nonce = db.Column(db.String(255), nullable=True)
|
2026-01-08 15:59:53 +10:30
|
|
|
|
|
|
|
|
# Authorization request parameters
|
|
|
|
|
redirect_uri = db.Column(db.String(512), nullable=False)
|
2026-03-01 12:40:48 +05:45
|
|
|
scope = db.Column(db.JSON, nullable=True)
|
2026-01-08 15:59:53 +10:30
|
|
|
|
|
|
|
|
# PKCE parameters
|
|
|
|
|
code_challenge = db.Column(db.String(255), nullable=True)
|
|
|
|
|
code_challenge_method = db.Column(db.String(10), nullable=True) # "S256" or "plain"
|
|
|
|
|
|
|
|
|
|
# Timing
|
|
|
|
|
expires_at = db.Column(db.DateTime, nullable=False, index=True)
|
|
|
|
|
authenticated_at = db.Column(db.DateTime, nullable=True)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
user = db.relationship("User", back_populates="oidc_sessions")
|
|
|
|
|
client = db.relationship("OIDCClient", back_populates="oidc_sessions")
|
|
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
|
"""String representation of OIDCSession."""
|
2026-03-01 12:40:48 +05:45
|
|
|
return (
|
|
|
|
|
f"<OIDCSession user_id={self.user_id} "
|
|
|
|
|
f"client_id={self.client_id} state={self.state[:8]}...>"
|
|
|
|
|
)
|
2026-01-08 15:59:53 +10:30
|
|
|
|
2026-03-01 12:40:48 +05:45
|
|
|
def is_expired(self) -> bool:
|
2026-01-08 15:59:53 +10:30
|
|
|
"""Check if the OIDC session has expired."""
|
2026-03-01 12:40:48 +05:45
|
|
|
expires_at = self.expires_at
|
|
|
|
|
if expires_at.tzinfo is None:
|
|
|
|
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
|
|
|
return datetime.now(timezone.utc) > expires_at
|
2026-01-08 15:59:53 +10:30
|
|
|
|
2026-03-01 12:40:48 +05:45
|
|
|
def is_authenticated(self) -> bool:
|
2026-01-08 15:59:53 +10:30
|
|
|
"""Check if the user has been authenticated in this session."""
|
|
|
|
|
return self.authenticated_at is not None
|
|
|
|
|
|
2026-03-01 12:40:48 +05:45
|
|
|
def mark_authenticated(self) -> None:
|
2026-01-08 15:59:53 +10:30
|
|
|
"""Mark the session as authenticated."""
|
2026-01-14 18:06:17 +10:30
|
|
|
self.authenticated_at = datetime.now(timezone.utc)
|
2026-01-08 15:59:53 +10:30
|
|
|
db.session.commit()
|
|
|
|
|
|
2026-03-01 12:40:48 +05:45
|
|
|
def validate_nonce(self, expected_nonce: str) -> bool:
|
2026-01-08 15:59:53 +10:30
|
|
|
"""Validate the nonce matches the expected value.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
expected_nonce: The expected nonce value
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-03-01 12:40:48 +05:45
|
|
|
True if nonce matches
|
2026-01-08 15:59:53 +10:30
|
|
|
"""
|
|
|
|
|
return self.nonce == expected_nonce
|
|
|
|
|
|
2026-03-01 12:40:48 +05:45
|
|
|
def validate_code_challenge(self, code_verifier: str) -> bool:
|
2026-01-08 15:59:53 +10:30
|
|
|
"""Validate the code verifier against the stored code challenge.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
code_verifier: The PKCE code verifier
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-03-01 12:40:48 +05:45
|
|
|
True if the challenge is satisfied
|
2026-01-08 15:59:53 +10:30
|
|
|
"""
|
|
|
|
|
if not self.code_challenge:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
if self.code_challenge_method == "S256":
|
|
|
|
|
digest = hashlib.sha256(code_verifier.encode()).digest()
|
|
|
|
|
expected = base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
|
|
|
|
return self.code_challenge == expected
|
|
|
|
|
elif self.code_challenge_method == "plain":
|
|
|
|
|
return self.code_challenge == code_verifier
|
|
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
@classmethod
|
2026-03-01 12:40:48 +05:45
|
|
|
def create_session(
|
|
|
|
|
cls,
|
|
|
|
|
user_id: str,
|
|
|
|
|
client_id: str,
|
|
|
|
|
state: str,
|
|
|
|
|
redirect_uri: str,
|
|
|
|
|
scope=None,
|
|
|
|
|
nonce: str = None,
|
|
|
|
|
code_challenge: str = None,
|
|
|
|
|
code_challenge_method: str = None,
|
|
|
|
|
lifetime_seconds: int = 600,
|
|
|
|
|
) -> "OIDCSession":
|
2026-01-08 15:59:53 +10:30
|
|
|
"""Create a new OIDC session.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
user_id: The user ID
|
|
|
|
|
client_id: The OIDC client ID
|
|
|
|
|
state: The state parameter
|
|
|
|
|
redirect_uri: The redirect URI
|
|
|
|
|
scope: Requested scopes
|
|
|
|
|
nonce: OIDC nonce
|
|
|
|
|
code_challenge: PKCE code challenge
|
|
|
|
|
code_challenge_method: PKCE method ("S256" or "plain")
|
|
|
|
|
lifetime_seconds: Session lifetime in seconds
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
OIDCSession instance
|
|
|
|
|
"""
|
|
|
|
|
session = cls(
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
client_id=client_id,
|
|
|
|
|
state=state,
|
|
|
|
|
redirect_uri=redirect_uri,
|
|
|
|
|
scope=scope,
|
|
|
|
|
nonce=nonce,
|
|
|
|
|
code_challenge=code_challenge,
|
|
|
|
|
code_challenge_method=code_challenge_method,
|
2026-01-14 18:06:17 +10:30
|
|
|
expires_at=datetime.now(timezone.utc) + timedelta(seconds=lifetime_seconds),
|
2026-01-08 15:59:53 +10:30
|
|
|
)
|
|
|
|
|
db.session.add(session)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
return session
|
|
|
|
|
|
|
|
|
|
@classmethod
|
2026-03-01 12:40:48 +05:45
|
|
|
def get_by_state(cls, state: str) -> "OIDCSession | None":
|
2026-01-08 15:59:53 +10:30
|
|
|
"""Get a session by state parameter.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
state: The state parameter
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
OIDCSession instance or None
|
|
|
|
|
"""
|
|
|
|
|
return cls.query.filter_by(state=state, deleted_at=None).first()
|
|
|
|
|
|
|
|
|
|
def to_dict(self, exclude=None):
|
|
|
|
|
"""Convert to dictionary."""
|
|
|
|
|
return super().to_dict(exclude=exclude)
|