Added OIDC client CORS attributes
This commit is contained in:
@@ -29,6 +29,7 @@ from gatehouse_app.exceptions.auth_exceptions import (
|
||||
from gatehouse_app.utils.constants import AuditAction
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
from gatehouse_app.services.oidc_audit_service import OIDCAuditService
|
||||
from gatehouse_app.utils.validators import validate_cors_origins
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -816,6 +817,11 @@ def oidc_register():
|
||||
except Exception:
|
||||
return jsonify({"error": "invalid_request", "error_description": f"Invalid redirect_uri: {uri}"}), 400
|
||||
|
||||
cors_origins_raw = data.get("allowed_cors_origins")
|
||||
cors_origins, cors_error = validate_cors_origins(cors_origins_raw)
|
||||
if cors_error:
|
||||
return jsonify({"error": "invalid_request", "error_description": cors_error}), 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")
|
||||
@@ -842,6 +848,7 @@ def oidc_register():
|
||||
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(),
|
||||
allowed_cors_origins=cors_origins,
|
||||
is_active=True,
|
||||
is_confidential=True,
|
||||
require_pkce=True,
|
||||
@@ -871,6 +878,7 @@ def oidc_register():
|
||||
"client_secret_expires_at": 0,
|
||||
"client_name": client_name,
|
||||
"redirect_uris": redirect_uris,
|
||||
"allowed_cors_origins": client.allowed_cors_origins,
|
||||
"token_endpoint_auth_method": data.get("token_endpoint_auth_method", "client_secret_basic"),
|
||||
"grant_types": client.grant_types,
|
||||
"response_types": client.response_types,
|
||||
|
||||
@@ -7,6 +7,7 @@ from gatehouse_app.utils.decorators import login_required, require_admin, full_a
|
||||
from gatehouse_app.extensions import db, bcrypt
|
||||
from gatehouse_app.utils.constants import AuditAction
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
from gatehouse_app.utils.validators import validate_cors_origins
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/clients", methods=["GET"])
|
||||
@@ -63,6 +64,11 @@ def create_org_client(org_id):
|
||||
if not redirect_uris:
|
||||
return api_response(success=False, message="At least one redirect URI is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
cors_origins_raw = data.get("allowed_cors_origins")
|
||||
cors_origins, cors_error = validate_cors_origins(cors_origins_raw)
|
||||
if cors_error:
|
||||
return api_response(success=False, message=cors_error, status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
client_id = _secrets.token_hex(16)
|
||||
client_secret = _secrets.token_urlsafe(32)
|
||||
|
||||
@@ -75,6 +81,7 @@ def create_org_client(org_id):
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
response_types=["code"],
|
||||
scopes=["openid", "profile", "email"],
|
||||
allowed_cors_origins=cors_origins,
|
||||
is_active=True,
|
||||
is_confidential=True,
|
||||
)
|
||||
@@ -99,6 +106,7 @@ def create_org_client(org_id):
|
||||
"client_secret": client_secret,
|
||||
"redirect_uris": client.redirect_uris,
|
||||
"scopes": client.scopes,
|
||||
"allowed_cors_origins": client.allowed_cors_origins,
|
||||
"created_at": client.created_at.isoformat() + "Z",
|
||||
}
|
||||
},
|
||||
@@ -135,6 +143,12 @@ def update_org_client(org_id, client_id):
|
||||
return api_response(success=False, message="At least one redirect URI is required", status=400, error_type="VALIDATION_ERROR")
|
||||
client.redirect_uris = uris
|
||||
|
||||
if "allowed_cors_origins" in data:
|
||||
cors_origins, cors_error = validate_cors_origins(data["allowed_cors_origins"])
|
||||
if cors_error:
|
||||
return api_response(success=False, message=cors_error, status=400, error_type="VALIDATION_ERROR")
|
||||
client.allowed_cors_origins = cors_origins
|
||||
|
||||
db.session.commit()
|
||||
|
||||
AuditService.log_action(
|
||||
@@ -155,6 +169,7 @@ def update_org_client(org_id, client_id):
|
||||
"redirect_uris": client.redirect_uris,
|
||||
"scopes": client.scopes,
|
||||
"grant_types": client.grant_types,
|
||||
"allowed_cors_origins": client.allowed_cors_origins,
|
||||
"is_active": client.is_active,
|
||||
"created_at": client.created_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Validation helpers for request data."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Special sentinel values allowed in allowed_cors_origins
|
||||
_CORS_SENTINELS = {"+", "*"}
|
||||
|
||||
|
||||
def validate_cors_origins(origins):
|
||||
"""Validate a list of CORS origin values.
|
||||
|
||||
Accepts:
|
||||
None - means "use global CORS config" (pass-through)
|
||||
["+"] - derive origins from the client's redirect_uris
|
||||
["*"] - allow any origin
|
||||
["https://host"] - explicit allow-list of well-formed origins
|
||||
|
||||
Each non-sentinel entry must be a well-formed origin:
|
||||
scheme (http or https) + host + optional port, with NO path,
|
||||
query string, or fragment.
|
||||
|
||||
Returns:
|
||||
(validated_value, None) on success, or
|
||||
(None, error_message) on failure.
|
||||
"""
|
||||
if origins is None:
|
||||
return None, None
|
||||
|
||||
if not isinstance(origins, list):
|
||||
return None, "allowed_cors_origins must be a list or null"
|
||||
|
||||
validated = []
|
||||
for i, entry in enumerate(origins):
|
||||
if not isinstance(entry, str):
|
||||
return None, f"allowed_cors_origins[{i}]: expected a string, got {type(entry).__name__}"
|
||||
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
return None, f"allowed_cors_origins[{i}]: empty string is not allowed"
|
||||
|
||||
# Sentinel values are accepted as-is
|
||||
if entry in _CORS_SENTINELS:
|
||||
validated.append(entry)
|
||||
continue
|
||||
|
||||
# Parse and validate as origin
|
||||
error = _validate_single_origin(entry, i)
|
||||
if error:
|
||||
return None, error
|
||||
|
||||
validated.append(entry)
|
||||
|
||||
return validated, None
|
||||
|
||||
|
||||
def _validate_single_origin(origin, index):
|
||||
"""Validate that a string is a well-formed browser origin.
|
||||
|
||||
A valid origin is: scheme://host[:port] with no path, query, or fragment.
|
||||
Only http and https schemes are accepted.
|
||||
|
||||
Returns an error message string on failure, or None on success.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(origin)
|
||||
except Exception:
|
||||
return f"allowed_cors_origins[{index}]: '{origin}' is not a valid URL"
|
||||
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return (
|
||||
f"allowed_cors_origins[{index}]: '{origin}' has an invalid scheme "
|
||||
f"'{parsed.scheme}'; only 'http' and 'https' are allowed"
|
||||
)
|
||||
|
||||
if not parsed.hostname:
|
||||
return f"allowed_cors_origins[{index}]: '{origin}' is missing a hostname"
|
||||
|
||||
# Origins must not have a path (other than empty or "/"), query, or fragment
|
||||
if parsed.path and parsed.path != "/":
|
||||
return (
|
||||
f"allowed_cors_origins[{index}]: '{origin}' must not contain a path "
|
||||
f"(got '{parsed.path}'). Specify only scheme://host[:port]"
|
||||
)
|
||||
|
||||
if parsed.query:
|
||||
return (
|
||||
f"allowed_cors_origins[{index}]: '{origin}' must not contain a query string"
|
||||
)
|
||||
|
||||
if parsed.fragment:
|
||||
return (
|
||||
f"allowed_cors_origins[{index}]: '{origin}' must not contain a fragment"
|
||||
)
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user