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
+9
View File
@@ -104,9 +104,18 @@ The API will be available at `http://localhost:5000`
- `DELETE /api/v1/organizations/:id/members/:userId` - Remove member - `DELETE /api/v1/organizations/:id/members/:userId` - Remove member
- `PATCH /api/v1/organizations/:id/members/:userId/role` - Update role - `PATCH /api/v1/organizations/:id/members/:userId/role` - Update role
### Health ### Health
- `GET /api/health` - Health check - `GET /api/health` - Health check
## O-auth Setup
- Redirect URI
```http://localhost:5000/api/v1/auth/external/[google|microsoft]/callback```
## API Response Format ## API Response Format
All API responses follow the standardized envelope format: All API responses follow the standardized envelope format:
+3
View File
@@ -113,3 +113,6 @@ class BaseConfig:
WEBAUTHN_RP_ID = os.getenv("WEBAUTHN_RP_ID", "localhost") WEBAUTHN_RP_ID = os.getenv("WEBAUTHN_RP_ID", "localhost")
WEBAUTHN_RP_NAME = os.getenv("WEBAUTHN_RP_NAME", "Gatehouse") WEBAUTHN_RP_NAME = os.getenv("WEBAUTHN_RP_NAME", "Gatehouse")
WEBAUTHN_ORIGIN = os.getenv("WEBAUTHN_ORIGIN", "https://ui.webauthn.local") WEBAUTHN_ORIGIN = os.getenv("WEBAUTHN_ORIGIN", "https://ui.webauthn.local")
# Frontend URL (for OAuth callback redirects)
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:8080")
+9
View File
@@ -16,3 +16,12 @@ class DevelopmentConfig(BaseConfig):
# Reduced bcrypt rounds for faster dev cycles # Reduced bcrypt rounds for faster dev cycles
BCRYPT_LOG_ROUNDS = 4 BCRYPT_LOG_ROUNDS = 4
# Gatehouse React UI URL — OIDC authorize redirects here instead of showing raw HTML
OIDC_UI_URL = os.getenv("OIDC_UI_URL", "http://localhost:8080")
# Add localhost:8080 (React UI) to CORS allowed origins for OIDC bridge endpoints
CORS_ORIGINS = os.getenv(
"CORS_ORIGINS",
"http://localhost:8080,http://localhost:3000,http://localhost:5173,https://ui.webauthn.local"
).split(",")
+33 -1
View File
@@ -714,12 +714,44 @@ Provider tokens are encrypted at rest:
- Auth URL: `https://login.microsoftonline.com/common/oauth2/v2.0/authorize` - Auth URL: `https://login.microsoftonline.com/common/oauth2/v2.0/authorize`
- Token URL: `https://login.microsoftonline.com/common/oauth2/v2.0/token` - Token URL: `https://login.microsoftonline.com/common/oauth2/v2.0/token`
- UserInfo URL: `https://graph.microsoft.com/oidc/userinfo` - UserInfo URL: `https://graph.microsoft.com/oidc/userinfo`
- JWKS URL: `https://login.microsoftonline.com/common/discovery/v2.0/keys`
**Default Scopes:** **Default Scopes:**
- `openid` - OpenID Connect - `openid` - OpenID Connect
- `profile` - User profile - `profile` - User profile
- `email` - Email address - `email` - Email address
- `User.Read` - Microsoft Graph access - `offline_access` - Required by Microsoft to return a refresh token (unlike Google which uses `access_type=offline`)
#### Azure App Registration steps
1. Go to [Azure Portal](https://portal.azure.com) → **App registrations****New registration**
2. Under **"Supported account types"** choose the option that matches your use case (see table above)
3. Set **Redirect URI** (Web platform) to:
`https://<your-api-host>/api/v1/auth/external/microsoft/callback`
4. Under **Certificates & secrets****New client secret** — copy the *Value* (not the Secret ID)
5. Under **API permissions****Add a permission****Microsoft Graph****Delegated**:
add `openid`, `profile`, `email`, `offline_access`
6. Configure Gatehouse:
```bash
# Multi-tenant (work + personal accounts):
MICROSOFT_CLIENT_ID=<Application (client) ID> \
MICROSOFT_CLIENT_SECRET=<client secret value> \
python scripts/configure_oauth_provider.py create microsoft \
--redirect-url "https://<your-api-host>/api/v1/auth/external/microsoft/callback"
# Work/school accounts only (replace with your tenant ID for single-org):
MICROSOFT_CLIENT_ID=<Application (client) ID> \
MICROSOFT_CLIENT_SECRET=<client secret value> \
python scripts/configure_oauth_provider.py create microsoft \
--tenant-id organizations \
--redirect-url "https://<your-api-host>/api/v1/auth/external/microsoft/callback"
```
**Behaviour notes:**
- Microsoft is a **confidential client** — PKCE is not used (the client secret authenticates the app).
- The `email_verified` claim is implicitly `true` for all Azure AD accounts; Gatehouse defaults it to `true` when Microsoft omits it.
- `prompt=select_account` is sent by default so users can choose between multiple signed-in Microsoft accounts.
--- ---
+193 -52
View File
@@ -19,10 +19,53 @@ from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.mfa_policy_service import MfaPolicyService from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.extensions import db from gatehouse_app.extensions import db
from gatehouse_app.extensions import bcrypt as flask_bcrypt from gatehouse_app.extensions import bcrypt as flask_bcrypt
from gatehouse_app.extensions import redis_client as _redis_client_ref # may be None until app init
from gatehouse_app.models import User, OIDCClient from gatehouse_app.models import User, OIDCClient
from gatehouse_app.models.organization import Organization from gatehouse_app.models.organization import Organization
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
# ---------------------------------------------------------------------------
# Helpers for Redis-backed OIDC pending state
# (avoids Flask session / cookie dependency for cross-origin /oidc/complete)
# ---------------------------------------------------------------------------
_OIDC_PENDING_TTL = 600 # 10 minutes
def _oidc_redis():
"""Return the shared Redis client, or None if not yet initialised."""
import gatehouse_app.extensions as _ext
return _ext.redis_client
def _stash_oidc_params(oidc_session_id: str, params: dict) -> None:
"""Store OIDC params in Redis with a TTL. Falls back to Flask session."""
rc = _oidc_redis()
key = f"oidc_pending:{oidc_session_id}"
if rc is not None:
rc.setex(key, _OIDC_PENDING_TTL, json.dumps(params))
else:
session[f"oidc_pending_{oidc_session_id}"] = params
def _fetch_oidc_params(oidc_session_id: str, *, consume: bool = False) -> dict | None:
"""Retrieve (and optionally delete) OIDC params from Redis / Flask session."""
rc = _oidc_redis()
key = f"oidc_pending:{oidc_session_id}"
if rc is not None:
raw = rc.get(key)
if raw is None:
return None
params = json.loads(raw)
if consume:
rc.delete(key)
return params
else:
params = session.get(f"oidc_pending_{oidc_session_id}")
if params and consume:
session.pop(f"oidc_pending_{oidc_session_id}", None)
return params
# Create OIDC blueprint registered at root level # Create OIDC blueprint registered at root level
oidc_bp = Blueprint("oidc", __name__) oidc_bp = Blueprint("oidc", __name__)
@@ -217,6 +260,134 @@ def oidc_discovery():
return response, 200 return response, 200
# ============================================================================
# OIDC UI Bridge — lets the React frontend drive the OIDC login flow
# ============================================================================
@oidc_bp.route("/oidc/begin", methods=["POST"])
def oidc_begin():
"""Stash OIDC authorize params server-side, return a one-time session ID.
Called by the React UI after being redirected from _show_login_page.
The UI cannot hold OIDC params itself (XSS risk, URL length limits), so
the backend stashes them in the server-side session store and hands back
an opaque ID the UI passes along during login.
Request body (JSON):
oidc_session_id: ID previously issued by _show_login_page
Returns:
200: { oidc_session_id, client_name, scopes } — context for the UI
400: missing / expired session
"""
data = request.get_json(silent=True) or {}
oidc_session_id = data.get("oidc_session_id") or request.args.get("oidc_session_id")
if not oidc_session_id:
return api_response(success=False, message="oidc_session_id required", status=400)
params = _fetch_oidc_params(oidc_session_id)
if not params:
return api_response(success=False, message="OIDC session expired or invalid", status=400)
# Look up client name for display
client = OIDCClient.query.filter_by(client_id=params.get("client_id"), is_active=True).first()
client_name = client.name if client else params.get("client_id", "Unknown Application")
return api_response(
data={
"oidc_session_id": oidc_session_id,
"client_name": client_name,
"scopes": params.get("scope", "").split(),
"redirect_uri": params.get("redirect_uri"),
},
message="OIDC session found",
)
@oidc_bp.route("/oidc/complete", methods=["POST"])
def oidc_complete():
"""Complete an OIDC authorization flow after the UI has authenticated the user.
Called by the React UI after a successful login. The UI sends its Bearer
token + the oidc_session_id. The backend:
1. Validates the Bearer token → resolves the user
2. Retrieves the stashed OIDC params
3. Generates an authorization code
4. Returns the redirect URL (client app redirect_uri + ?code=...&state=...)
The UI then does window.location.href = redirect_url.
Request body (JSON):
oidc_session_id: ID from oidc_begin
token: Gatehouse Bearer token (from /api/v1/auth/login response)
Returns:
200: { redirect_url }
400: invalid request
401: invalid token
"""
from gatehouse_app.models.session import Session as GHSession
from gatehouse_app.utils.constants import SessionStatus
data = request.get_json(silent=True) or {}
oidc_session_id = data.get("oidc_session_id")
bearer_token = data.get("token")
if not oidc_session_id or not bearer_token:
return api_response(success=False, message="oidc_session_id and token required", status=400)
# Validate the Bearer token
gh_session = GHSession.query.filter_by(token=bearer_token, status=SessionStatus.ACTIVE).first()
if not gh_session or gh_session.is_expired():
return api_response(success=False, message="Invalid or expired token", status=401)
user_id = str(gh_session.user_id)
# Retrieve stashed OIDC params (consume = True removes from Redis atomically)
params = _fetch_oidc_params(oidc_session_id, consume=True)
if not params:
return api_response(success=False, message="OIDC session expired or invalid", status=400)
client_id = params["client_id"]
redirect_uri = params["redirect_uri"]
state = params.get("state", "")
nonce = params.get("nonce", "")
scope = params.get("scope", "openid")
response_type = params.get("response_type", "code")
# Validate client still exists
client = OIDCClient.query.filter_by(client_id=client_id, is_active=True).first()
if not client:
return api_response(success=False, message="OIDC client not found", status=400)
# Generate authorization code
try:
valid_scopes = [s for s in scope.split() if s in (client.scopes or [])]
if not valid_scopes:
valid_scopes = ["openid"]
code = OIDCService.generate_authorization_code(
client_id=client_id,
user_id=user_id,
redirect_uri=redirect_uri,
scope=valid_scopes,
state=state,
nonce=nonce,
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
except Exception as e:
logger.error("[OIDC complete] Code generation failed: %s", str(e))
return api_response(success=False, message=f"Failed to generate authorization code: {e}", status=500)
redirect_params = {"code": code}
if state:
redirect_params["state"] = state
redirect_url = f"{redirect_uri}?{urlencode(redirect_params)}"
return api_response(data={"redirect_url": redirect_url}, message="Authorization complete")
# ============================================================================ # ============================================================================
# Authorization Endpoint # Authorization Endpoint
# ============================================================================ # ============================================================================
@@ -495,59 +666,30 @@ def _redirect_with_error(redirect_uri, error, error_description, state=None):
def _show_login_page(client_id, redirect_uri, scope, state, nonce, response_type, error=None): def _show_login_page(client_id, redirect_uri, scope, state, nonce, response_type, error=None):
"""Show the login page for authorization.""" """Redirect to the Gatehouse React UI login page for a proper login experience.
# Simple HTML login page
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>Sign In - OIDC Authorization</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }}
.container {{ max-width: 400px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
h1 {{ color: #333; font-size: 24px; margin-bottom: 20px; }}
.form-group {{ margin-bottom: 15px; }}
label {{ display: block; margin-bottom: 5px; color: #555; font-weight: bold; }}
input[type="email"], input[type="password"] {{ width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }}
button {{ width: 100%; padding: 12px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; }}
button:hover {{ background: #0056b3; }}
.error {{ color: #dc3545; margin-bottom: 15px; }}
.cancel {{ text-align: center; margin-top: 15px; }}
.cancel a {{ color: #666; text-decoration: none; }}
</style>
</head>
<body>
<div class="container">
<h1>Sign In</h1>
{"<p class='error'>" + error + "</p>" if error else ""}
<form method="POST">
<input type="hidden" name="client_id" value="{client_id}">
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
<input type="hidden" name="scope" value="{scope}">
<input type="hidden" name="state" value="{state}">
<input type="hidden" name="nonce" value="{nonce}">
<input type="hidden" name="response_type" value="{response_type}">
<div class="form-group"> Stashes the OIDC params in the server-side session keyed by a random ID,
<label for="email">Email</label> then sends the browser to the React UI at /login?oidc_session_id=<id>.
<input type="email" id="email" name="email" required> The UI logs the user in and calls /oidc/complete to finish the flow.
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Sign In</button>
</form>
<p class="cancel">
<a href="{redirect_uri}">Cancel</a>
</p>
</div>
</body>
</html>
""" """
return Response(html, mimetype="text/html"), 200 ui_base_url = current_app.config.get("OIDC_UI_URL", "http://localhost:8080")
# Stash OIDC params in Redis (TTL 10 min) — cookie-free, cross-origin safe
oidc_session_id = secrets.token_urlsafe(32)
_stash_oidc_params(oidc_session_id, {
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": scope,
"state": state,
"nonce": nonce,
"response_type": response_type,
})
params = {"oidc_session_id": oidc_session_id}
if error:
params["error"] = error
return redirect(f"{ui_base_url}/login?{urlencode(params)}")
# ============================================================================ # ============================================================================
@@ -1224,7 +1366,6 @@ def oidc_register():
grant_types=data.get("grant_types", ["authorization_code", "refresh_token"]), grant_types=data.get("grant_types", ["authorization_code", "refresh_token"]),
response_types=data.get("response_types", ["code"]), response_types=data.get("response_types", ["code"]),
scopes=data.get("scope", "openid profile email roles").split(), scopes=data.get("scope", "openid profile email roles").split(),
token_endpoint_auth_method=data.get("token_endpoint_auth_method", "client_secret_basic"),
is_active=True, is_active=True,
is_confidential=True, is_confidential=True,
require_pkce=True, require_pkce=True,
+169 -194
View File
@@ -1,4 +1,5 @@
"""External authentication provider endpoints.""" """External authentication provider endpoints."""
import json
import logging import logging
from flask import request, g from flask import request, g
from marshmallow import ValidationError from marshmallow import ValidationError
@@ -16,6 +17,35 @@ from gatehouse_app.services.oauth_flow_service import (
) )
from gatehouse_app.services.audit_service import AuditService from gatehouse_app.services.audit_service import AuditService
_OAUTH_BRIDGE_TTL = 600 # 10 minutes
def _store_oidc_bridge(oauth_state: str, oidc_session_id: str) -> None:
"""Store oidc_session_id keyed by OAuth state for retrieval in callback."""
try:
import gatehouse_app.extensions as _ext
rc = _ext.redis_client
if rc is not None:
rc.setex(f"oauth_oidc_bridge:{oauth_state}", _OAUTH_BRIDGE_TTL, oidc_session_id)
except Exception:
pass
def _pop_oidc_bridge(oauth_state: str) -> str | None:
"""Retrieve and delete oidc_session_id for the given OAuth state."""
try:
import gatehouse_app.extensions as _ext
rc = _ext.redis_client
if rc is not None:
key = f"oauth_oidc_bridge:{oauth_state}"
val = rc.get(key)
if val:
rc.delete(key)
return val.decode() if isinstance(val, bytes) else val
except Exception:
pass
return None
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -53,63 +83,50 @@ def list_providers():
200: List of providers with their configuration status 200: List of providers with their configuration status
401: Not authenticated 401: Not authenticated
""" """
from gatehouse_app.models import Organization from gatehouse_app.models.authentication_method import ApplicationProviderConfig
from gatehouse_app.services.external_auth_service import ExternalProviderConfig from gatehouse_app.services.external_auth_service import ExternalProviderConfig
# Get user's primary organization # Check app-level provider configs (ApplicationProviderConfig)
app_configs = {
c.provider_type.lower(): c
for c in ApplicationProviderConfig.query.filter_by(is_enabled=True).all()
}
# Get user's primary organization — check for org-level overrides too
user_orgs = g.current_user.get_organizations() user_orgs = g.current_user.get_organizations()
if not user_orgs: org_configs = {}
return api_response( if user_orgs:
success=False,
message="No organizations found for user",
status=400,
error_type="BAD_REQUEST",
)
organization_id = user_orgs[0].id organization_id = user_orgs[0].id
org_level = ExternalProviderConfig.query.filter_by(
# Get all configured providers for organization
configs = ExternalProviderConfig.query.filter_by(
organization_id=organization_id, organization_id=organization_id,
).all() ).all()
org_configs = {c.provider_type.lower(): c for c in org_level}
configured_providers = {c.provider_type.lower(): c for c in configs} def provider_info(provider_id: str, name: str) -> dict:
app_cfg = app_configs.get(provider_id)
org_cfg = org_configs.get(provider_id)
is_configured = app_cfg is not None or org_cfg is not None
is_active = False
if app_cfg:
is_active = bool(app_cfg.is_enabled)
if org_cfg and hasattr(org_cfg, "is_active"):
is_active = bool(org_cfg.is_active)
return {
"id": provider_id,
"name": name,
"type": provider_id,
"is_configured": is_configured,
"is_active": is_active,
"settings": {
"requires_domain": False,
"supports_refresh_tokens": True,
},
}
# Provider definitions
providers = [ providers = [
{ provider_info("google", "Google"),
"id": "google", provider_info("github", "GitHub"),
"name": "Google", provider_info("microsoft", "Microsoft"),
"type": "google",
"is_configured": "google" in configured_providers,
"is_active": configured_providers.get("google", {}).is_active if "google" in configured_providers else False,
"settings": {
"requires_domain": False,
"supports_refresh_tokens": True,
},
},
{
"id": "github",
"name": "GitHub",
"type": "github",
"is_configured": "github" in configured_providers,
"is_active": configured_providers.get("github", {}).is_active if "github" in configured_providers else False,
"settings": {
"requires_domain": False,
"supports_refresh_tokens": True,
},
},
{
"id": "microsoft",
"name": "Microsoft",
"type": "microsoft",
"is_configured": "microsoft" in configured_providers,
"is_active": configured_providers.get("microsoft", {}).is_active if "microsoft" in configured_providers else False,
"settings": {
"requires_domain": False,
"supports_refresh_tokens": True,
},
},
] ]
return api_response( return api_response(
@@ -564,6 +581,7 @@ def initiate_oauth_authorize(provider: str):
flow = request.args.get("flow", "login") flow = request.args.get("flow", "login")
redirect_uri = request.args.get("redirect_uri") redirect_uri = request.args.get("redirect_uri")
organization_id = request.args.get("organization_id") # Optional hint organization_id = request.args.get("organization_id") # Optional hint
oidc_session_id = request.args.get("oidc_session_id") # OIDC bridge passthrough
if flow not in ["login", "register"]: if flow not in ["login", "register"]:
return api_response( return api_response(
@@ -588,6 +606,11 @@ def initiate_oauth_authorize(provider: str):
redirect_uri=redirect_uri, redirect_uri=redirect_uri,
) )
# If this authorize was triggered during an OIDC bridge flow, remember
# the oidc_session_id so we can hand it back in the callback.
if oidc_session_id:
_store_oidc_bridge(state, oidc_session_id)
return api_response( return api_response(
data={ data={
"authorization_url": auth_url, "authorization_url": auth_url,
@@ -610,188 +633,140 @@ def handle_oauth_callback(provider: str):
""" """
Handle OAuth callback from provider. Handle OAuth callback from provider.
This endpoint handles the redirect from the OAuth provider after authentication. Google (and other providers) redirect the browser here after authentication.
It processes the response and handles different scenarios: On success, this endpoint redirects the browser to the frontend
- Successful login/register with redirect_uri: Redirects with authorization code /oauth/callback page carrying the session token as a URL parameter so the
- Successful login/register without redirect_uri: Returns session token frontend SPA can store it without needing a second API call.
- Login with multiple orgs: Returns list of organizations for user to select
- Register with no org: Prompts for organization creation/selection Success redirect:
{FRONTEND_URL}/oauth/callback?token=TOKEN&expires_in=86400&state=STATE&flow=login&provider=google
Error redirect:
{FRONTEND_URL}/oauth/callback?error=MESSAGE&error_type=TYPE&state=STATE
Args: Args:
provider: Provider type (google, github, microsoft) provider: Provider type (google, github, microsoft)
Query parameters: Query parameters from provider:
code: Authorization code from provider code: Authorization code
state: State parameter from OAuth flow state: State parameter (CSRF token from OAuth flow)
redirect_uri: Optional redirect URI for OAuth 2.0 Authorization Code flow
error: Error code if auth failed at provider error: Error code if auth failed at provider
error_description: Human-readable error description error_description: Human-readable error description
Returns:
302: Redirect with authorization code (if redirect_uri provided)
200: OAuth flow completed successfully (JSON response)
400: Validation error, OAuth error, or invalid state
404: User account not found (for login flows)
Response formats (when redirect_uri NOT provided):
Success with session:
{
"token": "session_token",
"expires_in": 86400,
"token_type": "Bearer",
"user": {...}
}
Requires organization selection (login flow):
{
"requires_org_selection": true,
"user": {...},
"available_organizations": [...],
"state": "state_token"
}
Requires organization creation (register flow):
{
"requires_org_creation": true,
"user": {...},
"state": "state_token"
}
""" """
from urllib.parse import urlencode
from flask import current_app, redirect as flask_redirect
provider_type = get_provider_type(provider) provider_type = get_provider_type(provider)
# Get callback parameters
authorization_code = request.args.get("code")
state = request.args.get("state") state = request.args.get("state")
authorization_code = request.args.get("code")
error = request.args.get("error") error = request.args.get("error")
error_description = request.args.get("error_description") error_description = request.args.get("error_description")
# Get redirect URI from query parameter (for OAuth 2.0 Authorization Code flow) frontend_url = current_app.config.get("FRONTEND_URL", "http://localhost:8080")
redirect_uri = request.args.get("redirect_uri") frontend_callback = f"{frontend_url}/oauth/callback"
def redirect_error(message: str, error_type: str = "OAUTH_ERROR"):
"""Redirect to frontend with error params."""
params = {"error": message, "error_type": error_type}
if state:
params["state"] = state
return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302)
# Handle errors returned by the provider (e.g. user denied)
if error:
msg = error_description or f"Authorization failed: {error}"
return redirect_error(msg, error.upper())
if not authorization_code or not state:
return redirect_error("Missing authorization code or state parameter.")
try: try:
result = OAuthFlowService.handle_callback( result = OAuthFlowService.handle_callback(
provider_type=provider_type, provider_type=provider_type,
authorization_code=authorization_code, authorization_code=authorization_code,
state=state, state=state,
redirect_uri=redirect_uri, redirect_uri=None, # backend handles the full flow
error=error, error=None,
error_description=error_description, error_description=None,
) )
if result.get("success"): if not result.get("success"):
flow_type = result.get("flow_type") return redirect_error("Authentication failed.", "AUTH_FAILED")
# Check if we should redirect with authorization code flow_type = result.get("flow_type", "login")
if redirect_uri and flow_type in ["login", "register"]:
# Generate authorization code for external application
user_id = result.get("user", {}).get("id")
if not user_id:
# For org selection/creation flows, we can't redirect
pass
else:
# Determine organization_id
organization_id = result.get("user", {}).get("organization_id")
if not organization_id:
# Can't redirect without organization
pass
else:
# Generate authorization code
auth_code = OAuthFlowService.generate_authorization_code(
user_id=user_id,
client_id="external-app",
redirect_uri=redirect_uri,
scope=["openid", "profile", "email"],
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
lifetime_seconds=600, # 10 minutes
)
# Mark state as used # ── Link flow: redirect to linked-accounts page ──────────────────────
state_record = OAuthFlowService.validate_state(state) if flow_type == "link":
if state_record: params = {"flow": "link", "provider": provider, "linked": "1"}
state_record.mark_used() return flask_redirect(f"{frontend_url}/linked-accounts?{urlencode(params)}", code=302)
# Redirect with authorization code # ── Login / Register flow ─────────────────────────────────────────────
return OAuthFlowService.create_redirect_response(
redirect_uri=redirect_uri,
authorization_code=auth_code,
state=state,
)
# Handle login flow responses (no redirect_uri or org selection required) # Recover oidc_session_id if this was triggered from an OIDC bridge flow
if flow_type == "login": oidc_session_id = _pop_oidc_bridge(state)
# Check if organization selection is required
# Organization selection needed (user belongs to multiple orgs)
if result.get("requires_org_selection"): if result.get("requires_org_selection"):
return api_response( import json
data={ orgs = json.dumps(result.get("available_organizations", []))
"requires_org_selection": True, params = {
"user": result["user"], "requires_org_selection": "1",
"available_organizations": result["available_organizations"],
"state": result["state"], "state": result["state"],
}, "provider": provider,
message="Please select an organization to continue", "flow": flow_type,
status=200, "orgs": orgs,
) }
if oidc_session_id:
params["oidc_session_id"] = oidc_session_id
return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302)
# Normal login with session # Organization creation needed (new user via OAuth with no org)
return api_response(
data={
"token": result["session"]["token"],
"expires_in": result["session"].get("expires_in", 86400),
"token_type": "Bearer",
"user": result["user"],
},
message="Login successful",
)
# Handle register flow responses
elif flow_type == "register":
# Check if organization creation is required
if result.get("requires_org_creation"): if result.get("requires_org_creation"):
return api_response( params = {
data={ "requires_org_creation": "1",
"requires_org_creation": True,
"user": result["user"],
"state": result["state"], "state": result["state"],
}, "provider": provider,
message="Please create or select an organization to continue", "flow": flow_type,
status=200, }
) if oidc_session_id:
params["oidc_session_id"] = oidc_session_id
return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302)
# Normal registration with session # Normal success — carry token to frontend via URL
return api_response( session_data = result.get("session", {})
data={ token = session_data.get("token")
"token": result["session"]["token"], expires_in = session_data.get("expires_in", 86400)
"expires_in": result["session"].get("expires_in", 86400),
"token_type": "Bearer",
"user": result["user"],
},
message="Registration successful",
)
# Handle link flow responses if not token:
elif flow_type == "link": return redirect_error("No session token returned by server.", "NO_TOKEN")
return api_response(
data={
"linked_account": result["linked_account"],
},
message="Account linked successfully",
)
# Fallback for unexpected result format params = {
return api_response( "token": token,
data=result, "expires_in": str(expires_in),
message="OAuth flow completed", "flow": flow_type,
"provider": provider,
"state": state,
}
user_info = result.get("user", {})
if user_info.get("email"):
params["email"] = user_info["email"]
# Pass oidc_session_id through so the frontend can complete the OIDC flow
if oidc_session_id:
params["oidc_session_id"] = oidc_session_id
logger.info(
f"OAuth callback success: provider={provider}, flow={flow_type}, "
f"user={user_info.get('email')}, redirecting to frontend"
) )
return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302)
except OAuthFlowError as e: except OAuthFlowError as e:
return api_response( logger.warning(f"OAuth callback OAuthFlowError: {e.message}")
success=False, return redirect_error(e.message, e.error_type)
message=e.message, except Exception as e:
status=e.status_code, logger.error(f"OAuth callback unexpected error: {str(e)}", exc_info=True)
error_type=e.error_type, return redirect_error("An unexpected error occurred. Please try again.", "INTERNAL_ERROR")
)
@api_v1_bp.route("/auth/external/select-organization", methods=["POST"]) @api_v1_bp.route("/auth/external/select-organization", methods=["POST"])
+2 -9
View File
@@ -24,9 +24,9 @@ class OIDCRefreshToken(BaseModel):
# Token (hashed for security) # Token (hashed for security)
token_hash = db.Column(db.String(255), nullable=False, unique=True, index=True) token_hash = db.Column(db.String(255), nullable=False, unique=True, index=True)
# Associated access token ID # Associated access token ID (stores JWT JTI string — no FK to sessions)
access_token_id = db.Column( access_token_id = db.Column(
db.String(36), db.ForeignKey("sessions.id"), nullable=True, index=True db.String(255), nullable=True, index=True
) )
# Token scope # Token scope
@@ -50,7 +50,6 @@ class OIDCRefreshToken(BaseModel):
# Relationships # Relationships
client = db.relationship("OIDCClient", back_populates="refresh_tokens") client = db.relationship("OIDCClient", back_populates="refresh_tokens")
user = db.relationship("User", back_populates="oidc_refresh_tokens") user = db.relationship("User", back_populates="oidc_refresh_tokens")
access_token = db.relationship("Session", back_populates="oidc_refresh_token")
def __repr__(self): def __repr__(self):
"""String representation of OIDCRefreshToken.""" """String representation of OIDCRefreshToken."""
@@ -155,9 +154,3 @@ from gatehouse_app.models.oidc_client import OIDCClient
OIDCClient.refresh_tokens = db.relationship( OIDCClient.refresh_tokens = db.relationship(
"OIDCRefreshToken", back_populates="client", cascade="all, delete-orphan" "OIDCRefreshToken", back_populates="client", cascade="all, delete-orphan"
) )
# Add relationship back to Session model
from gatehouse_app.models.session import Session
Session.oidc_refresh_token = db.relationship(
"OIDCRefreshToken", back_populates="access_token", uselist=False
)
@@ -1106,6 +1106,7 @@ class ExternalAuthService:
def _build_authorization_url(config: ProviderConfigAdapter, state: OAuthState) -> str: def _build_authorization_url(config: ProviderConfigAdapter, state: OAuthState) -> str:
"""Build authorization URL using the provider config adapter.""" """Build authorization URL using the provider config adapter."""
from urllib.parse import urlencode from urllib.parse import urlencode
provider = (config.provider_type or "").lower()
params = { params = {
"client_id": config.client_id, "client_id": config.client_id,
@@ -1113,10 +1114,26 @@ class ExternalAuthService:
"response_type": "code", "response_type": "code",
"scope": " ".join(config.scopes or ["openid", "profile", "email"]), "scope": " ".join(config.scopes or ["openid", "profile", "email"]),
"state": state.state, "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: if state.nonce:
params["nonce"] = state.nonce params["nonce"] = state.nonce
@@ -1178,17 +1195,26 @@ class ExternalAuthService:
"""Get user info from provider using the provider config adapter.""" """Get user info from provider using the provider config adapter."""
import requests import requests
provider = (config.provider_type or "").lower()
headers = {"Authorization": f"Bearer {access_token}"} headers = {"Authorization": f"Bearer {access_token}"}
response = requests.get(config.userinfo_url, headers=headers) response = requests.get(config.userinfo_url, headers=headers)
response.raise_for_status() response.raise_for_status()
data = response.json() 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 # Standardize user info
return { return {
"provider_user_id": data.get("sub"), "provider_user_id": data.get("sub"),
"email": data.get("email"), "email": data.get("email"),
"email_verified": data.get("email_verified", False), "email_verified": email_verified,
"name": data.get("name"), "name": data.get("name"),
"first_name": data.get("given_name"), "first_name": data.get("given_name"),
"last_name": data.get("family_name"), "last_name": data.get("family_name"),
+84 -38
View File
@@ -81,10 +81,11 @@ class OAuthFlowService:
400, 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_verifier = None
code_challenge = 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_verifier = secrets.token_urlsafe(32)
code_challenge = ExternalAuthService._compute_s256_challenge(code_verifier) code_challenge = ExternalAuthService._compute_s256_challenge(code_verifier)
@@ -92,7 +93,7 @@ class OAuthFlowService:
logger.info( logger.info(
f"[PKCE DEBUG] Provider type check: provider_type_str='{provider_type_str}', " f"[PKCE DEBUG] Provider type check: provider_type_str='{provider_type_str}', "
f"is_google={provider_type_str in ['google']}, " 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 # Create OAuth state for login flow
@@ -183,10 +184,11 @@ class OAuthFlowService:
400, 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_verifier = None
code_challenge = 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_verifier = secrets.token_urlsafe(32)
code_challenge = ExternalAuthService._compute_s256_challenge(code_verifier) code_challenge = ExternalAuthService._compute_s256_challenge(code_verifier)
@@ -194,7 +196,7 @@ class OAuthFlowService:
logger.info( logger.info(
f"[PKCE DEBUG] Register flow - Provider type check: provider_type_str='{provider_type_str}', " f"[PKCE DEBUG] Register flow - Provider type check: provider_type_str='{provider_type_str}', "
f"is_google={provider_type_str in ['google']}, " 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 # Create OAuth state for register flow
@@ -404,50 +406,77 @@ class OAuthFlowService:
).first() ).first()
if not auth_method: 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( existing_user = User.query.filter_by(
email=user_info["email"] email=user_info["email"]
).first() ).first()
if existing_user: if existing_user:
AuditService.log_external_auth_login_failed( # Email exists but no OAuth link — auto-link and log in
organization_id=state_record.organization_id, logger.info(
provider_type=provider_type_str, 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_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"], email=user_info["email"],
failure_reason="email_exists", full_name=user_info.get("name", ""),
error_message=f"An account with email {user_info['email']} already exists", status="active",
) email_verified=user_info.get("email_verified", False),
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,
) )
user.save()
AuditService.log_external_auth_login_failed( auth_method = AuthenticationMethod(
organization_id=state_record.organization_id, user_id=user.id,
provider_type=provider_type_str, method_type=provider_type,
provider_user_id=user_info["provider_user_id"], provider_user_id=user_info["provider_user_id"],
email=user_info["email"], provider_data=ExternalAuthService._encrypt_provider_data(tokens, user_info),
failure_reason="account_not_found", verified=user_info.get("email_verified", False),
error_message="No Gatehouse account matches this external account", is_primary=True,
last_used_at=datetime.utcnow(),
) )
raise OAuthFlowError( auth_method.save()
"No Gatehouse account matches this external account. Please register first.",
"ACCOUNT_NOT_FOUND", AuditService.log_action(
404, 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:
user = auth_method.user # Existing linked account — update provider data
# Update provider data
auth_method.provider_data = ExternalAuthService._encrypt_provider_data( auth_method.provider_data = ExternalAuthService._encrypt_provider_data(
tokens, user_info tokens, user_info
) )
auth_method.last_used_at = datetime.utcnow() auth_method.last_used_at = datetime.utcnow()
auth_method.save() auth_method.save()
user = auth_method.user
# Get user's organizations # Get user's organizations
user_orgs = user.get_organizations() user_orgs = user.get_organizations()
@@ -465,16 +494,33 @@ class OAuthFlowService:
if not target_org and len(user_orgs) == 1: if not target_org and len(user_orgs) == 1:
target_org = user_orgs[0] 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: if not target_org and len(user_orgs) == 0:
# Mark state as used import re
state_record.mark_used() 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:
state_record.mark_used()
logger.info( logger.info(
f"OAuth login requires org selection for user={user.id}, " f"OAuth login requires org selection for user={user.id}, "
f"provider={provider_type_str}, org_count={len(user_orgs)}" f"provider={provider_type_str}, org_count={len(user_orgs)}"
) )
return { return {
"success": True, "success": True,
"flow_type": "login", "flow_type": "login",
@@ -488,7 +534,7 @@ class OAuthFlowService:
{ {
"id": org.id, "id": org.id,
"name": org.name, "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 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] 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") 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( OIDCAuditService.log_authorization_event(
client_id=client_id, client_id=client.id,
user_id=user_id, user_id=user_id,
success=True, success=True,
redirect_uri=redirect_uri, redirect_uri=redirect_uri,
@@ -264,7 +264,7 @@ class OIDCService:
if not auth_code: if not auth_code:
logger.error(f"[OIDC] Validate auth code - Code not found or deleted: code_hash={code_hash[:20]}...") logger.error(f"[OIDC] Validate auth code - Code not found or deleted: code_hash={code_hash[:20]}...")
OIDCAuditService.log_authorization_event( OIDCAuditService.log_authorization_event(
client_id=client_id, client_id=client.id,
success=False, success=False,
error_code="invalid_grant", error_code="invalid_grant",
error_description="Invalid or expired authorization code", error_description="Invalid or expired authorization code",
@@ -275,7 +275,7 @@ class OIDCService:
if auth_code.is_used: 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}") 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( OIDCAuditService.log_authorization_event(
client_id=client_id, client_id=client.id,
user_id=auth_code.user_id, user_id=auth_code.user_id,
success=False, success=False,
error_code="invalid_grant", 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", 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") code_hash[:20], auth_code.expires_at.isoformat() + "Z", datetime.now(timezone.utc).isoformat() + "Z")
OIDCAuditService.log_authorization_event( OIDCAuditService.log_authorization_event(
client_id=client_id, client_id=client.id,
user_id=auth_code.user_id, user_id=auth_code.user_id,
success=False, success=False,
error_code="invalid_grant", error_code="invalid_grant",
@@ -324,7 +324,7 @@ class OIDCService:
if expected_challenge != auth_code.code_verifier: 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]}...") logger.error(f"[OIDC] Validate auth code - Invalid code_verifier: expected={expected_challenge[:20]}..., got={auth_code.code_verifier[:20]}...")
OIDCAuditService.log_authorization_event( OIDCAuditService.log_authorization_event(
client_id=client_id, client_id=client.id,
user_id=auth_code.user_id, user_id=auth_code.user_id,
success=False, success=False,
error_code="invalid_grant", error_code="invalid_grant",
@@ -526,9 +526,9 @@ class OIDCService:
expires_at=id_token_expires_at, 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( OIDCAuditService.log_token_event(
client_id=client_id, client_id=client.id,
user_id=user_id, user_id=user_id,
token_type="access_token", token_type="access_token",
success=True, success=True,
@@ -607,7 +607,7 @@ class OIDCService:
if not refresh_token_obj: if not refresh_token_obj:
OIDCAuditService.log_token_event( OIDCAuditService.log_token_event(
client_id=client_id, client_id=client.id,
success=False, success=False,
error_code="invalid_grant", error_code="invalid_grant",
error_description="Invalid refresh token", error_description="Invalid refresh token",
@@ -627,7 +627,7 @@ class OIDCService:
if not refresh_token_obj.is_valid(): if not refresh_token_obj.is_valid():
OIDCAuditService.log_token_event( OIDCAuditService.log_token_event(
client_id=client_id, client_id=client.id,
user_id=refresh_token_obj.user_id, user_id=refresh_token_obj.user_id,
success=False, success=False,
error_code="invalid_grant", error_code="invalid_grant",
@@ -694,9 +694,9 @@ class OIDCService:
expires_at=access_token_expires_at, 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( OIDCAuditService.log_token_event(
client_id=client_id, client_id=client.id,
user_id=refresh_token_obj.user_id, user_id=refresh_token_obj.user_id,
token_type="access_token", token_type="access_token",
success=True, success=True,
@@ -754,9 +754,14 @@ class OIDCService:
logger.error("[OIDC SERVICE] Token validation failed: %s: %s", type(e).__name__, str(e)) logger.error("[OIDC SERVICE] Token validation failed: %s: %s", type(e).__name__, str(e))
import traceback import traceback
logger.error("[OIDC SERVICE] Traceback: %s", traceback.format_exc()) 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( OIDCAuditService.log_event(
event_type="token_validation", event_type="token_validation",
client_id=client_id, client_id=_client_db_id,
success=False, success=False,
error_code="invalid_token", error_code="invalid_token",
error_description=str(e), error_description=str(e),
@@ -806,7 +811,7 @@ class OIDCService:
revoked = True revoked = True
OIDCAuditService.log_token_revocation_event( OIDCAuditService.log_token_revocation_event(
client_id=client_id, client_id=client.id,
user_id=refresh_token.user_id, user_id=refresh_token.user_id,
token_type="refresh_token", token_type="refresh_token",
reason="revoked_by_client", reason="revoked_by_client",
@@ -828,7 +833,7 @@ class OIDCService:
revoked = True revoked = True
OIDCAuditService.log_token_revocation_event( OIDCAuditService.log_token_revocation_event(
client_id=client_id, client_id=client.id,
user_id=claims.get("sub"), user_id=claims.get("sub"),
token_type="access_token", token_type="access_token",
reason="revoked_by_client", reason="revoked_by_client",
@@ -859,10 +864,14 @@ class OIDCService:
""" """
result = OIDCTokenService.introspect_token(token, client_id) 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( OIDCAuditService.log_event(
event_type="token_introspection", event_type="token_introspection",
client_id=client_id, client_id=_introspect_client_db_id,
user_id=result.get("sub"), user_id=result.get("sub"),
success=result.get("active", False), success=result.get("active", False),
metadata={"active": result.get("active")}, metadata={"active": result.get("active")},
@@ -949,12 +958,17 @@ class OIDCService:
logger.debug("[OIDC SERVICE] Final userinfo: %s", userinfo) 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...") 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( OIDCAuditService.log_userinfo_event(
access_token=access_token, access_token=access_token,
user_id=user_id, user_id=user_id,
client_id=claims.get("client_id"), client_id=_userinfo_client_db_id,
success=True, success=True,
scopes_claimed=scopes, scopes_claimed=scopes,
) )
+3 -2
View File
@@ -164,9 +164,10 @@ class TOTPService:
be used again. This ensures each code is single-use. be used again. This ensures each code is single-use.
""" """
remaining_codes = [] remaining_codes = []
matched = False
for hashed_code in hashed_codes: 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 # Code found and valid - mark as matched but don't add to remaining codes
matched = True matched = True
else: else:
@@ -176,7 +177,7 @@ class TOTPService:
if matched: if matched:
return True, remaining_codes return True, remaining_codes
else: else:
return False, remaining_codes return False, hashed_codes
@staticmethod @staticmethod
def generate_qr_code_data_uri(provisioning_uri: str) -> str: def generate_qr_code_data_uri(provisioning_uri: str) -> str:
@@ -0,0 +1,72 @@
"""Fix oidc_refresh_tokens.access_token_id — widen column and drop wrong FK
The access_token_id column was VARCHAR(36) with a foreign key to sessions.id.
In practice the code stores JWT JTI strings (43+ chars) in this column, not
session UUIDs, so the FK constraint was wrong and the column was too narrow.
This migration:
1. Drops the foreign key constraint to sessions.id (IF EXISTS may have been
applied manually already via raw SQL)
2. Widens the column to VARCHAR(255)
Revision ID: 005
Revises: d2fd4f159054
Create Date: 2026-02-25
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
# revision identifiers, used by Alembic.
revision = '005'
down_revision = 'd2fd4f159054'
branch_labels = None
depends_on = None
def _fk_exists(conn, table_name, constraint_name):
"""Check whether a named FK constraint exists on a table."""
insp = Inspector.from_engine(conn)
fks = insp.get_foreign_keys(table_name)
return any(fk.get('name') == constraint_name for fk in fks)
def upgrade():
conn = op.get_bind()
# Drop the incorrect FK to sessions.id only if it still exists
# (may have been removed manually before this migration was written)
if _fk_exists(conn, 'oidc_refresh_tokens', 'oidc_refresh_tokens_access_token_id_fkey'):
op.drop_constraint(
'oidc_refresh_tokens_access_token_id_fkey',
'oidc_refresh_tokens',
type_='foreignkey'
)
# Widen the column to hold JWT JTI strings (43+ chars)
op.alter_column(
'oidc_refresh_tokens',
'access_token_id',
existing_type=sa.String(length=36),
type_=sa.String(length=255),
existing_nullable=True
)
def downgrade():
op.alter_column(
'oidc_refresh_tokens',
'access_token_id',
existing_type=sa.String(length=255),
type_=sa.String(length=36),
existing_nullable=True
)
# Re-add the FK constraint to sessions.id
op.create_foreign_key(
'oidc_refresh_tokens_access_token_id_fkey',
'oidc_refresh_tokens',
'sessions',
['access_token_id'],
['id']
)
+1 -1
View File
@@ -99,7 +99,7 @@ def upgrade():
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'), sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'), sa.UniqueConstraint('id'),
sa.UniqueConstraint('organization_id', 'provider_type', name='uix_org_provider_type') sa.UniqueConstraint('organization_id', 'provider_type', name='uix_org_provider_override_type')
) )
op.create_index(op.f('ix_organization_provider_overrides_organization_id'), 'organization_provider_overrides', ['organization_id'], unique=False) op.create_index(op.f('ix_organization_provider_overrides_organization_id'), 'organization_provider_overrides', ['organization_id'], unique=False)
op.create_index(op.f('ix_organization_provider_overrides_provider_type'), 'organization_provider_overrides', ['provider_type'], unique=False) op.create_index(op.f('ix_organization_provider_overrides_provider_type'), 'organization_provider_overrides', ['provider_type'], unique=False)
+69 -7
View File
@@ -12,6 +12,12 @@ Usage:
--client-secret "YOUR_CLIENT_SECRET" \\ --client-secret "YOUR_CLIENT_SECRET" \\
--redirect-url "http://localhost:5173/auth/callback" --redirect-url "http://localhost:5173/auth/callback"
# Create a Microsoft provider configuration
python scripts/configure_oauth_provider.py create microsoft \\
--client-id "YOUR_AZURE_APP_ID" \\
--client-secret "YOUR_AZURE_CLIENT_SECRET" \\
--redirect-url "http://localhost:5000/api/v1/auth/external/microsoft/callback"
# List all configured providers # List all configured providers
python scripts/configure_oauth_provider.py list python scripts/configure_oauth_provider.py list
@@ -27,6 +33,11 @@ Usage:
# Use environment variables # Use environment variables
GOOGLE_CLIENT_ID=xxx GOOGLE_CLIENT_SECRET=yyy \\ GOOGLE_CLIENT_ID=xxx GOOGLE_CLIENT_SECRET=yyy \\
python scripts/configure_oauth_provider.py create google python scripts/configure_oauth_provider.py create google
# Use environment variables (Microsoft)
MICROSOFT_CLIENT_ID=xxx MICROSOFT_CLIENT_SECRET=yyy \\
python scripts/configure_oauth_provider.py create microsoft
""" """
import os import os
@@ -50,6 +61,33 @@ from gatehouse_app import create_app
from gatehouse_app.services.external_auth_service import ExternalAuthService, ExternalAuthError from gatehouse_app.services.external_auth_service import ExternalAuthService, ExternalAuthError
def _microsoft_defaults() -> dict:
"""
Build Microsoft provider defaults, honouring MICROSOFT_TENANT_ID if set.
Tenant options:
- "common" : work/school AND personal Microsoft accounts (app must be
registered with "Accounts in any organizational directory
and personal Microsoft accounts" in Azure Portal)
- "consumers" : personal Microsoft accounts only (MSA)
- "organizations": work/school accounts only (AAD)
- "<tenant-id>": single specific Azure AD tenant (most secure for enterprise)
Set MICROSOFT_TENANT_ID env var or pass --tenant-id to the script.
"""
tenant = os.environ.get("MICROSOFT_TENANT_ID", "common")
base = f"https://login.microsoftonline.com/{tenant}"
return {
"auth_url": f"{base}/oauth2/v2.0/authorize",
"token_url": f"{base}/oauth2/v2.0/token",
"userinfo_url": "https://graph.microsoft.com/oidc/userinfo",
"jwks_url": f"{base}/discovery/v2.0/keys",
# offline_access is required by Microsoft to receive a refresh token
# (unlike Google which uses access_type=offline as a query param)
"scopes": ["openid", "profile", "email", "offline_access"],
}
# Provider endpoint configurations # Provider endpoint configurations
PROVIDER_DEFAULTS = { PROVIDER_DEFAULTS = {
"google": { "google": {
@@ -65,13 +103,7 @@ PROVIDER_DEFAULTS = {
"userinfo_url": "https://api.github.com/user", "userinfo_url": "https://api.github.com/user",
"scopes": ["read:user", "user:email"], "scopes": ["read:user", "user:email"],
}, },
"microsoft": { "microsoft": _microsoft_defaults(),
"auth_url": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
"token_url": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
"userinfo_url": "https://graph.microsoft.com/oidc/userinfo",
"jwks_url": "https://login.microsoftonline.com/common/discovery/v2.0/keys",
"scopes": ["openid", "profile", "email"],
},
} }
@@ -164,6 +196,25 @@ def create_provider(args):
defaults = PROVIDER_DEFAULTS[provider_type] defaults = PROVIDER_DEFAULTS[provider_type]
# For Microsoft, allow --tenant-id / MICROSOFT_TENANT_ID to override URLs at create time
if provider_type == "microsoft":
tenant_id = getattr(args, "tenant_id", None) or os.environ.get("MICROSOFT_TENANT_ID")
if tenant_id and tenant_id != os.environ.get("MICROSOFT_TENANT_ID", "common"):
# Recompute URLs with the supplied tenant
os.environ["MICROSOFT_TENANT_ID"] = tenant_id
defaults = _microsoft_defaults()
print_info(f"Using Microsoft tenant: {tenant_id}")
elif tenant_id:
print_info(f"Using Microsoft tenant: {tenant_id}")
else:
print_warning(
"No --tenant-id provided; using 'common'.\n"
" • For personal Microsoft accounts: your Azure app must be registered with\n"
" 'Accounts in any organizational directory and personal Microsoft accounts'.\n"
" • For work/school only: use --tenant-id organizations\n"
" • For a single Azure AD tenant: use --tenant-id <your-tenant-id>"
)
# Build configuration # Build configuration
config_data = { config_data = {
"client_id": client_id, "client_id": client_id,
@@ -231,6 +282,9 @@ def update_provider(args):
if args.enabled is not None: if args.enabled is not None:
updates["is_enabled"] = args.enabled updates["is_enabled"] = args.enabled
if args.scopes:
updates["scopes"] = [s.strip() for s in args.scopes.split(",")]
if args.settings: if args.settings:
settings = {} settings = {}
for setting in args.settings: for setting in args.settings:
@@ -443,6 +497,13 @@ Supported Providers:
create_parser.add_argument("--redirect-url", help="Default redirect URL for OAuth callbacks") create_parser.add_argument("--redirect-url", help="Default redirect URL for OAuth callbacks")
create_parser.add_argument("--disabled", action="store_true", help="Create provider in disabled state") create_parser.add_argument("--disabled", action="store_true", help="Create provider in disabled state")
create_parser.add_argument("--settings", action="append", help="Custom settings (key=value format)") create_parser.add_argument("--settings", action="append", help="Custom settings (key=value format)")
create_parser.add_argument(
"--tenant-id",
help=(
"Microsoft only: Azure AD tenant ID (or 'common' / 'consumers' / 'organizations'). "
"Defaults to the MICROSOFT_TENANT_ID env var, then 'common'."
),
)
create_parser.set_defaults(func=create_provider) create_parser.set_defaults(func=create_provider)
# Update command # Update command
@@ -453,6 +514,7 @@ Supported Providers:
update_parser.add_argument("--redirect-url", help="New default redirect URL") update_parser.add_argument("--redirect-url", help="New default redirect URL")
update_parser.add_argument("--enabled", type=lambda x: x.lower() in ['true', '1', 'yes'], update_parser.add_argument("--enabled", type=lambda x: x.lower() in ['true', '1', 'yes'],
help="Enable or disable the provider (true/false)") help="Enable or disable the provider (true/false)")
update_parser.add_argument("--scopes", help="Comma-separated list of OAuth scopes to set (e.g. 'openid,profile,email,offline_access')")
update_parser.add_argument("--settings", action="append", help="Custom settings to update (key=value format)") update_parser.add_argument("--settings", action="append", help="Custom settings to update (key=value format)")
update_parser.set_defaults(func=update_provider) update_parser.set_defaults(func=update_provider)