Skip to content

Commit bece3e2

Browse files
Ali Haiderc00kiemon5ter
Ali Haider
authored andcommitted
Support stateless code flow
Signed-off-by: Ivan Kanakarakis <ivan.kanak@gmail.com>
1 parent b14d52c commit bece3e2

File tree

3 files changed

+220
-9
lines changed

3 files changed

+220
-9
lines changed

example/plugins/frontends/openid_connect_frontend.yaml.example

+28-2
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,35 @@ name: OIDC
33
config:
44
signing_key_path: frontend.key
55
signing_key_id: frontend.key1
6-
db_uri: mongodb://db.example.com # optional: only support MongoDB, will default to in-memory storage if not specified
6+
7+
# Defines the database connection URI for the databases:
8+
# - authz_code_db
9+
# - access_token_db
10+
# - refresh_token_db
11+
# - sub_db
12+
# - user_db
13+
#
14+
# supported storage backends:
15+
# - In-memory dictionary
16+
# - MongoDB (e.g. mongodb://db.example.com)
17+
# - Redis (e.g. redis://example/0)
18+
# - Stateless (eg. stateless://user:encryptionkey?alg=aes256)
19+
#
20+
# This configuration is optional.
21+
# By default, the in-memory storage is used.
22+
db_uri: mongodb://db.example.com
23+
24+
# Where to store clients.
25+
#
26+
# If client_db_uri is set, the database connection is used.
27+
# Otherwise, if client_db_path is set, the JSON file is used.
28+
# By default, an in-memory dictionary is used.
29+
client_db_uri: mongodb://db.example.com
730
client_db_path: /path/to/your/cdb.json
8-
sub_hash_salt: randomSALTvalue # if not specified, it is randomly generated on every startup
31+
32+
# if not specified, it is randomly generated on every startup
33+
sub_hash_salt: randomSALTvalue
34+
935
provider:
1036
client_registration_supported: Yes
1137
response_types_supported: ["code", "id_token token"]

src/satosa/frontends/openid_connect.py

+21-7
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
11
"""
22
A OpenID Connect frontend module for the satosa proxy
33
"""
4+
45
import json
56
import logging
67
from collections import defaultdict
78
from urllib.parse import urlencode, urlparse
89

910
from jwkest.jwk import rsa_load, RSAKey
11+
1012
from oic.oic import scope2claims
11-
from oic.oic.message import (AuthorizationRequest, AuthorizationErrorResponse, TokenErrorResponse,
12-
UserInfoErrorResponse)
13-
from oic.oic.provider import RegistrationEndpoint, AuthorizationEndpoint, TokenEndpoint, UserinfoEndpoint
13+
from oic.oic.message import AuthorizationRequest
14+
from oic.oic.message import AuthorizationErrorResponse
15+
from oic.oic.message import TokenErrorResponse
16+
from oic.oic.message import UserInfoErrorResponse
17+
from oic.oic.provider import RegistrationEndpoint
18+
from oic.oic.provider import AuthorizationEndpoint
19+
from oic.oic.provider import TokenEndpoint
20+
from oic.oic.provider import UserinfoEndpoint
21+
1422
from pyop.access_token import AccessToken
1523
from pyop.authz_state import AuthorizationState
16-
from pyop.exceptions import (InvalidAuthenticationRequest, InvalidClientRegistrationRequest,
17-
InvalidClientAuthentication, OAuthError, BearerTokenError, InvalidAccessToken)
24+
from pyop.exceptions import InvalidAuthenticationRequest
25+
from pyop.exceptions import InvalidClientRegistrationRequest
26+
from pyop.exceptions import InvalidClientAuthentication
27+
from pyop.exceptions import OAuthError
28+
from pyop.exceptions import BearerTokenError
29+
from pyop.exceptions import InvalidAccessToken
1830
from pyop.provider import Provider
1931
from pyop.storage import StorageBase
2032
from pyop.subject_identifier import HashBasedSubjectIdentifierFactory
@@ -57,7 +69,7 @@ def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url,
5769
db_uri = self.config.get("db_uri")
5870
self.user_db = (
5971
StorageBase.from_uri(db_uri, db_name="satosa", collection="authz_codes")
60-
if db_uri
72+
if db_uri and not StorageBase.type(db_uri) == "stateless"
6173
else {}
6274
)
6375

@@ -108,7 +120,9 @@ def handle_authn_response(self, context, internal_resp):
108120
claims = self.converter.from_internal("openid", internal_resp.attributes)
109121
# Filter unset claims
110122
claims = {k: v for k, v in claims.items() if v}
111-
self.user_db[internal_resp.subject_id] = dict(combine_claim_values(claims.items()))
123+
self.user_db[internal_resp.subject_id] = dict(
124+
combine_claim_values(claims.items())
125+
)
112126
auth_resp = self.provider.authorize(
113127
auth_req,
114128
internal_resp.subject_id,

tests/flows/test_oidc-saml.py

+171
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import os
12
import json
3+
import base64
24
from urllib.parse import urlparse, urlencode, parse_qsl
35

46
import pytest
@@ -20,8 +22,27 @@
2022

2123

2224
CLIENT_ID = "client1"
25+
CLIENT_SECRET = "secret"
26+
CLIENT_REDIRECT_URI = "https://client.example.com/cb"
2327
REDIRECT_URI = "https://client.example.com/cb"
2428

29+
@pytest.fixture(scope="session")
30+
def client_db_path(tmpdir_factory):
31+
tmpdir = str(tmpdir_factory.getbasetemp())
32+
path = os.path.join(tmpdir, "cdb.json")
33+
cdb_json = {
34+
CLIENT_ID: {
35+
"response_types": ["id_token", "code"],
36+
"redirect_uris": [
37+
CLIENT_REDIRECT_URI
38+
],
39+
"client_secret": CLIENT_SECRET
40+
}
41+
}
42+
with open(path, "w") as f:
43+
f.write(json.dumps(cdb_json))
44+
45+
return path
2546

2647
@pytest.fixture
2748
def oidc_frontend_config(signing_key_path, mongodb_instance):
@@ -47,6 +68,25 @@ def oidc_frontend_config(signing_key_path, mongodb_instance):
4768
return data
4869

4970

71+
@pytest.fixture
72+
def oidc_stateless_frontend_config(signing_key_path, client_db_path):
73+
data = {
74+
"module": "satosa.frontends.openid_connect.OpenIDConnectFrontend",
75+
"name": "OIDCFrontend",
76+
"config": {
77+
"issuer": "https://proxy-op.example.com",
78+
"signing_key_path": signing_key_path,
79+
"client_db_path": client_db_path,
80+
"db_uri": "stateless://user:abc123@localhost",
81+
"provider": {
82+
"response_types_supported": ["id_token", "code"]
83+
}
84+
}
85+
}
86+
87+
return data
88+
89+
5090
class TestOIDCToSAML:
5191
def test_full_flow(self, satosa_config_dict, oidc_frontend_config, saml_backend_config, idp_conf):
5292
subject_id = "testuser1"
@@ -105,3 +145,134 @@ def test_full_flow(self, satosa_config_dict, oidc_frontend_config, saml_backend_
105145
(name, values) in id_token_claims.items()
106146
for name, values in OIDC_USERS[subject_id].items()
107147
)
148+
149+
def test_full_stateless_id_token_flow(self, satosa_config_dict, oidc_stateless_frontend_config, saml_backend_config, idp_conf):
150+
subject_id = "testuser1"
151+
152+
# proxy config
153+
satosa_config_dict["FRONTEND_MODULES"] = [oidc_stateless_frontend_config]
154+
satosa_config_dict["BACKEND_MODULES"] = [saml_backend_config]
155+
satosa_config_dict["INTERNAL_ATTRIBUTES"]["attributes"] = {attr_name: {"openid": [attr_name],
156+
"saml": [attr_name]}
157+
for attr_name in USERS[subject_id]}
158+
_, backend_metadata = create_entity_descriptors(SATOSAConfig(satosa_config_dict))
159+
160+
# application
161+
test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), Response)
162+
163+
# get frontend OP config info
164+
provider_config = json.loads(test_client.get("/.well-known/openid-configuration").data.decode("utf-8"))
165+
166+
# create auth req
167+
claims_request = ClaimsRequest(id_token=Claims(**{k: None for k in USERS[subject_id]}))
168+
req_args = {"scope": "openid", "response_type": "id_token", "client_id": CLIENT_ID,
169+
"redirect_uri": REDIRECT_URI, "nonce": "nonce",
170+
"claims": claims_request.to_json()}
171+
auth_req = urlparse(provider_config["authorization_endpoint"]).path + "?" + urlencode(req_args)
172+
173+
# make auth req to proxy
174+
proxied_auth_req = test_client.get(auth_req)
175+
assert proxied_auth_req.status == "303 See Other"
176+
177+
# config test IdP
178+
backend_metadata_str = str(backend_metadata[saml_backend_config["name"]][0])
179+
idp_conf["metadata"]["inline"].append(backend_metadata_str)
180+
fakeidp = FakeIdP(USERS, config=IdPConfig().load(idp_conf))
181+
182+
# create auth resp
183+
req_params = dict(parse_qsl(urlparse(proxied_auth_req.data.decode("utf-8")).query))
184+
url, authn_resp = fakeidp.handle_auth_req(
185+
req_params["SAMLRequest"],
186+
req_params["RelayState"],
187+
BINDING_HTTP_REDIRECT,
188+
subject_id,
189+
response_binding=BINDING_HTTP_REDIRECT)
190+
191+
# make auth resp to proxy
192+
authn_resp_req = urlparse(url).path + "?" + urlencode(authn_resp)
193+
authn_resp = test_client.get(authn_resp_req)
194+
assert authn_resp.status == "303 See Other"
195+
196+
# verify auth resp from proxy
197+
resp_dict = dict(parse_qsl(urlparse(authn_resp.data.decode("utf-8")).fragment))
198+
signing_key = RSAKey(key=rsa_load(oidc_stateless_frontend_config["config"]["signing_key_path"]),
199+
use="sig", alg="RS256")
200+
id_token_claims = JWS().verify_compact(resp_dict["id_token"], keys=[signing_key])
201+
202+
assert all(
203+
(name, values) in id_token_claims.items()
204+
for name, values in OIDC_USERS[subject_id].items()
205+
)
206+
207+
def test_full_stateless_code_flow(self, satosa_config_dict, oidc_stateless_frontend_config, saml_backend_config, idp_conf):
208+
subject_id = "testuser1"
209+
210+
# proxy config
211+
satosa_config_dict["FRONTEND_MODULES"] = [oidc_stateless_frontend_config]
212+
satosa_config_dict["BACKEND_MODULES"] = [saml_backend_config]
213+
satosa_config_dict["INTERNAL_ATTRIBUTES"]["attributes"] = {attr_name: {"openid": [attr_name],
214+
"saml": [attr_name]}
215+
for attr_name in USERS[subject_id]}
216+
_, backend_metadata = create_entity_descriptors(SATOSAConfig(satosa_config_dict))
217+
218+
# application
219+
test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), Response)
220+
221+
# get frontend OP config info
222+
provider_config = json.loads(test_client.get("/.well-known/openid-configuration").data.decode("utf-8"))
223+
224+
# create auth req
225+
claims_request = ClaimsRequest(id_token=Claims(**{k: None for k in USERS[subject_id]}))
226+
req_args = {"scope": "openid", "response_type": "code", "client_id": CLIENT_ID,
227+
"redirect_uri": REDIRECT_URI, "nonce": "nonce",
228+
"claims": claims_request.to_json()}
229+
auth_req = urlparse(provider_config["authorization_endpoint"]).path + "?" + urlencode(req_args)
230+
231+
# make auth req to proxy
232+
proxied_auth_req = test_client.get(auth_req)
233+
assert proxied_auth_req.status == "303 See Other"
234+
235+
# config test IdP
236+
backend_metadata_str = str(backend_metadata[saml_backend_config["name"]][0])
237+
idp_conf["metadata"]["inline"].append(backend_metadata_str)
238+
fakeidp = FakeIdP(USERS, config=IdPConfig().load(idp_conf))
239+
240+
# create auth resp
241+
req_params = dict(parse_qsl(urlparse(proxied_auth_req.data.decode("utf-8")).query))
242+
url, authn_resp = fakeidp.handle_auth_req(
243+
req_params["SAMLRequest"],
244+
req_params["RelayState"],
245+
BINDING_HTTP_REDIRECT,
246+
subject_id,
247+
response_binding=BINDING_HTTP_REDIRECT)
248+
249+
# make auth resp to proxy
250+
authn_resp_req = urlparse(url).path + "?" + urlencode(authn_resp)
251+
authn_resp = test_client.get(authn_resp_req)
252+
assert authn_resp.status == "303 See Other"
253+
254+
resp_dict = dict(parse_qsl(urlparse(authn_resp.data.decode("utf-8")).query))
255+
code = resp_dict["code"]
256+
client_id_secret_str = CLIENT_ID + ":" + CLIENT_SECRET
257+
auth_header = "Basic %s" % base64.b64encode(client_id_secret_str.encode()).decode()
258+
259+
authn_resp = test_client.post(provider_config["token_endpoint"],
260+
data={
261+
"code": code,
262+
"grant_type": "authorization_code",
263+
"redirect_uri": CLIENT_REDIRECT_URI
264+
},
265+
headers={'Authorization': auth_header})
266+
267+
assert authn_resp.status == "200 OK"
268+
269+
# verify auth resp from proxy
270+
resp_dict = json.loads(authn_resp.data.decode("utf-8"))
271+
signing_key = RSAKey(key=rsa_load(oidc_stateless_frontend_config["config"]["signing_key_path"]),
272+
use="sig", alg="RS256")
273+
id_token_claims = JWS().verify_compact(resp_dict["id_token"], keys=[signing_key])
274+
275+
assert all(
276+
(name, values) in id_token_claims.items()
277+
for name, values in OIDC_USERS[subject_id].items()
278+
)

0 commit comments

Comments
 (0)