Merge pull request #1 from jamesii-b/oidc/v1.01
Feat: OIDC UI bridge, Microsoft SSO,, and schema session flaws
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(",")
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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']
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user