Skip to content

Commit d0f958e

Browse files
Merge pull request #439 from IdentityPython/idpy_oidc_backend
Add new OIDC backend based on idpy-oidc Install by: `pip install satosa[idpy_oidc_backend]`
2 parents 014e121 + 628ee94 commit d0f958e

File tree

4 files changed

+404
-0
lines changed

4 files changed

+404
-0
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
module: satosa.backends.idpy_oidc.IdpyOIDCBackend
2+
name: oidc
3+
config:
4+
client_type: oidc
5+
redirect_uris: [<base_url>/<name>]
6+
client_id: !ENV SATOSA_OIDC_BACKEND_CLIENTID
7+
client_secret: !ENV SATOSA_OIDC_BACKEND_CLIENTSECRET
8+
response_types_supported: ["code"]
9+
scopes_supported: ["openid", "profile", "email"]
10+
subject_type_supported: ["public"]
11+
provider_info:
12+
issuer: !ENV SATOSA_OIDC_BACKEND_ISSUER

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"ldap": ["ldap3"],
3232
"pyop_mongo": ["pyop[mongo]"],
3333
"pyop_redis": ["pyop[redis]"],
34+
"idpy_oidc_backend": ["idpyoidc >= 2.1.0"],
3435
},
3536
zip_safe=False,
3637
classifiers=[

src/satosa/backends/idpy_oidc.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""
2+
OIDC/OAuth2 backend module.
3+
"""
4+
import datetime
5+
import logging
6+
from urllib.parse import urlparse
7+
8+
from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient
9+
from idpyoidc.server.user_authn.authn_context import UNSPECIFIED
10+
11+
from satosa.backends.base import BackendModule
12+
from satosa.internal import AuthenticationInformation
13+
from satosa.internal import InternalData
14+
import satosa.logging_util as lu
15+
from ..exception import SATOSAAuthenticationError
16+
from ..exception import SATOSAError
17+
from ..response import Redirect
18+
19+
20+
UTC = datetime.timezone.utc
21+
logger = logging.getLogger(__name__)
22+
23+
24+
class IdpyOIDCBackend(BackendModule):
25+
"""
26+
Backend module for OIDC and OAuth 2.0, can be directly used.
27+
"""
28+
29+
def __init__(self, auth_callback_func, internal_attributes, config, base_url, name):
30+
"""
31+
OIDC backend module.
32+
:param auth_callback_func: Callback should be called by the module after the authorization
33+
in the backend is done.
34+
:param internal_attributes: Mapping dictionary between SATOSA internal attribute names and
35+
the names returned by underlying IdP's/OP's as well as what attributes the calling SP's and
36+
RP's expects namevice.
37+
:param config: Configuration parameters for the module.
38+
:param base_url: base url of the service
39+
:param name: name of the plugin
40+
41+
:type auth_callback_func:
42+
(satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
43+
:type internal_attributes: dict[string, dict[str, str | list[str]]]
44+
:type config: dict[str, dict[str, str] | list[str]]
45+
:type base_url: str
46+
:type name: str
47+
"""
48+
super().__init__(auth_callback_func, internal_attributes, base_url, name)
49+
# self.auth_callback_func = auth_callback_func
50+
# self.config = config
51+
self.client = StandAloneClient(config=config["client"], client_type="oidc")
52+
self.client.do_provider_info()
53+
self.client.do_client_registration()
54+
55+
_redirect_uris = self.client.context.claims.get_usage('redirect_uris')
56+
if not _redirect_uris:
57+
raise SATOSAError("Missing path in redirect uri")
58+
self.redirect_path = urlparse(_redirect_uris[0]).path
59+
60+
def start_auth(self, context, internal_request):
61+
"""
62+
See super class method satosa.backends.base#start_auth
63+
64+
:type context: satosa.context.Context
65+
:type internal_request: satosa.internal.InternalData
66+
:rtype satosa.response.Redirect
67+
"""
68+
login_url = self.client.init_authorization()
69+
return Redirect(login_url)
70+
71+
def register_endpoints(self):
72+
"""
73+
Creates a list of all the endpoints this backend module needs to listen to. In this case
74+
it's the authentication response from the underlying OP that is redirected from the OP to
75+
the proxy.
76+
:rtype: Sequence[(str, Callable[[satosa.context.Context], satosa.response.Response]]
77+
:return: A list that can be used to map the request to SATOSA to this endpoint.
78+
"""
79+
url_map = []
80+
url_map.append((f"^{self.redirect_path.lstrip('/')}$", self.response_endpoint))
81+
return url_map
82+
83+
def response_endpoint(self, context, *args):
84+
"""
85+
Handles the authentication response from the OP.
86+
:type context: satosa.context.Context
87+
:type args: Any
88+
:rtype: satosa.response.Response
89+
90+
:param context: SATOSA context
91+
:param args: None
92+
:return:
93+
"""
94+
95+
_info = self.client.finalize(context.request)
96+
self._check_error_response(_info, context)
97+
userinfo = _info.get('userinfo')
98+
id_token = _info.get('id_token')
99+
100+
if not id_token and not userinfo:
101+
msg = "No id_token or userinfo, nothing to do.."
102+
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
103+
logger.error(logline)
104+
raise SATOSAAuthenticationError(context.state, "No user info available.")
105+
106+
all_user_claims = dict(list(userinfo.items()) + list(id_token.items()))
107+
msg = "UserInfo: {}".format(all_user_claims)
108+
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
109+
logger.debug(logline)
110+
internal_resp = self._translate_response(all_user_claims, _info["issuer"])
111+
return self.auth_callback_func(context, internal_resp)
112+
113+
def _translate_response(self, response, issuer):
114+
"""
115+
Translates oidc response to SATOSA internal response.
116+
:type response: dict[str, str]
117+
:type issuer: str
118+
:type subject_type: str
119+
:rtype: InternalData
120+
121+
:param response: Dictioary with attribute name as key.
122+
:param issuer: The oidc op that gave the repsonse.
123+
:param subject_type: public or pairwise according to oidc standard.
124+
:return: A SATOSA internal response.
125+
"""
126+
timestamp_epoch = (
127+
response.get("auth_time")
128+
or response.get("iat")
129+
or int(datetime.datetime.now(UTC).timestamp())
130+
)
131+
timestamp_dt = datetime.datetime.fromtimestamp(timestamp_epoch, UTC)
132+
timestamp_iso = timestamp_dt.isoformat().replace("+00:00", "Z")
133+
auth_class_ref = response.get("acr") or response.get("amr") or UNSPECIFIED
134+
auth_info = AuthenticationInformation(auth_class_ref, timestamp_iso, issuer)
135+
136+
internal_resp = InternalData(auth_info=auth_info)
137+
internal_resp.attributes = self.converter.to_internal("openid", response)
138+
internal_resp.subject_id = response["sub"]
139+
return internal_resp
140+
141+
def _check_error_response(self, response, context):
142+
"""
143+
Check if the response is an error response.
144+
:param response: the response from finalize()
145+
:type response: oic.oic.message
146+
:raise SATOSAAuthenticationError: if the response is an OAuth error response
147+
"""
148+
if "error" in response:
149+
msg = "{name} error: {error} {description}".format(
150+
name=type(response).__name__,
151+
error=response["error"],
152+
description=response.get("error_description", ""),
153+
)
154+
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
155+
logger.debug(logline)
156+
raise SATOSAAuthenticationError(context.state, "Access denied")

0 commit comments

Comments
 (0)