Skip to content
This repository was archived by the owner on Jun 23, 2023. It is now read-only.

Commit a98696e

Browse files
authored
Merge branch 'develop' into pkce-per-client
2 parents 230d04e + ce04493 commit a98696e

21 files changed

Lines changed: 404 additions & 83 deletions

docs/source/contents/conf.rst

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,3 +643,72 @@ the following::
643643
}
644644
}
645645
}
646+
647+
648+
=======
649+
Clients
650+
=======
651+
652+
In this section there are some client configuration examples.
653+
654+
A common configuration::
655+
656+
endpoint_context.cdb['jbxedfmfyc'] = {
657+
client_id: 'jbxedfmfyc',
658+
client_salt: '6flfsj0Z',
659+
registration_access_token: 'z3PCMmC1HZ1QmXeXGOQMJpWQNQynM4xY',
660+
registration_client_uri: 'https://127.0.0.1:8000/registration_api?client_id=jbxedfmfyc',
661+
client_id_issued_at: 1630256902,
662+
client_secret: '19cc69b70d0108f630e52f72f7a3bd37ba4e11678ad1a7434e9818e1',
663+
client_secret_expires_at: 1929727754,
664+
application_type: 'web',
665+
contacts: [
666+
'rp@example.com'
667+
],
668+
token_endpoint_auth_method: 'client_secret_basic',
669+
redirect_uris: [
670+
[
671+
'https://127.0.0.1:8090/authz_cb/satosa',
672+
{}
673+
]
674+
],
675+
post_logout_redirect_uris: [
676+
[
677+
'https://127.0.0.1:8090/session_logout/satosa',
678+
null
679+
]
680+
],
681+
response_types: [
682+
'code'
683+
],
684+
grant_types: [
685+
'authorization_code'
686+
],
687+
allowed_scopes: [
688+
'openid',
689+
'profile',
690+
'email',
691+
'offline_access'
692+
]
693+
}
694+
695+
696+
How to configure the release of the user claims per clients::
697+
698+
endpoint_context.cdb["client_1"] = {
699+
"client_secret": "hemligt",
700+
"redirect_uris": [("https://example.com/cb", None)],
701+
"client_salt": "salted",
702+
"token_endpoint_auth_method": "client_secret_post",
703+
"response_types": ["code", "token", "code id_token", "id_token"],
704+
"add_claims": {
705+
"always": {
706+
"introspection": ["nickname", "eduperson_scoped_affiliation"],
707+
"userinfo": ["picture", "phone_number"],
708+
},
709+
# this overload the general endpoint configuration for this client
710+
# self.server.server_get("endpoint", "id_token").kwargs = {"add_claims_by_scope": True}
711+
"by_scope": {
712+
"id_token": False,
713+
},
714+
},

docs/source/contents/usage.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Usage
22
-----
33

4-
Some examples, how to run flask_op and django_op, but also some typical configuration in relation to common use cases.
4+
Some examples, how to run [flask_op](https://github.com/IdentityPython/oidc-op/tree/master/example/flask_op) and [django_op](https://github.com/peppelinux/django-oidc-op) but also some typical configuration in relation to common use cases.
55

66

77

@@ -34,7 +34,7 @@ Get to the RP landing page to choose your authentication endpoint. The first opt
3434

3535
![OP Auth](../_images/2.png)
3636

37-
AS/OP accepted our authentication request and prompt to us the login form. Read passwd.json file to get credentials.
37+
The AS/OP supports dynamic client registration, it accepts the authentication request and prompt to us the login form. Read [passwd.json](https://github.com/IdentityPython/oidc-op/blob/master/example/flask_op/passwd.json) file to get credentials.
3838

3939
----------------------------------
4040

@@ -75,12 +75,12 @@ It is important to consider that only scope=offline_access will get a usable ref
7575

7676
oidc-op will return a json response like this::
7777

78-
{
79-
'access_token': 'eyJhbGc ... CIOH_09tT_YVa_gyTqg',
80-
'token_type': 'Bearer',
81-
'scope': 'openid profile email address phone offline_access',
82-
'refresh_token': 'Z0FBQ ... 1TE16cm1Tdg=='
83-
}
78+
{
79+
'access_token': 'eyJhbGc ... CIOH_09tT_YVa_gyTqg',
80+
'token_type': 'Bearer',
81+
'scope': 'openid profile email address phone offline_access',
82+
'refresh_token': 'Z0FBQ ... 1TE16cm1Tdg=='
83+
}
8484

8585

8686

src/oidcop/client_authn.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from oidcop.exception import InvalidClient
2323
from oidcop.exception import MultipleUsage
2424
from oidcop.exception import NotForMe
25+
from oidcop.exception import ToOld
2526
from oidcop.exception import UnknownClient
2627
from oidcop.util import importer
2728

@@ -409,6 +410,8 @@ def verify_client(
409410
try:
410411
# get_client_id_from_token is a callback... Do not abuse for code readability.
411412
auth_info["client_id"] = get_client_id_from_token(endpoint_context, _token, request)
413+
except ToOld:
414+
raise ValueError("Expired token")
412415
except KeyError:
413416
raise ValueError("Unknown token")
414417

src/oidcop/oauth2/token.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ def process_request(self, req: Union[Message, dict], **kwargs):
253253
_resp = {
254254
"access_token": access_token.value,
255255
"token_type": access_token.token_type,
256-
"scope": _grant.scope,
256+
"scope": scope,
257257
}
258258

259259
if access_token.expires_at:
@@ -318,7 +318,7 @@ def post_parse_request(
318318
if "scope" in request:
319319
req_scopes = set(request["scope"])
320320
scopes = set(grant.find_scope(token.based_on))
321-
if scopes < req_scopes:
321+
if not req_scopes.issubset(scopes):
322322
return self.error_cls(
323323
error="invalid_request",
324324
error_description="Invalid refresh scopes",

src/oidcop/oidc/token.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ def process_request(self, req: Union[Message, dict], **kwargs):
218218
_resp = {
219219
"access_token": access_token.value,
220220
"token_type": token_type,
221-
"scope": _grant.scope,
221+
"scope": scope,
222222
}
223223

224224
if access_token.expires_at:
@@ -246,7 +246,7 @@ def process_request(self, req: Union[Message, dict], **kwargs):
246246
if "id_token" in _mints and "openid" in scope:
247247
try:
248248
_idtoken = self._mint_token(
249-
token_class="refresh_token",
249+
token_class="id_token",
250250
grant=_grant,
251251
session_id=_session_info["session_id"],
252252
client_id=_session_info["client_id"],
@@ -307,7 +307,7 @@ def post_parse_request(
307307
if "scope" in request:
308308
req_scopes = set(request["scope"])
309309
scopes = set(grant.find_scope(token.based_on))
310-
if scopes < req_scopes:
310+
if not req_scopes.issubset(scopes):
311311
return self.error_cls(
312312
error="invalid_request",
313313
error_description="Invalid refresh scopes",

src/oidcop/session/claims.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from oidcmsg.oidc import OpenIDSchema
66

77
from oidcop.exception import ServiceError
8+
from oidcop.exception import ImproperlyConfigured
89
from oidcop.scopes import convert_scopes2claims
910

1011
logger = logging.getLogger(__name__)
@@ -41,7 +42,11 @@ def authorization_request_claims(self,
4142

4243
def _get_client_claims(self, client_id, usage):
4344
client_info = self.server_get("endpoint_context").cdb.get(client_id, {})
44-
client_claims = client_info.get("{}_claims".format(usage), {})
45+
client_claims = (
46+
client_info.get("add_claims", {})
47+
.get("always", {})
48+
.get(usage, {})
49+
)
4550
if isinstance(client_claims, list):
4651
client_claims = {k: None for k in client_claims}
4752
return client_claims
@@ -94,8 +99,19 @@ def get_claims(self, session_id: str, scopes: str, claims_release_point: str) ->
9499

95100
claims.update(base_claims)
96101

97-
# Scopes can in some cases equate to set of claims, is that used here ?
98-
if module.kwargs.get("add_claims_by_scope"):
102+
# If specific client configuration exists overwrite add_claims_by_scope
103+
if client_id in _context.cdb:
104+
add_claims_by_scope = (
105+
_context.cdb[client_id].get("add_claims", {})
106+
.get("by_scope", {})
107+
.get(claims_release_point, {})
108+
)
109+
if isinstance(add_claims_by_scope, dict) and not add_claims_by_scope:
110+
add_claims_by_scope = module.kwargs.get("add_claims_by_scope")
111+
else:
112+
add_claims_by_scope = module.kwargs.get("add_claims_by_scope")
113+
114+
if add_claims_by_scope:
99115
if scopes:
100116
_scopes = _context.scopes_handler.filter_scopes(client_id, _context, scopes)
101117

@@ -127,9 +143,14 @@ def get_user_claims(self, user_id: str, claims_restriction: dict) -> dict:
127143
:param claims_restriction: Specifies the upper limit of which claims can be returned
128144
:return:
129145
"""
146+
meth = self.server_get("endpoint_context").userinfo
147+
if not meth:
148+
raise ImproperlyConfigured(
149+
"userinfo MUST be defined in the configuration"
150+
)
130151
if claims_restriction:
131152
# Get all possible claims
132-
user_info = self.server_get("endpoint_context").userinfo(user_id, client_id=None)
153+
user_info = meth(user_id, client_id=None)
133154
# Filter out the claims that can be returned
134155
return {
135156
k: user_info.get(k)

src/oidcop/token/id_token.py

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212
from oidcop.session.claims import claims_match
1313
from oidcop.token import is_expired
1414
from oidcop.token.exception import InvalidToken
15+
16+
from ..util import get_logout_id
1517
from . import Token
1618
from . import UnknownToken
17-
from ..util import get_logout_id
1819

1920
logger = logging.getLogger(__name__)
2021

@@ -131,7 +132,13 @@ def __init__(
131132
self.provider_info = construct_endpoint_info(self.default_capabilities, **kwargs)
132133

133134
def payload(
134-
self, session_id, alg="RS256", code=None, access_token=None, extra_claims=None,
135+
self,
136+
session_id,
137+
alg="RS256",
138+
code=None,
139+
access_token=None,
140+
extra_claims=None,
141+
user_info=None,
135142
):
136143
"""
137144
Collect payload for the ID Token.
@@ -155,16 +162,18 @@ def payload(
155162
if _val:
156163
_args[attr] = _val
157164

158-
_claims_restriction = grant.claims.get("id_token")
159-
if _claims_restriction == {}:
160-
user_info = None
161-
else:
162-
user_info = _context.claims_interface.get_user_claims(
163-
user_id=session_information["user_id"], claims_restriction=_claims_restriction,
164-
)
165-
if _claims_restriction and "acr" in _claims_restriction and "acr" in _args:
166-
if claims_match(_args["acr"], _claims_restriction["acr"]) is False:
167-
raise ValueError("Could not match expected 'acr'")
165+
if not user_info:
166+
_claims_restriction = grant.claims.get("id_token")
167+
if _claims_restriction == {}:
168+
user_info = None
169+
else:
170+
user_info = _context.claims_interface.get_user_claims(
171+
user_id=session_information["user_id"],
172+
claims_restriction=_claims_restriction,
173+
)
174+
if _claims_restriction and "acr" in _claims_restriction and "acr" in _args:
175+
if claims_match(_args["acr"], _claims_restriction["acr"]) is False:
176+
raise ValueError("Could not match expected 'acr'")
168177

169178
if user_info:
170179
try:
@@ -203,15 +212,16 @@ def payload(
203212
return _args
204213

205214
def sign_encrypt(
206-
self,
207-
session_id,
208-
client_id,
209-
code=None,
210-
access_token=None,
211-
sign=True,
212-
encrypt=False,
213-
lifetime=None,
214-
extra_claims=None,
215+
self,
216+
session_id,
217+
client_id,
218+
code=None,
219+
access_token=None,
220+
sign=True,
221+
encrypt=False,
222+
lifetime=None,
223+
extra_claims=None,
224+
user_info=None,
215225
) -> str:
216226
"""
217227
Signed and or encrypt a IDToken
@@ -240,6 +250,7 @@ def sign_encrypt(
240250
code=code,
241251
access_token=access_token,
242252
extra_claims=extra_claims,
253+
user_info=user_info,
243254
)
244255

245256
if lifetime is None:
@@ -249,7 +260,15 @@ def sign_encrypt(
249260

250261
return _jwt.pack(_payload, recv=client_id)
251262

252-
def __call__(self, session_id: Optional[str] = "", ttype: Optional[str] = "", **kwargs) -> str:
263+
def __call__(
264+
self,
265+
session_id: Optional[str] = "",
266+
ttype: Optional[str] = "",
267+
encrypt=False,
268+
code=None,
269+
access_token=None,
270+
**kwargs,
271+
) -> str:
253272
_context = self.server_get("endpoint_context")
254273

255274
user_id, client_id, grant_id = _context.session_manager.decrypt_session_id(session_id)
@@ -265,11 +284,16 @@ def __call__(self, session_id: Optional[str] = "", ttype: Optional[str] = "", **
265284

266285
lifetime = self.lifetime
267286

268-
# Weed out stuff that doesn't belong here
269-
kwargs = {k: v for k, v in kwargs.items() if k in ["encrypt", "code", "access_token"]}
270-
271287
id_token = self.sign_encrypt(
272-
session_id, client_id, sign=True, lifetime=lifetime, extra_claims=xargs, **kwargs
288+
session_id,
289+
client_id,
290+
sign=True,
291+
lifetime=lifetime,
292+
extra_claims=xargs,
293+
encrypt=encrypt,
294+
code=code,
295+
access_token=access_token,
296+
user_info=kwargs,
273297
)
274298

275299
return id_token

tests/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import os
2+
3+
BASEDIR = os.path.abspath(os.path.dirname(__file__))
4+
5+
6+
def full_path(local_file):
7+
return os.path.join(BASEDIR, local_file)

0 commit comments

Comments
 (0)