-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
205 lines (167 loc) · 6.3 KB
/
Copy pathapp.py
File metadata and controls
205 lines (167 loc) · 6.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
#!/usr/bin/env python3
"""Stateless SAML 2.0 Identity Provider — Flask application."""
import os
import secrets
from flask import Flask, make_response, redirect, render_template, request
from auth import (
clear_session_cookie,
create_jwt,
get_current_user,
set_session_cookie,
)
from config import Config
from saml import (
SignatureVerificationError,
build_metadata,
build_saml_response,
generate_self_signed_cert,
parse_authn_request,
verify_post_signature,
verify_redirect_signature,
)
from scim import scim_bp
from sp_registry import get_sp
from user_store import UserStore
app = Flask(__name__)
app.register_blueprint(scim_bp)
store = UserStore()
def render_saml_response(user: dict, authn_req: dict, relay_state: str) -> str:
"""Build the signed SAML Response and wrap it in an auto-POST form."""
saml_response = build_saml_response(
user=user,
request_id=authn_req["id"],
acs_url=authn_req["acs_url"],
sp_entity_id=authn_req["issuer"],
)
return render_template(
"auto_post.html",
acs_url=authn_req["acs_url"],
saml_response=saml_response,
relay_state=relay_state,
)
@app.route("/sso", methods=["GET", "POST"])
def sso():
"""SSO endpoint — accepts SAMLRequest via GET (Redirect) or POST binding."""
if request.method == "GET":
saml_request = request.args.get("SAMLRequest", "")
relay_state = request.args.get("RelayState", "")
binding = "redirect"
else:
saml_request = request.form.get("SAMLRequest", "")
relay_state = request.form.get("RelayState", "")
binding = "post"
if not saml_request:
return "Missing SAMLRequest parameter", 400
authn_req = parse_authn_request(saml_request, binding)
# AuthnRequest signature verification (optional per config).
# If required, lookup the SP in the registry and verify per-binding.
if Config.REQUIRE_SIGNED_AUTHN_REQUESTS:
try:
_verify_authn_request_signature(authn_req, binding)
except SignatureVerificationError as e:
return f"AuthnRequest rejected: {e}", 401
user = get_current_user()
if user:
return render_saml_response(user, authn_req, relay_state)
return render_template(
"login.html", saml_request=saml_request, relay_state=relay_state
)
def _verify_authn_request_signature(authn_req: dict, binding: str) -> None:
"""Verify a signed AuthnRequest. Raises SignatureVerificationError on failure.
Trust boundary: the SP must be pre-registered in sp_registry.json. An
unregistered issuer is rejected outright — the IdP has no way to know
whose cert to trust otherwise.
"""
issuer = authn_req.get("issuer", "")
sp = get_sp(issuer)
if sp is None:
raise SignatureVerificationError(
f"issuer {issuer!r} not registered in sp_registry.json"
)
if binding == "redirect":
# Redirect binding: signature is over the raw query string octets.
# request.query_string is bytes; decode as ASCII (URL-encoded text).
verify_redirect_signature(
request.query_string.decode("ascii"),
sp.cert_bytes(),
)
else:
# POST binding: signature is enveloped in the XML itself. We need
# the raw XML bytes — re-decode from the form SAMLRequest field.
import base64
raw = request.form.get("SAMLRequest", "")
padded = raw + "=" * (-len(raw) % 4)
xml_bytes = base64.b64decode(padded)
verify_post_signature(xml_bytes, sp.cert_bytes())
@app.route("/sso/login", methods=["POST"])
def sso_login():
"""Handle login form submission."""
email = request.form.get("email", "")
password = request.form.get("password", "")
saml_request = request.form.get("SAMLRequest", "")
relay_state = request.form.get("RelayState", "")
if not saml_request:
return "Missing SAMLRequest", 400
user = store.validate_credentials(email, password)
if user is None:
return (
render_template(
"login.html",
saml_request=saml_request,
relay_state=relay_state,
error="Invalid credentials or account disabled",
),
401,
)
user_dict = user.to_dict()
# The SAMLRequest was originally sent via either binding; retry on failure.
try:
authn_req = parse_authn_request(saml_request, binding="redirect")
except Exception:
authn_req = parse_authn_request(saml_request, binding="post")
token = create_jwt(user_dict)
html = render_saml_response(user_dict, authn_req, relay_state)
resp = make_response(html)
set_session_cookie(resp, token)
return resp
@app.route("/metadata", methods=["GET"])
def metadata():
"""Return IdP SAML metadata."""
xml = build_metadata()
resp = make_response(xml)
resp.headers["Content-Type"] = "application/xml"
return resp
@app.route("/slo", methods=["GET", "POST"])
def slo():
"""Single Logout endpoint."""
resp = make_response(render_template("logged_out.html"))
clear_session_cookie(resp)
return resp
@app.route("/logout", methods=["GET"])
def logout():
"""Local logout — clear cookie and redirect to confirmation."""
resp = make_response(redirect("/slo"))
clear_session_cookie(resp)
return resp
if __name__ == "__main__":
generate_self_signed_cert()
if not Config.JWT_SECRET:
Config.JWT_SECRET = secrets.token_hex(32)
print(
"WARNING: JWT_SECRET not set — generated random secret. "
"Sessions will be invalidated on every restart. "
"Set JWT_SECRET in .env for stable sessions."
)
if not Config.SCIM_BEARER_TOKEN:
print(
"WARNING: SCIM_BEARER_TOKEN not set — SCIM endpoints are "
"UNAUTHENTICATED. Anyone who can reach /scim/v2 can delete "
"or deactivate users. Do not expose this service to untrusted "
"networks without setting SCIM_BEARER_TOKEN."
)
Config.validate()
print(f"IdP Entity ID: {Config.IDP_ENTITY_ID}")
print(f"SSO URL: {Config.IDP_SSO_URL}")
print(f"Metadata: {Config.IDP_ENTITY_ID}")
port = int(os.environ.get("PORT", "5001"))
app.run(host="0.0.0.0", port=port, debug=Config.FLASK_DEBUG)