refactor(oidc): move OIDC module to versioned API path
- Move OIDC endpoints from gatehouse_app/api/oidc.py to gatehouse_app/api/v1/oidc.py - Register OIDC discovery endpoint directly on app instead of separate blueprint - Update service name from authy2-backend to secuird-backend in health check
This commit is contained in:
@@ -111,14 +111,20 @@ def setup_middleware(app):
|
||||
def register_blueprints(app):
|
||||
"""Register application blueprints."""
|
||||
from gatehouse_app.api import register_api_blueprints
|
||||
from gatehouse_app.api.oidc import oidc_bp, oidc_discovery_bp
|
||||
|
||||
register_api_blueprints(app)
|
||||
|
||||
# Register OIDC discovery at root level (OIDC spec requirement: .well-known must be at root)
|
||||
app.register_blueprint(oidc_discovery_bp)
|
||||
# Register OIDC blueprint at /api/v1/oidc (conforms to API versioning standard)
|
||||
app.register_blueprint(oidc_bp, url_prefix="/api/v1/oidc")
|
||||
# Register OIDC discovery endpoint at root (OIDC spec requirement)
|
||||
from gatehouse_app.api.v1.oidc import get_oidc_config
|
||||
from flask import jsonify
|
||||
|
||||
@app.route("/.well-known/openid-configuration", methods=["GET"])
|
||||
def oidc_discovery():
|
||||
"""OpenID Connect Discovery endpoint at root level (OIDC spec requirement)."""
|
||||
config = get_oidc_config()
|
||||
response = jsonify(config)
|
||||
response.headers["Cache-Control"] = "max-age=86400"
|
||||
return response, 200
|
||||
|
||||
|
||||
def register_error_handlers(app):
|
||||
|
||||
@@ -10,7 +10,7 @@ api_bp = Blueprint("api", __name__)
|
||||
def health_check():
|
||||
"""Health check endpoint."""
|
||||
return api_response(
|
||||
data={"status": "healthy", "service": "authy2-backend"},
|
||||
data={"status": "healthy", "service": "secuird-backend"},
|
||||
message="Service is running",
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ from flask import Blueprint
|
||||
api_v1_bp = Blueprint("api_v1", __name__)
|
||||
|
||||
# Import route modules to register them
|
||||
from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth, departments, principals, ssh, zerotier, sudo
|
||||
from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth, departments, principals, ssh, zerotier, sudo, oidc
|
||||
|
||||
api_v1_bp.register_blueprint(ssh.ssh_bp)
|
||||
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
"""OIDC (OpenID Connect) API endpoints - API v1 blueprint."""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import bcrypt
|
||||
from flask import request, redirect, jsonify, session, g, current_app
|
||||
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.services.oidc import (
|
||||
OIDCService, InvalidClientError, InvalidGrantError, InvalidRequestError
|
||||
)
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.extensions import bcrypt as flask_bcrypt
|
||||
from gatehouse_app.extensions import redis_client as _redis_client_ref
|
||||
from gatehouse_app.models import User, OIDCClient
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.exceptions.auth_exceptions import (
|
||||
InvalidCredentialsError,
|
||||
AccountSuspendedError,
|
||||
AccountInactiveError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for Redis-backed OIDC pending state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OIDC_PENDING_TTL = 600
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
def get_oidc_config():
|
||||
"""Get OIDC configuration from app config."""
|
||||
base_url = current_app.config.get("OIDC_ISSUER_URL", "http://localhost:5000")
|
||||
return {
|
||||
"issuer": base_url,
|
||||
"authorization_endpoint": f"{base_url}/api/v1/oidc/authorize",
|
||||
"token_endpoint": f"{base_url}/api/v1/oidc/token",
|
||||
"userinfo_endpoint": f"{base_url}/api/v1/oidc/userinfo",
|
||||
"jwks_uri": f"{base_url}/api/v1/oidc/jwks",
|
||||
"registration_endpoint": f"{base_url}/api/v1/oidc/register",
|
||||
"revocation_endpoint": f"{base_url}/api/v1/oidc/revoke",
|
||||
"introspection_endpoint": f"{base_url}/api/v1/oidc/introspect",
|
||||
"scopes_supported": ["openid", "profile", "email", "roles"],
|
||||
"response_types_supported": ["code"],
|
||||
"response_modes_supported": ["query"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
||||
"subject_types_supported": ["public"],
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
"claims_supported": ["sub", "name", "email", "email_verified", "roles"],
|
||||
}
|
||||
|
||||
|
||||
def authenticate_client(client_id, client_secret=None):
|
||||
"""Authenticate an OIDC client."""
|
||||
logger.debug(f"[OIDC] Client validation: client_id={client_id}, confidential={client_secret is not None}")
|
||||
|
||||
client = OIDCClient.query.filter_by(client_id=client_id, is_active=True).first()
|
||||
if not client:
|
||||
logger.debug(f"[OIDC] Client validation: client_id={client_id}, exists=False")
|
||||
raise InvalidClientError("Invalid client")
|
||||
|
||||
logger.debug(f"[OIDC] Client validation: client_id={client_id}, client_id_db={client.id}, exists=True")
|
||||
|
||||
if client.is_confidential and client_secret:
|
||||
secret_match = _check_password_hash(client, client_secret)
|
||||
logger.debug(f"[OIDC] Client secret validation: client_id={client_id}, match={secret_match}")
|
||||
if not secret_match:
|
||||
raise InvalidClientError("Invalid client credentials")
|
||||
|
||||
return client
|
||||
|
||||
|
||||
def require_valid_token():
|
||||
"""Validate Bearer token from Authorization header."""
|
||||
logger.debug("[OIDC USERINFO] require_valid_token() called")
|
||||
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise InvalidGrantError("Invalid token: Missing or invalid Authorization header")
|
||||
|
||||
token = auth_header[7:]
|
||||
try:
|
||||
claims = OIDCService.validate_access_token(token)
|
||||
except Exception as e:
|
||||
logger.error("[OIDC USERINFO] Token validation failed: %s: %s", type(e).__name__, str(e))
|
||||
raise
|
||||
|
||||
g.current_token = claims
|
||||
g.access_token = token
|
||||
|
||||
user_id = claims.get("sub")
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
raise InvalidGrantError("Invalid token: User not found")
|
||||
|
||||
g.current_user = user
|
||||
|
||||
|
||||
def _check_password_hash(client, password):
|
||||
"""Check password hash with backward compatibility for old bcrypt format."""
|
||||
pw_hash = client.client_secret_hash
|
||||
|
||||
try:
|
||||
return flask_bcrypt.check_password_hash(pw_hash, password)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
match = bcrypt.checkpw(
|
||||
pw_hash.encode('utf-8') if isinstance(pw_hash, str) else pw_hash,
|
||||
password.encode('utf-8') if isinstance(password, str) else password
|
||||
)
|
||||
if match:
|
||||
new_hash = flask_bcrypt.generate_password_hash(
|
||||
password.decode('utf-8') if isinstance(password, bytes) else password
|
||||
).decode('utf-8')
|
||||
client.client_secret_hash = new_hash
|
||||
db.session.commit()
|
||||
logger.info(f"[OIDC] Migrated client secret hash to new format: client_id={client.client_id}")
|
||||
return match
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def parse_basic_auth():
|
||||
"""Parse Basic authentication from Authorization header."""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Basic "):
|
||||
try:
|
||||
encoded = auth_header[6:]
|
||||
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||
client_id, client_secret = decoded.split(":", 1)
|
||||
return client_id, client_secret
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# OIDC UI Bridge
|
||||
# ============================================================================
|
||||
|
||||
@api_v1_bp.route("/oidc/begin", methods=["POST"])
|
||||
def oidc_begin():
|
||||
"""Stash OIDC authorize params server-side, return a one-time session ID."""
|
||||
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)
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/oidc/complete", methods=["POST"])
|
||||
def oidc_complete():
|
||||
"""Complete an OIDC authorization flow after the UI has authenticated the user."""
|
||||
from gatehouse_app.models.user.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)
|
||||
|
||||
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)
|
||||
|
||||
from gatehouse_app.models.user.user import User as _User
|
||||
from gatehouse_app.utils.constants import UserStatus
|
||||
_complete_user = _User.query.filter_by(id=user_id, deleted_at=None).first()
|
||||
if not _complete_user or _complete_user.status in (
|
||||
UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED, UserStatus.INACTIVE
|
||||
):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Your account is not active or has been suspended.",
|
||||
status=403,
|
||||
error_type="ACCOUNT_SUSPENDED",
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
@api_v1_bp.route("/oidc/authorize", methods=["GET", "POST"])
|
||||
def oidc_authorize():
|
||||
"""OpenID Connect Authorization endpoint."""
|
||||
logger.debug("[OIDC] oidc_authorize called")
|
||||
|
||||
if request.method == "GET":
|
||||
params = request.args.to_dict()
|
||||
else:
|
||||
params = request.form.to_dict()
|
||||
|
||||
client_id = params.get("client_id")
|
||||
redirect_uri = params.get("redirect_uri")
|
||||
response_type = params.get("response_type")
|
||||
scope = params.get("scope", "")
|
||||
state = params.get("state", "")
|
||||
nonce = params.get("nonce", "")
|
||||
code_challenge = params.get("code_challenge")
|
||||
code_challenge_method = params.get("code_challenge_method")
|
||||
|
||||
errors = []
|
||||
if not client_id:
|
||||
errors.append("client_id is required")
|
||||
if not redirect_uri:
|
||||
errors.append("redirect_uri is required")
|
||||
if not response_type:
|
||||
errors.append("response_type is required")
|
||||
|
||||
if errors:
|
||||
return _redirect_with_error(redirect_uri, "invalid_request", "; ".join(errors), state)
|
||||
|
||||
if response_type != "code":
|
||||
return _redirect_with_error(
|
||||
redirect_uri, "unsupported_response_type",
|
||||
"Only response_type=code is supported", state
|
||||
)
|
||||
|
||||
client = OIDCClient.query.filter_by(client_id=client_id, is_active=True).first()
|
||||
if not client:
|
||||
return _redirect_with_error(redirect_uri, "unauthorized_client", "Invalid client", state)
|
||||
|
||||
if not client.is_redirect_uri_allowed(redirect_uri):
|
||||
return _redirect_with_error(redirect_uri, "invalid_request", "Invalid redirect_uri", state)
|
||||
|
||||
requested_scopes = scope.split() if scope else []
|
||||
allowed_scopes = client.scopes or []
|
||||
valid_scopes = [s for s in requested_scopes if s in allowed_scopes]
|
||||
|
||||
if not valid_scopes:
|
||||
return _redirect_with_error(redirect_uri, "invalid_scope", "Invalid or no scopes requested", state)
|
||||
|
||||
user_id = session.get("oidc_user_id")
|
||||
|
||||
if request.method == "POST" and not user_id:
|
||||
email = params.get("email")
|
||||
password = params.get("password")
|
||||
|
||||
if not email or not password:
|
||||
return _show_login_page(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
response_type=response_type,
|
||||
error="Invalid credentials"
|
||||
)
|
||||
|
||||
try:
|
||||
user = AuthService.authenticate(email, password)
|
||||
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me=False)
|
||||
|
||||
if not policy_result.can_create_full_session:
|
||||
return _show_login_page(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
response_type=response_type,
|
||||
error="Your account requires multi factor enrollment before using single sign on"
|
||||
)
|
||||
|
||||
user_id = user.id
|
||||
session["oidc_user_id"] = user_id
|
||||
except AccountSuspendedError:
|
||||
return _show_login_page(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
response_type=response_type,
|
||||
error="Your account has been suspended. Please contact an administrator.",
|
||||
)
|
||||
except AccountInactiveError:
|
||||
return _show_login_page(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
response_type=response_type,
|
||||
error="Your account is not active. Please verify your email.",
|
||||
)
|
||||
except InvalidCredentialsError:
|
||||
return _show_login_page(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
response_type=response_type,
|
||||
error="Invalid email or password"
|
||||
)
|
||||
|
||||
if not user_id:
|
||||
return _show_login_page(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
response_type=response_type
|
||||
)
|
||||
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return _redirect_with_error(redirect_uri, "server_error", "User not found", state)
|
||||
|
||||
from gatehouse_app.utils.constants import UserStatus as _UserStatus
|
||||
if user.status in (_UserStatus.SUSPENDED, _UserStatus.COMPLIANCE_SUSPENDED):
|
||||
session.pop("oidc_user_id", None)
|
||||
return _show_login_page(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
response_type=response_type,
|
||||
error="Your account has been suspended. Please contact an administrator.",
|
||||
)
|
||||
if user.status == _UserStatus.INACTIVE:
|
||||
session.pop("oidc_user_id", None)
|
||||
return _show_login_page(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
response_type=response_type,
|
||||
error="Your account is not active. Please verify your email.",
|
||||
)
|
||||
|
||||
try:
|
||||
code = OIDCService.generate_authorization_code(
|
||||
client_id=client_id,
|
||||
user_id=user_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=valid_scopes,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method=code_challenge_method,
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("[OIDC] Authorization code generation failed: %s", str(e))
|
||||
return _redirect_with_error(redirect_uri, "server_error", str(e), state)
|
||||
|
||||
redirect_params = {"code": code}
|
||||
if state:
|
||||
redirect_params["state"] = state
|
||||
|
||||
redirect_url = f"{redirect_uri}?{urlencode(redirect_params)}"
|
||||
return redirect(redirect_url)
|
||||
|
||||
|
||||
def _redirect_with_error(redirect_uri, error, error_description, state=None):
|
||||
"""Redirect to client with error parameters."""
|
||||
if not redirect_uri:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=error_description,
|
||||
status=400,
|
||||
error_type=error.upper(),
|
||||
error_details={"error": error, "error_description": error_description},
|
||||
)
|
||||
|
||||
params = {
|
||||
"error": error,
|
||||
"error_description": error_description,
|
||||
}
|
||||
if state:
|
||||
params["state"] = state
|
||||
|
||||
return redirect(f"{redirect_uri}?{urlencode(params)}")
|
||||
|
||||
|
||||
def _show_login_page(client_id, redirect_uri, scope, state, nonce, response_type, error=None):
|
||||
"""Redirect to the Gatehouse React UI login page for a proper login experience."""
|
||||
ui_base_url = current_app.config.get("OIDC_UI_URL", "http://localhost:8080")
|
||||
|
||||
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}/oidc-login?{urlencode(params)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Token Endpoint
|
||||
# ============================================================================
|
||||
|
||||
@api_v1_bp.route("/oidc/token", methods=["POST"])
|
||||
def oidc_token():
|
||||
"""OpenID Connect Token endpoint."""
|
||||
if request.content_type and "application/x-www-form-urlencoded" in request.content_type:
|
||||
data = request.form.to_dict()
|
||||
else:
|
||||
data = request.json or {}
|
||||
|
||||
grant_type = data.get("grant_type")
|
||||
if not grant_type:
|
||||
response = jsonify({
|
||||
"error": "invalid_request",
|
||||
"error_description": "grant_type is required"
|
||||
})
|
||||
return response, 400
|
||||
|
||||
client_id = data.get("client_id")
|
||||
client_secret = data.get("client_secret")
|
||||
|
||||
if not client_id:
|
||||
client_id, client_secret = parse_basic_auth()
|
||||
|
||||
if not client_id:
|
||||
response = jsonify({
|
||||
"error": "invalid_client",
|
||||
"error_description": "Client authentication required"
|
||||
})
|
||||
response.headers["WWW-Authenticate"] = 'Basic realm="OIDC Token Endpoint"'
|
||||
return response, 401
|
||||
|
||||
try:
|
||||
client = authenticate_client(client_id, client_secret)
|
||||
except InvalidClientError:
|
||||
response = jsonify({
|
||||
"error": "invalid_client",
|
||||
"error_description": "Invalid client credentials"
|
||||
})
|
||||
return response, 401
|
||||
|
||||
if grant_type == "authorization_code":
|
||||
return _handle_authorization_code_grant(data, client)
|
||||
elif grant_type == "refresh_token":
|
||||
return _handle_refresh_token_grant(data, client)
|
||||
else:
|
||||
response = jsonify({
|
||||
"error": "unsupported_grant_type",
|
||||
"error_description": f"Grant type '{grant_type}' is not supported"
|
||||
})
|
||||
return response, 400
|
||||
|
||||
|
||||
def _handle_authorization_code_grant(data, client):
|
||||
"""Handle authorization_code grant type."""
|
||||
code = data.get("code")
|
||||
redirect_uri = data.get("redirect_uri")
|
||||
code_verifier = data.get("code_verifier")
|
||||
|
||||
if not code:
|
||||
return jsonify({"error": "invalid_request", "error_description": "code is required"}), 400
|
||||
|
||||
if not redirect_uri:
|
||||
return jsonify({"error": "invalid_request", "error_description": "redirect_uri is required"}), 400
|
||||
|
||||
try:
|
||||
claims, user = OIDCService.validate_authorization_code(
|
||||
code=code,
|
||||
client_id=client.client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
code_verifier=code_verifier,
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
)
|
||||
except InvalidGrantError as e:
|
||||
return jsonify({"error": "invalid_grant", "error_description": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": "invalid_grant", "error_description": str(e)}), 400
|
||||
|
||||
try:
|
||||
tokens = OIDCService.generate_tokens(
|
||||
client_id=client.client_id,
|
||||
user_id=claims["user_id"],
|
||||
scope=claims["scope"],
|
||||
nonce=claims.get("nonce"),
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
auth_time=int(__import__("time").time()),
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": "server_error", "error_description": str(e)}), 500
|
||||
|
||||
response = jsonify(tokens)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
return response, 200
|
||||
|
||||
|
||||
def _handle_refresh_token_grant(data, client):
|
||||
"""Handle refresh_token grant type."""
|
||||
refresh_token = data.get("refresh_token")
|
||||
scope = data.get("scope")
|
||||
|
||||
if not refresh_token:
|
||||
return jsonify({"error": "invalid_request", "error_description": "refresh_token is required"}), 400
|
||||
|
||||
scope_list = scope.split() if scope else None
|
||||
|
||||
try:
|
||||
tokens = OIDCService.refresh_access_token(
|
||||
refresh_token=refresh_token,
|
||||
client_id=client.client_id,
|
||||
scope=scope_list,
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
)
|
||||
except InvalidGrantError as e:
|
||||
return jsonify({"error": "invalid_grant", "error_description": str(e)}), 400
|
||||
|
||||
response = jsonify(tokens)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
return response, 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# UserInfo Endpoint
|
||||
# ============================================================================
|
||||
|
||||
@api_v1_bp.route("/oidc/userinfo", methods=["GET", "POST"])
|
||||
def oidc_userinfo():
|
||||
"""OpenID Connect UserInfo endpoint."""
|
||||
try:
|
||||
require_valid_token()
|
||||
except InvalidGrantError as e:
|
||||
response = jsonify({
|
||||
"error": "invalid_token",
|
||||
"error_description": str(e)
|
||||
})
|
||||
response.headers["WWW-Authenticate"] = 'Bearer realm="OIDC UserInfo Endpoint", error="invalid_token"'
|
||||
return response, 401
|
||||
except Exception as e:
|
||||
response = jsonify({
|
||||
"error": "server_error",
|
||||
"error_description": str(e)
|
||||
})
|
||||
response.headers["WWW-Authenticate"] = 'Bearer realm="OIDC UserInfo Endpoint", error="server_error"'
|
||||
return response, 500
|
||||
|
||||
access_token = g.access_token
|
||||
try:
|
||||
userinfo = OIDCService.get_userinfo(access_token)
|
||||
except Exception as e:
|
||||
return jsonify({"error": "server_error", "error_description": str(e)}), 500
|
||||
|
||||
response = jsonify(userinfo)
|
||||
response.headers["Cache-Control"] = "no-cache, no-store"
|
||||
return response, 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# JWKS Endpoint
|
||||
# ============================================================================
|
||||
|
||||
@api_v1_bp.route("/oidc/jwks", methods=["GET"])
|
||||
def oidc_jwks():
|
||||
"""OpenID Connect JSON Web Key Set endpoint."""
|
||||
try:
|
||||
jwks = OIDCService.get_jwks()
|
||||
except Exception as e:
|
||||
return jsonify({"error": "server_error", "error_description": str(e)}), 500
|
||||
|
||||
response = jsonify(jwks)
|
||||
response.headers["Cache-Control"] = "max-age=3600"
|
||||
return response, 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Token Revocation Endpoint
|
||||
# ============================================================================
|
||||
|
||||
@api_v1_bp.route("/oidc/revoke", methods=["POST"])
|
||||
def oidc_revoke():
|
||||
"""OAuth2 Token Revocation endpoint."""
|
||||
if request.content_type and "application/x-www-form-urlencoded" in request.content_type:
|
||||
data = request.form.to_dict()
|
||||
else:
|
||||
data = request.json or {}
|
||||
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
return jsonify({"error": "invalid_request", "error_description": "token is required"}), 400
|
||||
|
||||
client_id = data.get("client_id")
|
||||
client_secret = data.get("client_secret")
|
||||
|
||||
if not client_id:
|
||||
client_id, client_secret = parse_basic_auth()
|
||||
|
||||
if not client_id:
|
||||
response = jsonify({"error": "invalid_client", "error_description": "Client authentication required"})
|
||||
response.headers["WWW-Authenticate"] = 'Basic realm="OIDC Revoke Endpoint"'
|
||||
return response, 401
|
||||
|
||||
try:
|
||||
client = authenticate_client(client_id, client_secret)
|
||||
except InvalidClientError:
|
||||
return jsonify({"error": "invalid_client", "error_description": "Invalid client credentials"}), 401
|
||||
|
||||
token_type_hint = data.get("token_type_hint")
|
||||
|
||||
try:
|
||||
OIDCService.revoke_token(
|
||||
token=token,
|
||||
client_id=client.client_id,
|
||||
token_type_hint=token_type_hint,
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "", 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Token Introspection Endpoint
|
||||
# ============================================================================
|
||||
|
||||
@api_v1_bp.route("/oidc/introspect", methods=["POST"])
|
||||
def oidc_introspect():
|
||||
"""OAuth2 Token Introspection endpoint."""
|
||||
if request.content_type and "application/x-www-form-urlencoded" in request.content_type:
|
||||
data = request.form.to_dict()
|
||||
else:
|
||||
data = request.json or {}
|
||||
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
return jsonify({"error": "invalid_request", "error_description": "token is required"}), 400
|
||||
|
||||
client_id = data.get("client_id")
|
||||
client_secret = data.get("client_secret")
|
||||
|
||||
if not client_id:
|
||||
client_id, client_secret = parse_basic_auth()
|
||||
|
||||
if not client_id:
|
||||
response = jsonify({"error": "invalid_client", "error_description": "Client authentication required"})
|
||||
response.headers["WWW-Authenticate"] = 'Basic realm="OIDC Introspect Endpoint"'
|
||||
return response, 401
|
||||
|
||||
try:
|
||||
client = authenticate_client(client_id, client_secret)
|
||||
except InvalidClientError:
|
||||
return jsonify({"error": "invalid_client", "error_description": "Invalid client credentials"}), 401
|
||||
|
||||
token_type_hint = data.get("token_type_hint")
|
||||
|
||||
try:
|
||||
result = OIDCService.introspect_token(
|
||||
token=token,
|
||||
client_id=client.client_id,
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": "server_error", "error_description": str(e)}), 500
|
||||
|
||||
response = jsonify(result)
|
||||
response.headers["Cache-Control"] = "no-cache, no-store"
|
||||
return response, 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Client Registration Endpoint
|
||||
# ============================================================================
|
||||
|
||||
@api_v1_bp.route("/oidc/register", methods=["POST"])
|
||||
def oidc_register():
|
||||
"""OpenID Connect Client Registration endpoint."""
|
||||
data = request.json or {}
|
||||
|
||||
client_name = data.get("client_name")
|
||||
redirect_uris = data.get("redirect_uris", [])
|
||||
|
||||
if not client_name:
|
||||
return jsonify({"error": "invalid_request", "error_description": "client_name is required"}), 400
|
||||
|
||||
if not redirect_uris:
|
||||
return jsonify({"error": "invalid_request", "error_description": "redirect_uris is required"}), 400
|
||||
|
||||
for uri in redirect_uris:
|
||||
try:
|
||||
parsed = urlparse(uri)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
raise ValueError(f"Invalid redirect URI: {uri}")
|
||||
except Exception:
|
||||
return jsonify({"error": "invalid_request", "error_description": f"Invalid redirect_uri: {uri}"}), 400
|
||||
|
||||
client_id = f"oidc_{secrets.token_urlsafe(16)}"
|
||||
client_secret = f"secret_{secrets.token_urlsafe(24)}"
|
||||
client_secret_hash = flask_bcrypt.generate_password_hash(client_secret).decode("utf-8")
|
||||
|
||||
org_id = data.get("organization_id")
|
||||
if org_id:
|
||||
organization = Organization.query.get(org_id)
|
||||
else:
|
||||
organization = Organization.query.filter_by(is_active=True).first()
|
||||
|
||||
if not organization:
|
||||
organization = Organization(
|
||||
name=f"OIDC Clients",
|
||||
slug=f"oidc-clients-{secrets.token_urlsafe(8)}",
|
||||
)
|
||||
organization.save()
|
||||
|
||||
client = OIDCClient(
|
||||
organization_id=organization.id,
|
||||
name=client_name,
|
||||
client_id=client_id,
|
||||
client_secret_hash=client_secret_hash,
|
||||
redirect_uris=redirect_uris,
|
||||
grant_types=data.get("grant_types", ["authorization_code", "refresh_token"]),
|
||||
response_types=data.get("response_types", ["code"]),
|
||||
scopes=data.get("scope", "openid profile email roles").split(),
|
||||
is_active=True,
|
||||
is_confidential=True,
|
||||
require_pkce=True,
|
||||
logo_uri=data.get("logo_uri"),
|
||||
client_uri=data.get("client_uri"),
|
||||
policy_uri=data.get("policy_uri"),
|
||||
tos_uri=data.get("tos_uri"),
|
||||
)
|
||||
client.save()
|
||||
|
||||
response = jsonify({
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"client_id_issued_at": int(__import__("time").time()),
|
||||
"client_secret_expires_at": 0,
|
||||
"client_name": client_name,
|
||||
"redirect_uris": redirect_uris,
|
||||
"token_endpoint_auth_method": data.get("token_endpoint_auth_method", "client_secret_basic"),
|
||||
"grant_types": client.grant_types,
|
||||
"response_types": client.response_types,
|
||||
"scope": " ".join(client.scopes),
|
||||
})
|
||||
return response, 201
|
||||
Reference in New Issue
Block a user