From 0853a298a8d75fad14d9ca864f35ae9e74c74b94 Mon Sep 17 00:00:00 2001 From: Roland Hedberg Date: Mon, 12 Apr 2021 08:40:24 +0200 Subject: [PATCH 001/143] oidcendpoint in oidcop. With new sesssion management and persistent storage handling. --- chpy/private/jwks.json | 32 - chpy/static/jwks.json | 1 - chpy/templates/user_pass.jinja2 | 39 - {chpy => example/chpy}/certs/cert.pem | 0 {chpy => example/chpy}/certs/key.pem | 0 {chpy => example/chpy}/conf.py | 24 +- {chpy => example/chpy}/passwd.json | 0 {chpy => example/chpy}/provider.py | 0 {chpy => example/chpy}/seed.txt | 0 {chpy => example/chpy}/server.py | 0 {chpy => example/chpy}/users.json | 0 {django_op => example/django_op}/.gitignore | 0 {django_op => example/django_op}/AUTHORS | 0 {django_op => example/django_op}/LICENSE | 0 {django_op => example/django_op}/README.md | 0 .../example/data/oidc_op/certs/cert.pem | 0 .../example/data/oidc_op/certs/key.pem | 0 .../example/data/oidc_rp/conf.django.yaml | 0 .../django_op}/example/example/__init__.py | 0 .../example/example/oidc_op.conf.yaml | 0 .../django_op}/example/example/settings.py | 0 .../django_op}/example/example/urls.py | 0 .../django_op}/example/example/wsgi.py | 0 .../django_op}/example/manage.py | 0 .../django_op}/example/oidc_op | 0 .../django_op}/example/requirements.txt | 0 .../example/unical_accounts/__init__.py | 0 .../example/unical_accounts/admin.py | 0 .../example/unical_accounts/admin_inlines.py | 0 .../example/unical_accounts/apps.py | 0 .../example/unical_accounts/forms.py | 0 .../migrations/0001_initial.py | 0 .../unical_accounts/migrations/__init__.py | 0 .../example/unical_accounts/models.py | 0 .../unical_accounts/templatetags/__init__.py | 0 .../unical_accounts/templatetags/has_group.py | 0 .../example/unical_accounts/tests.py | 0 .../example/unical_accounts/urls.py | 0 .../example/unical_accounts/views.py | 0 .../django_op}/oidc_op/__init__.py | 0 .../django_op}/oidc_op/admin.py | 0 .../django_op}/oidc_op/application.py | 0 .../django_op}/oidc_op/apps.py | 0 .../django_op}/oidc_op/configure.py | 0 .../django_op}/oidc_op/migrations/__init__.py | 0 .../django_op}/oidc_op/models.py | 0 .../django_op}/oidc_op/tests.py | 0 .../django_op}/oidc_op/urls.py | 0 .../django_op}/oidc_op/users.py | 0 .../django_op}/oidc_op/views.py | 0 .../django_op}/requirements.txt | 0 {flask_op => example/flask_op}/Dockerfile | 0 {flask_op => example/flask_op}/README.md | 0 {flask_op => example/flask_op}/__init__.py | 0 {flask_op => example/flask_op}/application.py | 0 {flask_op => example/flask_op}/certs/cert.pem | 0 .../flask_op}/certs/client.crt | 0 .../flask_op}/certs/client.key | 0 {flask_op => example/flask_op}/certs/key.pem | 0 {flask_op => example/flask_op}/conf.py | 0 {flask_op => example/flask_op}/conf_192.yaml | 0 {flask_op => example/flask_op}/config.yaml | 6 +- .../flask_op}/config_persistent.yaml | 0 {flask_op => example/flask_op}/passwd.json | 0 .../flask_op}/requirements.txt | 0 {flask_op => example/flask_op}/seed.txt | 0 {flask_op => example/flask_op}/server.py | 0 {flask_op => example/flask_op}/users.json | 0 {flask_op => example/flask_op}/views.py | 10 +- flask_op/templates/check_session_iframe.html | 122 -- flask_op/templates/error.html | 12 - flask_op/templates/frontchannel_logout.html | 31 - flask_op/templates/index.html | 10 - flask_op/templates/logout.html | 87 -- flask_op/templates/post_logout.html | 10 - flask_op/templates/user_pass.jinja2 | 39 - setup.py | 7 +- src/oidcop/__init__.py | 36 +- src/oidcop/authn_event.py | 67 ++ src/oidcop/authz/__init__.py | 117 ++ src/oidcop/client_authn.py | 449 ++++++++ src/oidcop/configure.py | 12 +- src/oidcop/construct.py | 77 ++ src/oidcop/cookie.py | 598 ++++++++++ src/oidcop/endpoint.py | 416 +++++++ src/oidcop/endpoint_context.py | 343 ++++++ src/oidcop/exception.py | 99 ++ src/oidcop/id_token.py | 280 +++++ src/oidcop/login_hint.py | 30 + src/oidcop/oauth2/__init__.py | 0 src/oidcop/oauth2/add_on/__init__.py | 0 .../oauth2/add_on/device_authorization.py | 7 + src/oidcop/oauth2/add_on/dpop.py | 168 +++ src/oidcop/oauth2/add_on/dpop_token.py | 188 +++ src/oidcop/oauth2/authorization.py | 1013 +++++++++++++++++ src/oidcop/oauth2/device_authorization.py | 62 + src/oidcop/oauth2/introspection.py | 111 ++ src/oidcop/oauth2/pushed_authorization.py | 38 + src/oidcop/oidc/__init__.py | 0 src/oidcop/oidc/add_on/__init__.py | 2 + src/oidcop/oidc/add_on/custom_scopes.py | 28 + src/oidcop/oidc/add_on/pkce.py | 157 +++ src/oidcop/oidc/authorization.py | 145 +++ src/oidcop/oidc/discovery.py | 39 + src/oidcop/oidc/provider_config.py | 32 + src/oidcop/oidc/read_registration.py | 31 + src/oidcop/oidc/registration.py | 474 ++++++++ src/oidcop/oidc/session.py | 404 +++++++ src/oidcop/oidc/token.py | 441 +++++++ src/oidcop/oidc/userinfo.py | 181 +++ src/oidcop/scopes.py | 79 ++ src/oidcop/server.py | 151 +++ src/oidcop/session/__init__.py | 19 + src/oidcop/session/claims.py | 184 +++ src/oidcop/session/database.py | 151 +++ src/oidcop/session/grant.py | 367 ++++++ src/oidcop/session/info.py | 73 ++ src/oidcop/session/manager.py | 433 +++++++ src/oidcop/session/token.py | 159 +++ src/oidcop/template_handler.py | 16 + src/oidcop/token/__init__.py | 174 +++ src/oidcop/token/exception.py | 18 + src/oidcop/token/handler.py | 152 +++ src/oidcop/token/jwt_token.py | 118 ++ src/oidcop/user_authn/__init__.py | 1 + src/oidcop/user_authn/authn_context.py | 197 ++++ src/oidcop/user_authn/user.py | 334 ++++++ src/oidcop/user_info/__init__.py | 79 ++ src/oidcop/util.py | 213 ++++ tests/passwd.json | 5 + tests/test_00_server.py | 149 +++ tests/test_01_grant.py | 363 ++++++ tests/test_01_session_info.py | 64 ++ tests/test_01_session_token.py | 92 ++ tests/test_01_util.py | 58 + tests/test_02_client_authn.py | 574 ++++++++++ tests/test_02_sess_mngm_db.py | 258 +++++ tests/test_03_id_token.py | 421 +++++++ tests/test_04_token_handler.py | 226 ++++ tests/test_05_session_manager.py | 506 ++++++++ tests/test_06_authn_context.py | 217 ++++ tests/test_07_userinfo.py | 539 +++++++++ tests/test_08_session_life.py | 491 ++++++++ tests/test_09_cookie_dealer.py | 317 ++++++ tests/test_12_user_authn.py | 101 ++ tests/test_13_login_hint.py | 33 + tests/test_20_endpoint.py | 161 +++ tests/test_21_oidc_discovery_endpoint.py | 57 + .../test_22_oidc_provider_config_endpoint.py | 105 ++ tests/test_23_oidc_registration_endpoint.py | 290 +++++ .../test_24_oauth2_authorization_endpoint.py | 648 +++++++++++ ...st_24_oauth2_authorization_endpoint_jar.py | 233 ++++ tests/test_24_oidc_authorization_endpoint.py | 945 +++++++++++++++ tests/test_26_oidc_userinfo_endpoint.py | 364 ++++++ tests/test_27_jwt_token.py | 280 +++++ tests/test_30_oidc_end_session.py | 565 +++++++++ tests/test_31_introspection.py | 468 ++++++++ tests/test_32_read_registration.py | 138 +++ tests/test_33_pkce.py | 369 ++++++ tests/test_34_sso.py | 247 ++++ tests/test_35_oidc_token_endpoint.py | 484 ++++++++ tests/test_36_token_exchange.py | 257 +++++ tests/test_40_oauth2_pushed_authorization.py | 263 +++++ tests/test_49_session_persistence.py | 234 ++++ tests/test_50_persistence.py | 461 ++++++++ tests/users.json | 42 + 166 files changed, 20012 insertions(+), 406 deletions(-) delete mode 100644 chpy/private/jwks.json delete mode 100644 chpy/static/jwks.json delete mode 100644 chpy/templates/user_pass.jinja2 rename {chpy => example/chpy}/certs/cert.pem (100%) rename {chpy => example/chpy}/certs/key.pem (100%) rename {chpy => example/chpy}/conf.py (87%) rename {chpy => example/chpy}/passwd.json (100%) rename {chpy => example/chpy}/provider.py (100%) rename {chpy => example/chpy}/seed.txt (100%) rename {chpy => example/chpy}/server.py (100%) rename {chpy => example/chpy}/users.json (100%) rename {django_op => example/django_op}/.gitignore (100%) rename {django_op => example/django_op}/AUTHORS (100%) rename {django_op => example/django_op}/LICENSE (100%) rename {django_op => example/django_op}/README.md (100%) rename {django_op => example/django_op}/example/data/oidc_op/certs/cert.pem (100%) rename {django_op => example/django_op}/example/data/oidc_op/certs/key.pem (100%) rename {django_op => example/django_op}/example/data/oidc_rp/conf.django.yaml (100%) rename {django_op => example/django_op}/example/example/__init__.py (100%) rename {django_op => example/django_op}/example/example/oidc_op.conf.yaml (100%) rename {django_op => example/django_op}/example/example/settings.py (100%) rename {django_op => example/django_op}/example/example/urls.py (100%) rename {django_op => example/django_op}/example/example/wsgi.py (100%) rename {django_op => example/django_op}/example/manage.py (100%) rename {django_op => example/django_op}/example/oidc_op (100%) rename {django_op => example/django_op}/example/requirements.txt (100%) rename {django_op => example/django_op}/example/unical_accounts/__init__.py (100%) rename {django_op => example/django_op}/example/unical_accounts/admin.py (100%) rename {django_op => example/django_op}/example/unical_accounts/admin_inlines.py (100%) rename {django_op => example/django_op}/example/unical_accounts/apps.py (100%) rename {django_op => example/django_op}/example/unical_accounts/forms.py (100%) rename {django_op => example/django_op}/example/unical_accounts/migrations/0001_initial.py (100%) rename {django_op => example/django_op}/example/unical_accounts/migrations/__init__.py (100%) rename {django_op => example/django_op}/example/unical_accounts/models.py (100%) rename {django_op => example/django_op}/example/unical_accounts/templatetags/__init__.py (100%) rename {django_op => example/django_op}/example/unical_accounts/templatetags/has_group.py (100%) rename {django_op => example/django_op}/example/unical_accounts/tests.py (100%) rename {django_op => example/django_op}/example/unical_accounts/urls.py (100%) rename {django_op => example/django_op}/example/unical_accounts/views.py (100%) rename {django_op => example/django_op}/oidc_op/__init__.py (100%) rename {django_op => example/django_op}/oidc_op/admin.py (100%) rename {django_op => example/django_op}/oidc_op/application.py (100%) rename {django_op => example/django_op}/oidc_op/apps.py (100%) rename {django_op => example/django_op}/oidc_op/configure.py (100%) rename {django_op => example/django_op}/oidc_op/migrations/__init__.py (100%) rename {django_op => example/django_op}/oidc_op/models.py (100%) rename {django_op => example/django_op}/oidc_op/tests.py (100%) rename {django_op => example/django_op}/oidc_op/urls.py (100%) rename {django_op => example/django_op}/oidc_op/users.py (100%) rename {django_op => example/django_op}/oidc_op/views.py (100%) rename {django_op => example/django_op}/requirements.txt (100%) rename {flask_op => example/flask_op}/Dockerfile (100%) rename {flask_op => example/flask_op}/README.md (100%) rename {flask_op => example/flask_op}/__init__.py (100%) rename {flask_op => example/flask_op}/application.py (100%) rename {flask_op => example/flask_op}/certs/cert.pem (100%) rename {flask_op => example/flask_op}/certs/client.crt (100%) rename {flask_op => example/flask_op}/certs/client.key (100%) rename {flask_op => example/flask_op}/certs/key.pem (100%) rename {flask_op => example/flask_op}/conf.py (100%) rename {flask_op => example/flask_op}/conf_192.yaml (100%) rename {flask_op => example/flask_op}/config.yaml (98%) rename {flask_op => example/flask_op}/config_persistent.yaml (100%) rename {flask_op => example/flask_op}/passwd.json (100%) rename {flask_op => example/flask_op}/requirements.txt (100%) rename {flask_op => example/flask_op}/seed.txt (100%) rename {flask_op => example/flask_op}/server.py (100%) rename {flask_op => example/flask_op}/users.json (100%) rename {flask_op => example/flask_op}/views.py (98%) delete mode 100644 flask_op/templates/check_session_iframe.html delete mode 100644 flask_op/templates/error.html delete mode 100644 flask_op/templates/frontchannel_logout.html delete mode 100644 flask_op/templates/index.html delete mode 100644 flask_op/templates/logout.html delete mode 100644 flask_op/templates/post_logout.html delete mode 100644 flask_op/templates/user_pass.jinja2 create mode 100644 src/oidcop/authn_event.py create mode 100755 src/oidcop/authz/__init__.py create mode 100755 src/oidcop/client_authn.py create mode 100644 src/oidcop/construct.py create mode 100755 src/oidcop/cookie.py create mode 100755 src/oidcop/endpoint.py create mode 100755 src/oidcop/endpoint_context.py create mode 100755 src/oidcop/exception.py create mode 100755 src/oidcop/id_token.py create mode 100644 src/oidcop/login_hint.py create mode 100644 src/oidcop/oauth2/__init__.py create mode 100644 src/oidcop/oauth2/add_on/__init__.py create mode 100644 src/oidcop/oauth2/add_on/device_authorization.py create mode 100644 src/oidcop/oauth2/add_on/dpop.py create mode 100644 src/oidcop/oauth2/add_on/dpop_token.py create mode 100755 src/oidcop/oauth2/authorization.py create mode 100644 src/oidcop/oauth2/device_authorization.py create mode 100644 src/oidcop/oauth2/introspection.py create mode 100644 src/oidcop/oauth2/pushed_authorization.py create mode 100755 src/oidcop/oidc/__init__.py create mode 100644 src/oidcop/oidc/add_on/__init__.py create mode 100644 src/oidcop/oidc/add_on/custom_scopes.py create mode 100644 src/oidcop/oidc/add_on/pkce.py create mode 100755 src/oidcop/oidc/authorization.py create mode 100755 src/oidcop/oidc/discovery.py create mode 100755 src/oidcop/oidc/provider_config.py create mode 100644 src/oidcop/oidc/read_registration.py create mode 100755 src/oidcop/oidc/registration.py create mode 100644 src/oidcop/oidc/session.py create mode 100755 src/oidcop/oidc/token.py create mode 100755 src/oidcop/oidc/userinfo.py create mode 100644 src/oidcop/scopes.py create mode 100644 src/oidcop/server.py create mode 100644 src/oidcop/session/__init__.py create mode 100755 src/oidcop/session/claims.py create mode 100644 src/oidcop/session/database.py create mode 100644 src/oidcop/session/grant.py create mode 100644 src/oidcop/session/info.py create mode 100644 src/oidcop/session/manager.py create mode 100644 src/oidcop/session/token.py create mode 100644 src/oidcop/template_handler.py create mode 100755 src/oidcop/token/__init__.py create mode 100644 src/oidcop/token/exception.py create mode 100755 src/oidcop/token/handler.py create mode 100644 src/oidcop/token/jwt_token.py create mode 100755 src/oidcop/user_authn/__init__.py create mode 100755 src/oidcop/user_authn/authn_context.py create mode 100755 src/oidcop/user_authn/user.py create mode 100755 src/oidcop/user_info/__init__.py create mode 100755 src/oidcop/util.py create mode 100644 tests/passwd.json create mode 100755 tests/test_00_server.py create mode 100644 tests/test_01_grant.py create mode 100644 tests/test_01_session_info.py create mode 100644 tests/test_01_session_token.py create mode 100644 tests/test_01_util.py create mode 100755 tests/test_02_client_authn.py create mode 100644 tests/test_02_sess_mngm_db.py create mode 100644 tests/test_03_id_token.py create mode 100644 tests/test_04_token_handler.py create mode 100644 tests/test_05_session_manager.py create mode 100644 tests/test_06_authn_context.py create mode 100644 tests/test_07_userinfo.py create mode 100644 tests/test_08_session_life.py create mode 100644 tests/test_09_cookie_dealer.py create mode 100644 tests/test_12_user_authn.py create mode 100644 tests/test_13_login_hint.py create mode 100755 tests/test_20_endpoint.py create mode 100755 tests/test_21_oidc_discovery_endpoint.py create mode 100755 tests/test_22_oidc_provider_config_endpoint.py create mode 100755 tests/test_23_oidc_registration_endpoint.py create mode 100755 tests/test_24_oauth2_authorization_endpoint.py create mode 100755 tests/test_24_oauth2_authorization_endpoint_jar.py create mode 100755 tests/test_24_oidc_authorization_endpoint.py create mode 100755 tests/test_26_oidc_userinfo_endpoint.py create mode 100644 tests/test_27_jwt_token.py create mode 100644 tests/test_30_oidc_end_session.py create mode 100644 tests/test_31_introspection.py create mode 100644 tests/test_32_read_registration.py create mode 100644 tests/test_33_pkce.py create mode 100755 tests/test_34_sso.py create mode 100755 tests/test_35_oidc_token_endpoint.py create mode 100644 tests/test_36_token_exchange.py create mode 100644 tests/test_40_oauth2_pushed_authorization.py create mode 100644 tests/test_49_session_persistence.py create mode 100644 tests/test_50_persistence.py create mode 100755 tests/users.json diff --git a/chpy/private/jwks.json b/chpy/private/jwks.json deleted file mode 100644 index 4d90bca4..00000000 --- a/chpy/private/jwks.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "keys": [ - { - "kty": "RSA", - "use": "sig", - "kid": "S3ZNcGdDUnBPRGVjNXJISzg3N2RxMXdZVUJDekU2WTZ5NGRPdVJ4VlpEcw", - "n": "xIja_Ne7PPmDpvSYJWp_-GSG3PCmhwNO98QctjwsBVOmyEOcJ7gGj8kC9wsC7_l0VuIsnQLzDp8ZaoPoow5_dnOR9mi_zuuPa-6v3qaIed7kD5LSlXWkJf-SNWtFPqZPM1eIbRAkpX8oB097x_mFtt-Hag2IR0FPGFmwo03NPwVjci0PMOQbgAUt1iTfuXTbssNQcT8iHu-B_BhAgsfFBQjZvU06EuQx9XqDmm8W-MtVVazjQ9_jyHVgIoxo13h_aIgbnJGTeYWVQgVaQTWRiJGuoRtR4FVffDh7Ntax7iNl3YtmVwHaAA_I2S77G_FE_yUfEdQU0smhLfgSMIFs1w", - "e": "AQAB", - "d": "K5_BFAyCuCceTOnP98Yq_6S4tsQIAsH-PkreoKi8kLYfuCYhvENB2ZqhuNpN3Zx-cWakxnlIID-6cYU0FzV5n35JsOtRYAmzfR6vFPncI0kRUE-jxJ8nP3P6LTdRWHWr8NaGJsdFiyKtbOn8rMe7IXt-YFD-pepeNyZ5adY0L7-LivJ-7P3DDfF2976PySBk388YJEnWNSYkJDs1mr_5C54tyQO1y-d5x4EkuIuqcK-e_8vunm8HghgAEofH2FW5IDDlfje_y666oL4qqHoPDQPb02pzRzYm3PtC4KgFYavgHcD1XPfw9z9ywecKFc94-9LRs-JDjYpjz7QQLZfpWQ", - "p": "8zXgwSePXgV62ClQqE1twDkyIYI7vUWZk6F2PkrbipdaPqW6pkQDbfkWwQVNx6Wv6onmDcjt5Y5QQAycTlxoSaOsFzEw73iBBw-iNsvVuWwvMfLOJrxwm75ROruBooygukq0TUcigM3l2d5LA3Uzs9Mc_WqDjoByonEc_F5egls", - "q": "zt6fXsgDIwZrNIQ9BYlp2bUDn_Wu7ol4Hn73gNZ0Q_BKmy06JmzH_jNrn1tHO-zg5C44ysYu-Lv-H_0Fg4WcuRZ2KjNbv4UVIJ9gR9KPvKyRWokhG7HrqWmkK5gf5T0hbTrdqPISzndwuw8zyu_nWUHtZAD1w3GB0p4YssjcUDU" - }, - { - "kty": "EC", - "use": "sig", - "kid": "WnNVcjd0NEJzYWJXN3dDZ1JpclZkWHMyU3Q5eGs0SG9qZFlsbENPUjkydw", - "crv": "P-256", - "x": "XqBi5FEAx3485Kw28qgy5vWuFE4OPfmq2eht6iuuo-Q", - "y": "2MrrzBhkwOe4Q2Bzxmm_eyppE8Y7r2JFqQjAyK5C0w8", - "d": "5xOhuurIpPXbYdD81QqI5BobOXKJ9IK0fD31koQUtwc" - }, - { - "kty": "EC", - "use": "sig", - "kid": "RWVYM1R2Y0RQRl9aWHhGSDYtVUdIQlNVTmZLWmNJTXY2N3NaZWlkOVRxTQ", - "crv": "P-384", - "x": "n2qe-DM4-dtN6M_lZR5rmgczHUoUgtttxv6T-mgUsqNa-Sd4Zr_vWp7v2pC1FO5f", - "y": "kMHo2OVLF2TNXZRbJEjHfFFUiIaMyysXaC62PGd3Aw6Mkq1LdudLhVIJo00osn4h", - "d": "NOydgMRJ5ofyI5sqJiCKN8kTKAKEIkPrFRrYF1FPpJ-36VECttQJxAMUdPkNPjJm" - } - ] -} \ No newline at end of file diff --git a/chpy/static/jwks.json b/chpy/static/jwks.json deleted file mode 100644 index 978b14cc..00000000 --- a/chpy/static/jwks.json +++ /dev/null @@ -1 +0,0 @@ -{"keys": [{"kty": "RSA", "use": "sig", "kid": "S3ZNcGdDUnBPRGVjNXJISzg3N2RxMXdZVUJDekU2WTZ5NGRPdVJ4VlpEcw", "e": "AQAB", "n": "xIja_Ne7PPmDpvSYJWp_-GSG3PCmhwNO98QctjwsBVOmyEOcJ7gGj8kC9wsC7_l0VuIsnQLzDp8ZaoPoow5_dnOR9mi_zuuPa-6v3qaIed7kD5LSlXWkJf-SNWtFPqZPM1eIbRAkpX8oB097x_mFtt-Hag2IR0FPGFmwo03NPwVjci0PMOQbgAUt1iTfuXTbssNQcT8iHu-B_BhAgsfFBQjZvU06EuQx9XqDmm8W-MtVVazjQ9_jyHVgIoxo13h_aIgbnJGTeYWVQgVaQTWRiJGuoRtR4FVffDh7Ntax7iNl3YtmVwHaAA_I2S77G_FE_yUfEdQU0smhLfgSMIFs1w"}, {"kty": "EC", "use": "sig", "kid": "WnNVcjd0NEJzYWJXN3dDZ1JpclZkWHMyU3Q5eGs0SG9qZFlsbENPUjkydw", "crv": "P-256", "x": "XqBi5FEAx3485Kw28qgy5vWuFE4OPfmq2eht6iuuo-Q", "y": "2MrrzBhkwOe4Q2Bzxmm_eyppE8Y7r2JFqQjAyK5C0w8"}, {"kty": "EC", "use": "sig", "kid": "RWVYM1R2Y0RQRl9aWHhGSDYtVUdIQlNVTmZLWmNJTXY2N3NaZWlkOVRxTQ", "crv": "P-384", "x": "n2qe-DM4-dtN6M_lZR5rmgczHUoUgtttxv6T-mgUsqNa-Sd4Zr_vWp7v2pC1FO5f", "y": "kMHo2OVLF2TNXZRbJEjHfFFUiIaMyysXaC62PGd3Aw6Mkq1LdudLhVIJo00osn4h"}]} \ No newline at end of file diff --git a/chpy/templates/user_pass.jinja2 b/chpy/templates/user_pass.jinja2 deleted file mode 100644 index 9add475c..00000000 --- a/chpy/templates/user_pass.jinja2 +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - Please login - - - -

{{ page_header }}

- -
- - -

- - -

- -

- - -

- -

- {{ logo_label }} -

-

- {{ tos_label }} -

-

- {{ policy_label }} -

- - -
- - diff --git a/chpy/certs/cert.pem b/example/chpy/certs/cert.pem similarity index 100% rename from chpy/certs/cert.pem rename to example/chpy/certs/cert.pem diff --git a/chpy/certs/key.pem b/example/chpy/certs/key.pem similarity index 100% rename from chpy/certs/key.pem rename to example/chpy/certs/key.pem diff --git a/chpy/conf.py b/example/chpy/conf.py similarity index 87% rename from chpy/conf.py rename to example/chpy/conf.py index 8b4be60c..a4516ea9 100644 --- a/chpy/conf.py +++ b/example/chpy/conf.py @@ -1,15 +1,15 @@ -from oidcendpoint import user_info -from oidcendpoint.oidc.authorization import Authorization -from oidcendpoint.oidc.discovery import Discovery -from oidcendpoint.oidc.provider_config import ProviderConfiguration -from oidcendpoint.oidc.registration import Registration -from oidcendpoint.oidc.session import Session -from oidcendpoint.oidc.token import AccessToken -from oidcendpoint.oidc.userinfo import UserInfo -from oidcendpoint.user_authn.authn_context import INTERNETPROTOCOLPASSWORD -from oidcendpoint.user_authn.user import NoAuthn -from oidcendpoint.user_authn.user import UserPassJinja2 -from oidcendpoint.util import JSONDictDB +from oidcop import user_info +from oidcop.oidc.authorization import Authorization +from oidcop.oidc.discovery import Discovery +from oidcop.oidc.provider_config import ProviderConfiguration +from oidcop.oidc.registration import Registration +from oidcop.oidc.session import Session +from oidcop.oidc.token import AccessToken +from oidcop.oidc.userinfo import UserInfo +from oidcop.user_authn.authn_context import INTERNETPROTOCOLPASSWORD +from oidcop.user_authn.user import NoAuthn +from oidcop.user_authn.user import UserPassJinja2 +from oidcop.util import JSONDictDB RESPONSE_TYPES_SUPPORTED = [ ["code"], ["token"], ["id_token"], ["code", "token"], ["code", "id_token"], diff --git a/chpy/passwd.json b/example/chpy/passwd.json similarity index 100% rename from chpy/passwd.json rename to example/chpy/passwd.json diff --git a/chpy/provider.py b/example/chpy/provider.py similarity index 100% rename from chpy/provider.py rename to example/chpy/provider.py diff --git a/chpy/seed.txt b/example/chpy/seed.txt similarity index 100% rename from chpy/seed.txt rename to example/chpy/seed.txt diff --git a/chpy/server.py b/example/chpy/server.py similarity index 100% rename from chpy/server.py rename to example/chpy/server.py diff --git a/chpy/users.json b/example/chpy/users.json similarity index 100% rename from chpy/users.json rename to example/chpy/users.json diff --git a/django_op/.gitignore b/example/django_op/.gitignore similarity index 100% rename from django_op/.gitignore rename to example/django_op/.gitignore diff --git a/django_op/AUTHORS b/example/django_op/AUTHORS similarity index 100% rename from django_op/AUTHORS rename to example/django_op/AUTHORS diff --git a/django_op/LICENSE b/example/django_op/LICENSE similarity index 100% rename from django_op/LICENSE rename to example/django_op/LICENSE diff --git a/django_op/README.md b/example/django_op/README.md similarity index 100% rename from django_op/README.md rename to example/django_op/README.md diff --git a/django_op/example/data/oidc_op/certs/cert.pem b/example/django_op/example/data/oidc_op/certs/cert.pem similarity index 100% rename from django_op/example/data/oidc_op/certs/cert.pem rename to example/django_op/example/data/oidc_op/certs/cert.pem diff --git a/django_op/example/data/oidc_op/certs/key.pem b/example/django_op/example/data/oidc_op/certs/key.pem similarity index 100% rename from django_op/example/data/oidc_op/certs/key.pem rename to example/django_op/example/data/oidc_op/certs/key.pem diff --git a/django_op/example/data/oidc_rp/conf.django.yaml b/example/django_op/example/data/oidc_rp/conf.django.yaml similarity index 100% rename from django_op/example/data/oidc_rp/conf.django.yaml rename to example/django_op/example/data/oidc_rp/conf.django.yaml diff --git a/django_op/example/example/__init__.py b/example/django_op/example/example/__init__.py similarity index 100% rename from django_op/example/example/__init__.py rename to example/django_op/example/example/__init__.py diff --git a/django_op/example/example/oidc_op.conf.yaml b/example/django_op/example/example/oidc_op.conf.yaml similarity index 100% rename from django_op/example/example/oidc_op.conf.yaml rename to example/django_op/example/example/oidc_op.conf.yaml diff --git a/django_op/example/example/settings.py b/example/django_op/example/example/settings.py similarity index 100% rename from django_op/example/example/settings.py rename to example/django_op/example/example/settings.py diff --git a/django_op/example/example/urls.py b/example/django_op/example/example/urls.py similarity index 100% rename from django_op/example/example/urls.py rename to example/django_op/example/example/urls.py diff --git a/django_op/example/example/wsgi.py b/example/django_op/example/example/wsgi.py similarity index 100% rename from django_op/example/example/wsgi.py rename to example/django_op/example/example/wsgi.py diff --git a/django_op/example/manage.py b/example/django_op/example/manage.py similarity index 100% rename from django_op/example/manage.py rename to example/django_op/example/manage.py diff --git a/django_op/example/oidc_op b/example/django_op/example/oidc_op similarity index 100% rename from django_op/example/oidc_op rename to example/django_op/example/oidc_op diff --git a/django_op/example/requirements.txt b/example/django_op/example/requirements.txt similarity index 100% rename from django_op/example/requirements.txt rename to example/django_op/example/requirements.txt diff --git a/django_op/example/unical_accounts/__init__.py b/example/django_op/example/unical_accounts/__init__.py similarity index 100% rename from django_op/example/unical_accounts/__init__.py rename to example/django_op/example/unical_accounts/__init__.py diff --git a/django_op/example/unical_accounts/admin.py b/example/django_op/example/unical_accounts/admin.py similarity index 100% rename from django_op/example/unical_accounts/admin.py rename to example/django_op/example/unical_accounts/admin.py diff --git a/django_op/example/unical_accounts/admin_inlines.py b/example/django_op/example/unical_accounts/admin_inlines.py similarity index 100% rename from django_op/example/unical_accounts/admin_inlines.py rename to example/django_op/example/unical_accounts/admin_inlines.py diff --git a/django_op/example/unical_accounts/apps.py b/example/django_op/example/unical_accounts/apps.py similarity index 100% rename from django_op/example/unical_accounts/apps.py rename to example/django_op/example/unical_accounts/apps.py diff --git a/django_op/example/unical_accounts/forms.py b/example/django_op/example/unical_accounts/forms.py similarity index 100% rename from django_op/example/unical_accounts/forms.py rename to example/django_op/example/unical_accounts/forms.py diff --git a/django_op/example/unical_accounts/migrations/0001_initial.py b/example/django_op/example/unical_accounts/migrations/0001_initial.py similarity index 100% rename from django_op/example/unical_accounts/migrations/0001_initial.py rename to example/django_op/example/unical_accounts/migrations/0001_initial.py diff --git a/django_op/example/unical_accounts/migrations/__init__.py b/example/django_op/example/unical_accounts/migrations/__init__.py similarity index 100% rename from django_op/example/unical_accounts/migrations/__init__.py rename to example/django_op/example/unical_accounts/migrations/__init__.py diff --git a/django_op/example/unical_accounts/models.py b/example/django_op/example/unical_accounts/models.py similarity index 100% rename from django_op/example/unical_accounts/models.py rename to example/django_op/example/unical_accounts/models.py diff --git a/django_op/example/unical_accounts/templatetags/__init__.py b/example/django_op/example/unical_accounts/templatetags/__init__.py similarity index 100% rename from django_op/example/unical_accounts/templatetags/__init__.py rename to example/django_op/example/unical_accounts/templatetags/__init__.py diff --git a/django_op/example/unical_accounts/templatetags/has_group.py b/example/django_op/example/unical_accounts/templatetags/has_group.py similarity index 100% rename from django_op/example/unical_accounts/templatetags/has_group.py rename to example/django_op/example/unical_accounts/templatetags/has_group.py diff --git a/django_op/example/unical_accounts/tests.py b/example/django_op/example/unical_accounts/tests.py similarity index 100% rename from django_op/example/unical_accounts/tests.py rename to example/django_op/example/unical_accounts/tests.py diff --git a/django_op/example/unical_accounts/urls.py b/example/django_op/example/unical_accounts/urls.py similarity index 100% rename from django_op/example/unical_accounts/urls.py rename to example/django_op/example/unical_accounts/urls.py diff --git a/django_op/example/unical_accounts/views.py b/example/django_op/example/unical_accounts/views.py similarity index 100% rename from django_op/example/unical_accounts/views.py rename to example/django_op/example/unical_accounts/views.py diff --git a/django_op/oidc_op/__init__.py b/example/django_op/oidc_op/__init__.py similarity index 100% rename from django_op/oidc_op/__init__.py rename to example/django_op/oidc_op/__init__.py diff --git a/django_op/oidc_op/admin.py b/example/django_op/oidc_op/admin.py similarity index 100% rename from django_op/oidc_op/admin.py rename to example/django_op/oidc_op/admin.py diff --git a/django_op/oidc_op/application.py b/example/django_op/oidc_op/application.py similarity index 100% rename from django_op/oidc_op/application.py rename to example/django_op/oidc_op/application.py diff --git a/django_op/oidc_op/apps.py b/example/django_op/oidc_op/apps.py similarity index 100% rename from django_op/oidc_op/apps.py rename to example/django_op/oidc_op/apps.py diff --git a/django_op/oidc_op/configure.py b/example/django_op/oidc_op/configure.py similarity index 100% rename from django_op/oidc_op/configure.py rename to example/django_op/oidc_op/configure.py diff --git a/django_op/oidc_op/migrations/__init__.py b/example/django_op/oidc_op/migrations/__init__.py similarity index 100% rename from django_op/oidc_op/migrations/__init__.py rename to example/django_op/oidc_op/migrations/__init__.py diff --git a/django_op/oidc_op/models.py b/example/django_op/oidc_op/models.py similarity index 100% rename from django_op/oidc_op/models.py rename to example/django_op/oidc_op/models.py diff --git a/django_op/oidc_op/tests.py b/example/django_op/oidc_op/tests.py similarity index 100% rename from django_op/oidc_op/tests.py rename to example/django_op/oidc_op/tests.py diff --git a/django_op/oidc_op/urls.py b/example/django_op/oidc_op/urls.py similarity index 100% rename from django_op/oidc_op/urls.py rename to example/django_op/oidc_op/urls.py diff --git a/django_op/oidc_op/users.py b/example/django_op/oidc_op/users.py similarity index 100% rename from django_op/oidc_op/users.py rename to example/django_op/oidc_op/users.py diff --git a/django_op/oidc_op/views.py b/example/django_op/oidc_op/views.py similarity index 100% rename from django_op/oidc_op/views.py rename to example/django_op/oidc_op/views.py diff --git a/django_op/requirements.txt b/example/django_op/requirements.txt similarity index 100% rename from django_op/requirements.txt rename to example/django_op/requirements.txt diff --git a/flask_op/Dockerfile b/example/flask_op/Dockerfile similarity index 100% rename from flask_op/Dockerfile rename to example/flask_op/Dockerfile diff --git a/flask_op/README.md b/example/flask_op/README.md similarity index 100% rename from flask_op/README.md rename to example/flask_op/README.md diff --git a/flask_op/__init__.py b/example/flask_op/__init__.py similarity index 100% rename from flask_op/__init__.py rename to example/flask_op/__init__.py diff --git a/flask_op/application.py b/example/flask_op/application.py similarity index 100% rename from flask_op/application.py rename to example/flask_op/application.py diff --git a/flask_op/certs/cert.pem b/example/flask_op/certs/cert.pem similarity index 100% rename from flask_op/certs/cert.pem rename to example/flask_op/certs/cert.pem diff --git a/flask_op/certs/client.crt b/example/flask_op/certs/client.crt similarity index 100% rename from flask_op/certs/client.crt rename to example/flask_op/certs/client.crt diff --git a/flask_op/certs/client.key b/example/flask_op/certs/client.key similarity index 100% rename from flask_op/certs/client.key rename to example/flask_op/certs/client.key diff --git a/flask_op/certs/key.pem b/example/flask_op/certs/key.pem similarity index 100% rename from flask_op/certs/key.pem rename to example/flask_op/certs/key.pem diff --git a/flask_op/conf.py b/example/flask_op/conf.py similarity index 100% rename from flask_op/conf.py rename to example/flask_op/conf.py diff --git a/flask_op/conf_192.yaml b/example/flask_op/conf_192.yaml similarity index 100% rename from flask_op/conf_192.yaml rename to example/flask_op/conf_192.yaml diff --git a/flask_op/config.yaml b/example/flask_op/config.yaml similarity index 98% rename from flask_op/config.yaml rename to example/flask_op/config.yaml index 99844adf..053dea4b 100644 --- a/flask_op/config.yaml +++ b/example/flask_op/config.yaml @@ -22,7 +22,7 @@ logging: format: '%(asctime)s %(name)s %(levelname)s %(message)s' port: &port 5000 -domain: &domain localhost +domain: &domain 192.168.1.158 server_name: '{domain}:{port}' base_url: &base_url 'https://{domain}:{port}' @@ -93,7 +93,7 @@ op: code: lifetime: 600 token: - class: oidcendpoint.jwt_token.JWTToken + class: oidcendpoint.token.jwt_token.JWTToken lifetime: 3600 add_claims: - email @@ -161,7 +161,7 @@ op: - form_post token: path: token - class: oidcendpoint.oidc.token.AccessToken + class: oidcendpoint.oidc.token.Token kwargs: client_authn_method: - client_secret_post diff --git a/flask_op/config_persistent.yaml b/example/flask_op/config_persistent.yaml similarity index 100% rename from flask_op/config_persistent.yaml rename to example/flask_op/config_persistent.yaml diff --git a/flask_op/passwd.json b/example/flask_op/passwd.json similarity index 100% rename from flask_op/passwd.json rename to example/flask_op/passwd.json diff --git a/flask_op/requirements.txt b/example/flask_op/requirements.txt similarity index 100% rename from flask_op/requirements.txt rename to example/flask_op/requirements.txt diff --git a/flask_op/seed.txt b/example/flask_op/seed.txt similarity index 100% rename from flask_op/seed.txt rename to example/flask_op/seed.txt diff --git a/flask_op/server.py b/example/flask_op/server.py similarity index 100% rename from flask_op/server.py rename to example/flask_op/server.py diff --git a/flask_op/users.json b/example/flask_op/users.json similarity index 100% rename from flask_op/users.json rename to example/flask_op/users.json diff --git a/flask_op/views.py b/example/flask_op/views.py similarity index 98% rename from flask_op/views.py rename to example/flask_op/views.py index fdc19bf2..8bd4986c 100644 --- a/flask_op/views.py +++ b/example/flask_op/views.py @@ -18,7 +18,7 @@ from oidcendpoint.exception import FailedAuthentication from oidcendpoint.exception import InvalidClient from oidcendpoint.exception import UnknownClient -from oidcendpoint.oidc.token import AccessToken +from oidcendpoint.oidc.token import Token from oidcmsg.oauth2 import ResponseMessage from oidcmsg.oidc import AccessTokenRequest from oidcmsg.oidc import AuthorizationRequest @@ -215,6 +215,12 @@ def service_endpoint(endpoint): else: pr_args = {'auth': authn} + pr_args["http_info"] = { + "method": request.method, + "url": request.url, + "headers": request.headers + } + if request.method == 'GET': try: req_args = endpoint.parse_request(request.args.to_dict(), **pr_args) @@ -256,7 +262,7 @@ def service_endpoint(endpoint): else: kwargs = {} - if isinstance(endpoint, AccessToken): + if isinstance(endpoint, Token): args = endpoint.process_request(AccessTokenRequest(**req_args), **kwargs) else: args = endpoint.process_request(req_args, **kwargs) diff --git a/flask_op/templates/check_session_iframe.html b/flask_op/templates/check_session_iframe.html deleted file mode 100644 index 08f2f420..00000000 --- a/flask_op/templates/check_session_iframe.html +++ /dev/null @@ -1,122 +0,0 @@ - - - - - Session Management - OP iframe - - - - - - - - - \ No newline at end of file diff --git a/flask_op/templates/error.html b/flask_op/templates/error.html deleted file mode 100644 index 5933d924..00000000 --- a/flask_op/templates/error.html +++ /dev/null @@ -1,12 +0,0 @@ - - - -

Error: {{ title }}

- -{% if redirect_url is defined %} -

Continue

-{% else %} -{% endif %} - - - diff --git a/flask_op/templates/frontchannel_logout.html b/flask_op/templates/frontchannel_logout.html deleted file mode 100644 index 0cca93c1..00000000 --- a/flask_op/templates/frontchannel_logout.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - Logout - - - - - - - {{ frames|safe }} - - \ No newline at end of file diff --git a/flask_op/templates/index.html b/flask_op/templates/index.html deleted file mode 100644 index e45bf506..00000000 --- a/flask_op/templates/index.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Title - - -

Hi There!

- - \ No newline at end of file diff --git a/flask_op/templates/logout.html b/flask_op/templates/logout.html deleted file mode 100644 index 5b8e91ea..00000000 --- a/flask_op/templates/logout.html +++ /dev/null @@ -1,87 +0,0 @@ - - - - Logout Request - - - - - -
-

Do you want to sign-out from {{ op }}?

- -
-
- - -
- - \ No newline at end of file diff --git a/flask_op/templates/post_logout.html b/flask_op/templates/post_logout.html deleted file mode 100644 index 509c1f7a..00000000 --- a/flask_op/templates/post_logout.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Post Logout - - -

You have now been logged out from this server!

- - diff --git a/flask_op/templates/user_pass.jinja2 b/flask_op/templates/user_pass.jinja2 deleted file mode 100644 index 9add475c..00000000 --- a/flask_op/templates/user_pass.jinja2 +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - Please login - - - -

{{ page_header }}

- -
- - -

- - -

- -

- - -

- -

- {{ logo_label }} -

-

- {{ tos_label }} -

-

- {{ policy_label }} -

- - -
- - diff --git a/setup.py b/setup.py index 94d824ef..94e14efe 100644 --- a/setup.py +++ b/setup.py @@ -51,8 +51,11 @@ def run_tests(self): author_email="roland@catalogix.se", license="Apache 2.0", url='https://github.com/IdentityPython/oidcop', - packages=["oidcop"], package_dir={"": "src"}, + packages=["oidcop", 'oidcop/oidc', 'oidcop/authz', + 'oidcop/user_authn', 'oidcop/user_info', + 'oidcop/oauth2', 'oidcop/oidc/add_on', 'oidcop/oauth2/add_on', + 'oidcop/session', 'oidcop/token'], classifiers=[ "Development Status :: 4 - Beta", "License :: OSI Approved :: Apache Software License", @@ -61,8 +64,8 @@ def run_tests(self): "Programming Language :: Python :: 3.8", "Topic :: Software Development :: Libraries :: Python Modules"], install_requires=[ + "oidcmsg>=1.3.0", "pyyaml", - 'oidcendpoint>=1.1.0' ], zip_safe=False, cmdclass={'test': PyTest}, diff --git a/src/oidcop/__init__.py b/src/oidcop/__init__.py index 39cae215..2bb7941e 100644 --- a/src/oidcop/__init__.py +++ b/src/oidcop/__init__.py @@ -1 +1,35 @@ -__version__ = '0.6.2' \ No newline at end of file +import string +from secrets import choice + +__version__ = '2.0.0' + +DEF_SIGN_ALG = { + "id_token": "RS256", + "userinfo": "RS256", + "request_object": "RS256", + "client_secret_jwt": "HS256", + "private_key_jwt": "RS256", +} + +HTTP_ARGS = ["headers", "redirections", "connection_type"] + +JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + +URL_ENCODED = "application/x-www-form-urlencoded" +JSON_ENCODED = "application/json" +JOSE_ENCODED = "application/jose" + + +def sanitize(txt): + return txt + + +def rndstr(size=16): + """ + Returns a string of random ascii characters or digits + + :param size: The length of the string + :return: string + """ + chars = string.ascii_letters + string.digits + return "".join(choice(chars) for i in range(size)) diff --git a/src/oidcop/authn_event.py b/src/oidcop/authn_event.py new file mode 100644 index 00000000..d3091952 --- /dev/null +++ b/src/oidcop/authn_event.py @@ -0,0 +1,67 @@ +from oidcmsg.message import SINGLE_OPTIONAL_INT +from oidcmsg.message import SINGLE_OPTIONAL_STRING +from oidcmsg.message import SINGLE_REQUIRED_STRING +from oidcmsg.message import Message +from oidcmsg.time_util import time_sans_frac + +DEFAULT_AUTHN_EXPIRES_IN = 3600 + + +class AuthnEvent(Message): + c_param = { + "uid": SINGLE_REQUIRED_STRING, + "authn_info": SINGLE_REQUIRED_STRING, + "authn_time": SINGLE_OPTIONAL_INT, + "valid_until": SINGLE_OPTIONAL_INT, + "sub": SINGLE_OPTIONAL_STRING + } + + def is_valid(self, now=0): + if now: + return self["valid_until"] > now + else: + return self["valid_until"] > time_sans_frac() + + def expires_in(self): + return self["valid_until"] - time_sans_frac() + + +def create_authn_event(uid, authn_info=None, authn_time: int = 0, + valid_until: int = 0, expires_in: int = 0, + sub: str = "", **kwargs): + """ + + :param uid: User ID. This is the identifier used by the user DB + :param authn_time: When the authentication took place + :param authn_info: Information about the authentication + :param valid_until: Until when the authentication is valid + :param expires_in: How long before the authentication expires + :param sub: Subject identifier. The identifier for the user used between + the AS and the RP. + :param kwargs: + :return: + """ + + args = {"uid": uid, "authn_info": authn_info} + + if sub: + args["sub"] = sub + + if authn_time: + args["authn_time"] = authn_time + else: + _ts = kwargs.get("timestamp") + if _ts: + args["authn_time"] = _ts + else: + args["authn_time"] = time_sans_frac() + + if valid_until: + args["valid_until"] = valid_until + else: + if expires_in: + args["valid_until"] = args["authn_time"] + expires_in + else: + args["valid_until"] = args["authn_time"] + DEFAULT_AUTHN_EXPIRES_IN + + return AuthnEvent(**args) diff --git a/src/oidcop/authz/__init__.py b/src/oidcop/authz/__init__.py new file mode 100755 index 00000000..3779d860 --- /dev/null +++ b/src/oidcop/authz/__init__.py @@ -0,0 +1,117 @@ +import copy +import inspect +import logging +import sys +from typing import Optional +from typing import Union + +from oidcmsg.message import Message + +from oidcop.session.grant import Grant + +logger = logging.getLogger(__name__) + + +class AuthzHandling(object): + """ Class that allow an entity to manage authorization """ + + def __init__(self, server_get, grant_config=None, **kwargs): + self.server_get = server_get + self.grant_config = grant_config or {} + self.kwargs = kwargs + + def usage_rules(self, client_id): + if "usage_rules" in self.grant_config: + _usage_rules = copy.deepcopy(self.grant_config["usage_rules"]) + else: + _usage_rules = {} + + try: + _per_client = self.server_get("endpoint_context").cdb[client_id]["token_usage_rules"] + except KeyError: + pass + else: + if _usage_rules: + for _token_type, _rule in _usage_rules.items(): + _pc = _per_client.get(_token_type) + if _pc: + _rule.update(_pc) + for _token_type, _rule in _per_client.items(): + if _token_type not in _usage_rules: + _usage_rules[_token_type] = _rule + else: + _usage_rules = _per_client + + return _usage_rules + + def usage_rules_for(self, client_id, token_type): + _token_usage = self.usage_rules(client_id=client_id) + try: + return _token_usage[token_type] + except KeyError: + return {} + + def __call__(self, session_id: str, request: Union[dict, Message], + resources: Optional[list] = None) -> Grant: + args = self.grant_config.copy() + + scope = request.get("scope") + if scope: + args["scope"] = scope + + claims = request.get("claims") + if claims: + if isinstance(request, Message): + claims = claims.to_dict() + args["claims"] = claims + + session_info = self.server_get("endpoint_context").session_manager.get_session_info( + session_id=session_id, grant=True + ) + grant = session_info["grant"] + + for key, val in args.items(): + if key == "expires_in": + grant.set_expires_at(val) + else: + setattr(grant, key, val) + + if resources is None: + grant.resources = [session_info["client_id"]] + else: + grant.resources = resources + + # This is where user consent should be handled + for interface in ["userinfo", "introspection", "id_token", "access_token"]: + grant.claims[interface] = self.server_get("endpoint_context").claims_interface.get_claims( + session_id=session_id, scopes=request["scope"], usage=interface + ) + return grant + + +class Implicit(AuthzHandling): + def __call__(self, session_id: str, request: Union[dict, Message], + resources: Optional[list] = None) -> Grant: + args = self.grant_config.copy() + grant = self.server_get("endpoint_context").session_manager.get_grant(session_id=session_id) + for arg, val in args: + setattr(grant, arg, val) + return grant + + +def factory(msgtype, server_get, **kwargs): + """ + Factory method that can be used to easily instantiate a class instance + + :param msgtype: The name of the class + :param kwargs: Keyword arguments + :return: An instance of the class or None if the name doesn't match any + known class. + """ + for name, obj in inspect.getmembers(sys.modules[__name__]): + if inspect.isclass(obj) and issubclass(obj, AuthzHandling): + try: + if obj.__name__ == msgtype: + return obj(server_get, **kwargs) + except AttributeError: + pass diff --git a/src/oidcop/client_authn.py b/src/oidcop/client_authn.py new file mode 100755 index 00000000..50d012ee --- /dev/null +++ b/src/oidcop/client_authn.py @@ -0,0 +1,449 @@ +import base64 +import logging +from typing import Callable +from typing import Optional +from typing import Union +from urllib.parse import unquote_plus + +from cryptojwt.exception import BadSignature +from cryptojwt.exception import Invalid +from cryptojwt.exception import MissingKey +from cryptojwt.jwt import JWT +from cryptojwt.jwt import utc_time_sans_frac +from cryptojwt.utils import as_bytes +from cryptojwt.utils import as_unicode +from oidcmsg.message import Message +from oidcmsg.oidc import JsonWebToken +from oidcmsg.oidc import verified_claim_name + +from oidcop import JWT_BEARER +from oidcop import sanitize +from oidcop.endpoint_context import EndpointContext +from oidcop.exception import InvalidClient +from oidcop.exception import MultipleUsage +from oidcop.exception import NotForMe +from oidcop.exception import UnknownClient +from oidcop.util import importer + +logger = logging.getLogger(__name__) + +__author__ = "roland hedberg" + + +class AuthnFailure(Exception): + pass + + +class NoMatchingKey(Exception): + pass + + +class UnknownOrNoAuthnMethod(Exception): + pass + + +class WrongAuthnMethod(Exception): + pass + + +class ClientAuthnMethod(object): + def __init__(self, server_get): + """ + :param endpoint_context: Server info, a + :py:class:`oidcop.endpoint_context.EndpointContext` instance + """ + self.server_get = server_get + + def verify(self, **kwargs): + """ + Verify authentication information in a request + :param kwargs: + :return: + """ + raise NotImplementedError() + + def is_usable(self, request=None, authorization_info=None): + """ + Verify that this authentication method is applicable. + + :param request: The request + :param authorization_info: Other authorization information + :return: True/False + """ + raise NotImplementedError() + + +def basic_authn(authn): + if not authn.startswith("Basic "): + raise AuthnFailure("Wrong type of authorization token") + + _tok = as_bytes(authn[6:]) + # Will raise ValueError type exception if not base64 encoded + _tok = base64.b64decode(_tok) + part = [unquote_plus(p) for p in as_unicode(_tok).split(":")] + if len(part) == 2: + return dict(zip(["id", "secret"], part)) + else: + raise ValueError("Illegal token") + + +class ClientSecretBasic(ClientAuthnMethod): + """ + Clients that have received a client_secret value from the Authorization + Server, authenticate with the Authorization Server in accordance with + Section 3.2.1 of OAuth 2.0 [RFC6749] using HTTP Basic authentication scheme. + """ + + tag = "client_secret_basic" + + def is_usable(self, request=None, authorization_info=None): + if authorization_info is not None and authorization_info.startswith("Basic "): + return True + return False + + def verify(self, authorization_info, **kwargs): + client_info = basic_authn(authorization_info) + + if ( + self.server_get("endpoint_context").cdb[client_info["id"]]["client_secret"] + == client_info["secret"] + ): + return {"client_id": client_info["id"]} + else: + raise AuthnFailure() + + +class ClientSecretPost(ClientSecretBasic): + """ + Clients that have received a client_secret value from the Authorization + Server, authenticate with the Authorization Server in accordance with + Section 3.2.1 of OAuth 2.0 [RFC6749] by including the Client Credentials in + the request body. + """ + + tag = "client_secret_post" + + def is_usable(self, request=None, authorization_info=None): + if request is None: + return False + if "client_id" in request and "client_secret" in request: + return True + return False + + def verify(self, request, **kwargs): + if ( + self.server_get("endpoint_context").cdb[request["client_id"]]["client_secret"] + == request["client_secret"] + ): + return {"client_id": request["client_id"]} + else: + raise AuthnFailure("secrets doesn't match") + + +class BearerHeader(ClientSecretBasic): + """ + """ + + tag = "bearer_header" + + def is_usable(self, request=None, authorization_info=None): + if authorization_info is not None and authorization_info.startswith("Bearer "): + return True + return False + + def verify(self, authorization_info, **kwargs): + return {"token": authorization_info.split(" ", 1)[1]} + + +class BearerBody(ClientSecretPost): + """ + Same as Client Secret Post + """ + + tag = "bearer_body" + + def is_usable(self, request=None, authorization_info=None): + if request is not None and "access_token" in request: + return True + return False + + def verify(self, request, **kwargs): + _token = request.get("access_token") + if _token is None: + raise AuthnFailure("No access token") + + res = {"token": _token} + _client_id = request.get("client_id") + if _client_id: + res["client_id"] = _client_id + return res + + +class JWSAuthnMethod(ClientAuthnMethod): + def is_usable(self, request=None, authorization_info=None): + if request is None: + return False + if "client_assertion" in request: + return True + return False + + def verify(self, request, key_type, **kwargs): + _context = self.server_get("endpoint_context") + _jwt = JWT(_context.keyjar, msg_cls=JsonWebToken) + try: + ca_jwt = _jwt.unpack(request["client_assertion"]) + except (Invalid, MissingKey, BadSignature) as err: + logger.info("%s" % sanitize(err)) + raise AuthnFailure("Could not verify client_assertion.") + + _sign_alg = ca_jwt.jws_header.get("alg") + if _sign_alg and _sign_alg.startswith("HS"): + if key_type == "private_key": + raise AttributeError("Wrong key type") + keys = _context.keyjar.get( + "sig", "oct", ca_jwt["iss"], ca_jwt.jws_header.get("kid") + ) + _secret = _context.cdb[ca_jwt["iss"]].get("client_secret") + if _secret and keys[0].key != as_bytes(_secret): + raise AttributeError("Oct key used for signing not client_secret") + else: + if key_type == "client_secret": + raise AttributeError("Wrong key type") + + authtoken = sanitize(ca_jwt.to_dict()) + logger.debug("authntoken: {}".format(authtoken)) + + _endpoint = kwargs.get("endpoint") + if _endpoint is None or not _endpoint: + if _context.issuer in ca_jwt["aud"]: + pass + else: + raise NotForMe("Not for me!") + else: + if set(ca_jwt["aud"]).intersection(_endpoint.allowed_target_uris()): + pass + else: + raise NotForMe("Not for me!") + + # If there is a jti use it to make sure one-time usage is true + _jti = ca_jwt.get("jti") + if _jti: + _key = "{}:{}".format(ca_jwt["iss"], _jti) + if _key in _context.jti_db: + raise MultipleUsage("Have seen this token once before") + else: + _context.jti_db[_key] = utc_time_sans_frac() + + request[verified_claim_name("client_assertion")] = ca_jwt + client_id = kwargs.get("client_id") or ca_jwt["iss"] + + return {"client_id": client_id, "jwt": ca_jwt} + + +class ClientSecretJWT(JWSAuthnMethod): + """ + Clients that have received a client_secret value from the Authorization + Server create a JWT using an HMAC SHA algorithm, such as HMAC SHA-256. + The HMAC (Hash-based Message Authentication Code) is calculated using the + bytes of the UTF-8 representation of the client_secret as the shared key. + """ + + tag = "client_secret_jwt" + + def verify(self, request=None, **kwargs): + res = JWSAuthnMethod.verify(self, request, key_type="client_secret", **kwargs) + # Verify that a HS alg was used + res["method"] = self.tag + return res + + +class PrivateKeyJWT(JWSAuthnMethod): + """ + Clients that have registered a public key sign a JWT using that key. + """ + + tag = "private_key_jwt" + + def verify(self, request=None, **kwargs): + res = JWSAuthnMethod.verify(self, request, key_type="private_key", **kwargs) + # Verify that an RS or ES alg was used ? + res["method"] = self.tag + return res + + +class RequestParam(ClientAuthnMethod): + tag = "request_param" + + def is_usable(self, request=None, authorization_info=None): + if request and "request" in request: + return True + + def verify(self, request=None, **kwargs): + _context = self.server_get("endpoint_context") + _jwt = JWT(_context.keyjar, msg_cls=JsonWebToken) + try: + _jwt = _jwt.unpack(request["request"]) + except (Invalid, MissingKey, BadSignature) as err: + logger.info("%s" % sanitize(err)) + raise AuthnFailure("Could not verify client_assertion.") + + # If there is a jti use it to make sure one-time usage is true + _jti = _jwt.get("jti") + if _jti: + _key = "{}:{}".format(_jwt["iss"], _jti) + if _key in _context.jti_db: + raise MultipleUsage("Have seen this token once before") + else: + _context.jti_db.set(_key, utc_time_sans_frac()) + + request[verified_claim_name("client_assertion")] = _jwt + client_id = kwargs.get("client_id") or _jwt["iss"] + + return {"client_id": client_id, "jwt": _jwt} + + +CLIENT_AUTHN_METHOD = { + "client_secret_basic": ClientSecretBasic, + "client_secret_post": ClientSecretPost, + "bearer_header": BearerHeader, + "bearer_body": BearerBody, + "client_secret_jwt": ClientSecretJWT, + "private_key_jwt": PrivateKeyJWT, + "request_param": RequestParam, + "none": None, +} + +TYPE_METHOD = [(JWT_BEARER, JWSAuthnMethod)] + + +def valid_client_info(cinfo): + eta = cinfo.get("client_secret_expires_at", 0) + if eta != 0 and eta < utc_time_sans_frac(): + return False + return True + + +def verify_client( + endpoint_context: EndpointContext, + request: Union[dict, Message], + authorization_info: Optional[Union[dict, str]] = None, + get_client_id_from_token: Optional[Callable] = None, + endpoint=None, # Optional[Endpoint] + also_known_as: Optional[str] = None, +): + """ + Initiated Guessing ! + + :param endpoint: Endpoint instance + :param endpoint_context: EndpointContext instance + :param request: The request + :param authorization_info: Client authentication information + :param get_client_id_from_token: Function that based on a token returns a client id. + :return: dictionary containing client id, client authentication method and + possibly access token. + """ + + # fixes request = {} instead of str + # "AttributeError: 'dict' object has no attribute 'startswith'" in oidcop/endpoint.py( + # 158)client_authentication() + if isinstance(authorization_info, dict): + strings_parade = ("{} {}".format(k, v) for k, v in authorization_info.items()) + authorization_info = " ".join(strings_parade) + + auth_info = {} + _methods = [] + if endpoint: + try: + _methods = endpoint.client_authn_method + except AttributeError: + pass + + for _method in _methods: + if _method is None: + continue + if _method.is_usable(request, authorization_info): + try: + auth_info = _method.verify( + request=request, + authorization_info=authorization_info, + endpoint=endpoint, + ) + except Exception as err: + logger.warning( + "Verifying auth using {} failed: {}".format(_method.tag, err) + ) + else: + if "method" not in auth_info: + auth_info["method"] = _method.tag + break + + if not auth_info: + if None in _methods: + auth_info = {"method": "none", "client_id": request.get("client_id")} + else: + return auth_info + + if also_known_as: + client_id = also_known_as[auth_info.get("client_id")] + auth_info["client_id"] = client_id + else: + client_id = auth_info.get("client_id") + + _token = auth_info.get("token") + + if client_id: + if not client_id in endpoint_context.cdb: + raise UnknownClient("Unknown Client ID") + + _cinfo = endpoint_context.cdb[client_id] + if isinstance(_cinfo, str): + if not _cinfo in endpoint_context.cdb: + raise UnknownClient("Unknown Client ID") + + if not valid_client_info(_cinfo): + logger.warning("Client registration has timed out or " + "client secret is expired.") + raise InvalidClient("Not valid client") + + # store what authn method was used + if auth_info.get("method"): + _request_type = request.__class__.__name__ + _used_authn_method = endpoint_context.cdb[client_id].get("auth_method") + if _used_authn_method: + endpoint_context.cdb[client_id]["auth_method"][ + _request_type + ] = auth_info["method"] + else: + endpoint_context.cdb[client_id]["auth_method"] = { + _request_type: auth_info["method"] + } + elif not client_id and get_client_id_from_token: + if not _token: + logger.warning("No token") + raise ValueError("No token") + + try: + # get_client_id_from_token is a callback... Do not abuse for code readability. + auth_info["client_id"] = get_client_id_from_token( + endpoint_context, _token, request + ) + except KeyError: + raise ValueError("Unknown token") + + return auth_info + + +def client_auth_setup(auth_set, server_get): + res = [] + + for item in auth_set: + if item is None or item.lower() == "none": + res.append(None) + else: + _cls = CLIENT_AUTHN_METHOD.get(item) + if _cls: + res.append(_cls(server_get)) + else: + res.append(importer(item)(server_get)) + + return res diff --git a/src/oidcop/configure.py b/src/oidcop/configure.py index ac4a05d8..b44683b3 100755 --- a/src/oidcop/configure.py +++ b/src/oidcop/configure.py @@ -1,5 +1,5 @@ """Configuration management for IDP""" - +import logging import os from typing import Dict from typing import Optional @@ -13,7 +13,7 @@ try: from secrets import token_urlsafe as rnd_token except ImportError: - from oidcendpoint import rndstr as rnd_token + from oidcop import rndstr as rnd_token DEFAULT_ITEM_PATHS = { "webserver": ['server_key', 'server_cert'], @@ -40,7 +40,13 @@ class Configuration: """OP Configuration""" def __init__(self, conf: Dict, base_path: str = '', item_paths: Optional[dict] = None) -> None: - self.logger = configure_logging(config=conf.get('logging')).getChild(__name__) + + log_conf = conf.get('logging') + if log_conf: + self.logger = configure_logging(config=log_conf).getChild(__name__) + else: + self.logger = logging.getLogger('oidcop') + self.op = {} if item_paths is None: item_paths = DEFAULT_ITEM_PATHS diff --git a/src/oidcop/construct.py b/src/oidcop/construct.py new file mode 100644 index 00000000..5f120ea7 --- /dev/null +++ b/src/oidcop/construct.py @@ -0,0 +1,77 @@ +from functools import cmp_to_key + +from cryptojwt import jwe +from cryptojwt.jws.jws import SIGNER_ALGS + +ALG_SORT_ORDER = {"RS": 0, "ES": 1, "HS": 2, "PS": 3, "no": 4} + + +def sort_sign_alg(alg1, alg2): + if ALG_SORT_ORDER[alg1[0:2]] < ALG_SORT_ORDER[alg2[0:2]]: + return -1 + + if ALG_SORT_ORDER[alg1[0:2]] > ALG_SORT_ORDER[alg2[0:2]]: + return 1 + + if alg1 < alg2: + return -1 + + if alg1 > alg2: + return 1 + + return 0 + + +def assign_algorithms(typ): + if typ == "signing_alg": + # Pick supported signing algorithms from crypto library + # Sort order RS, ES, HS, PS + sign_algs = list(SIGNER_ALGS.keys()) + return sorted(sign_algs, key=cmp_to_key(sort_sign_alg)) + elif typ == "encryption_alg": + return jwe.SUPPORTED["alg"] + elif typ == "encryption_enc": + return jwe.SUPPORTED["enc"] + + +def construct_endpoint_info(default_capabilities, **kwargs): + if default_capabilities is not None: + _info = {} + for attr, default_val in default_capabilities.items(): + try: + _proposal = kwargs[attr] + except KeyError: + if default_val is not None: + _info[attr] = default_val + elif "signing_alg_values_supported" in attr: + _info[attr] = assign_algorithms("signing_alg") + if attr == "token_endpoint_auth_signing_alg_values_supported": + # none must not be in + # token_endpoint_auth_signing_alg_values_supported + if "none" in _info[attr]: + _info[attr].remove("none") + elif "encryption_alg_values_supported" in attr: + _info[attr] = assign_algorithms("encryption_alg") + elif "encryption_enc_values_supported" in attr: + _info[attr] = assign_algorithms("encryption_enc") + else: + _permitted = None + + if "signing_alg_values_supported" in attr: + _permitted = set(assign_algorithms("signing_alg")) + elif "encryption_alg_values_supported" in attr: + _permitted = set(assign_algorithms("encryption_alg")) + elif "encryption_enc_values_supported" in attr: + _permitted = set(assign_algorithms("encryption_enc")) + + if _permitted and not _permitted.issuperset(set(_proposal)): + raise ValueError( + "Proposed set of values outside set of permitted ({})".__format__( + attr + ) + ) + + _info[attr] = _proposal + return _info + else: + return None diff --git a/src/oidcop/cookie.py b/src/oidcop/cookie.py new file mode 100755 index 00000000..71f7b230 --- /dev/null +++ b/src/oidcop/cookie.py @@ -0,0 +1,598 @@ +import base64 +import hashlib +import json +import logging +import os +import sys +import time +from http.cookies import SimpleCookie +from urllib.parse import urlparse + +from cryptography.exceptions import InvalidTag +from cryptojwt import b64d +from cryptojwt.exception import VerificationError +from cryptojwt.jwe.aes import AES_GCMEncrypter +from cryptojwt.jwe.utils import split_ctx_and_tag +from cryptojwt.jwk.hmac import SYMKey +from cryptojwt.jws.hmac import HMACSigner +from cryptojwt.key_bundle import import_jwk +from cryptojwt.key_bundle import init_key +from cryptojwt.utils import as_bytes +from cryptojwt.utils import as_unicode +from cryptojwt.utils import b64e +from oidcmsg import time_util +from oidcmsg.time_util import in_a_while + +from oidcop.util import lv_pack +from oidcop.util import lv_unpack + +__author__ = "Roland Hedberg" + +LOGGER = logging.getLogger(__name__) + +CORS_HEADERS = [ + ("Access-Control-Allow-Origin", "*"), + ("Access-Control-Allow-Methods", "GET"), + ("Access-Control-Allow-Headers", "Authorization"), +] + + +def _expiration(timeout, time_format=None): + """ + Return an expiration time + + :param timeout: When + :param time_format: The format of the returned value + :return: A timeout date + """ + if timeout == "now": + return time_util.instant(time_format) + else: + # validity time should match lifetime of assertions + return time_util.in_a_while(minutes=timeout, time_format=time_format) + + +def sign_enc_payload(load, timestamp=0, sign_key=None, enc_key=None, sign_alg="SHA256"): + """ + + :param load: The basic information in the payload + :param timestamp: A timestamp (seconds since epoch) + :param sign_key: A :py:class:`cryptojwt.jwk.hmac.SYMKey` instance + :param enc_key: A :py:class:`cryptojwt.jwk.hmac.SYMKey` instance + :param sign_alg: Which signing algorithm to use + :return: Signed and/or encrypted payload + """ + + # Just sign, sign and encrypt or just encrypt + + if timestamp: + timestamp = str(timestamp) + else: + timestamp = str(int(time.time())) + + bytes_load = load.encode("utf-8") + bytes_timestamp = timestamp.encode("utf-8") + + if sign_key: + signer = HMACSigner(algorithm=sign_alg) + mac = signer.sign(bytes_load + bytes_timestamp, sign_key.key) + else: + mac = b"" + + if enc_key: + if len(enc_key.key) not in [16, 24, 32]: + raise ValueError("Wrong size of enc_key") + + encrypter = AES_GCMEncrypter(key=enc_key.key) + iv = os.urandom(12) + if mac: + msg = lv_pack(load, timestamp, base64.b64encode(mac).decode("utf-8")) + else: + msg = lv_pack(load, timestamp) + + enc_msg = encrypter.encrypt(msg.encode("utf-8"), iv) + ctx, tag = split_ctx_and_tag(enc_msg) + + cookie_payload = [ + bytes_timestamp, + base64.b64encode(iv), + base64.b64encode(ctx), + base64.b64encode(tag), + ] + else: + cookie_payload = [bytes_timestamp, bytes_load, base64.b64encode(mac)] + + return (b"|".join(cookie_payload)).decode("utf-8") + + +def ver_dec_content(parts, sign_key=None, enc_key=None, sign_alg="SHA256"): + """ + Verifies the value of a cookie + + :param parts: The parts of the payload + :param sign_key: A :py:class:`cryptojwt.jwk.hmac.SYMKey` instance + :param enc_key: A :py:class:`cryptojwt.jwk.hmac.SYMKey` instance + :param sign_alg: Which signing algorithm to was used + :return: A tuple with basic information and a timestamp + """ + + if parts is None: + return None + elif len(parts) == 3: + # verify the cookie signature + timestamp, load, b64_mac = parts + mac = base64.b64decode(b64_mac) + verifier = HMACSigner(algorithm=sign_alg) + if verifier.verify( + load.encode("utf-8") + timestamp.encode("utf-8"), mac, sign_key.key + ): + return load, timestamp + else: + raise VerificationError() + elif len(parts) == 4: + b_timestamp = parts[0] + iv = base64.b64decode(parts[1]) + ciphertext = base64.b64decode(parts[2]) + tag = base64.b64decode(parts[3]) + + decrypter = AES_GCMEncrypter(key=enc_key.key) + try: + msg = decrypter.decrypt(ciphertext, iv, tag=tag) + except InvalidTag: + return None + + p = lv_unpack(msg.decode("utf-8")) + load = p[0] + timestamp = p[1] + if len(p) == 3: + verifier = HMACSigner(algorithm=sign_alg) + if verifier.verify( + load.encode("utf-8") + timestamp.encode("utf-8"), + base64.b64decode(p[2]), + sign_key.key, + ): + return load, timestamp + else: + return load, timestamp + return None + + +def make_cookie_content( + name, + load, + sign_key, + domain=None, + path=None, + expire=0, + timestamp="", + enc_key=None, + max_age=0, + sign_alg="SHA256", + secure=True, + http_only=True, + same_site="", +): + """ + Create and return a cookies content + + If you only provide a `seed`, a HMAC gets added to the cookies value + and this is checked, when the cookie is parsed again. + + If you provide both `seed` and `enc_key`, the cookie gets protected + by using AEAD encryption. This provides both a MAC over the whole cookie + and encrypts the `load` in a single step. + + The `seed` and `enc_key` parameters should be byte strings of at least + 16 bytes length each. Those are used as cryptographic keys. + + :param name: Cookie name + :type name: text + :param load: Cookie load + :type load: text + :param sign_key: A sign_key key for payload signing + :type sign_key: A :py:class:`cryptojwt.jwk.hmac.SYMKey` instance + :param domain: The domain of the cookie + :param path: The path specification for the cookie + :param expire: Number of minutes before this cookie goes stale + :type expire: int + :param timestamp: A time stamp + :type timestamp: text + :param enc_key: The key to use for payload encryption. + :type enc_key: A :py:class:`cryptojwt.jwk.hmac.SYMKey` instance + :param max_age: The time in seconds for when a cookie will be deleted + :type max_age: int + :param secure: A secure cookie is only sent to the server with an encrypted request over the + HTTPS protocol. + :type secure: boolean + :param http_only: HttpOnly cookies are inaccessible to JavaScript's Document.cookie API + :type http_only: boolean + :param same_site: Whether SameSite (None,Strict or Lax) should be added to the cookie + :type same_site: byte string + :return: A SimpleCookie instance + """ + if not timestamp: + timestamp = str(int(time.time())) + + _cookie_value = sign_enc_payload( + load, timestamp, sign_key=sign_key, enc_key=enc_key, sign_alg=sign_alg + ) + + content = {name: {"value": _cookie_value}} + + if path is not None: + content[name]["path"] = path + if domain is not None: + content[name]["domain"] = domain + if max_age: + content[name]["expires"] = in_a_while(seconds=max_age) + if path: + content[name]["path"] = path + if domain: + content[name]["domain"] = domain + if expire: + content[name]["expires"] = _expiration(expire, "%a, %d-%b-%Y %H:%M:%S GMT") + if same_site: + content[name]["SameSite"] = same_site + + # these are booleans so just set them. + content[name]["Secure"] = secure + content[name]["httponly"] = http_only + + return content + + +def make_cookie( + name, + payload, + sign_key, + domain=None, + path=None, + expire=0, + timestamp="", + enc_key=None, + max_age=0, + sign_alg="SHA256", + secure=True, + http_only=True, + same_site="", +): + content = make_cookie_content( + name, + payload, + sign_key, + domain=domain, + path=path, + expire=expire, + timestamp=timestamp, + enc_key=enc_key, + max_age=max_age, + sign_alg=sign_alg, + secure=secure, + http_only=http_only, + same_site=same_site, + ) + + cookie = SimpleCookie() + + for name, args in content.items(): + cookie[name] = args["value"] + # Necessary if Python version < 3.8 + if sys.version_info[:2] <= (3, 8): + cookie[name]._reserved[str("samesite")] = str("SameSite") + + for key, value in args.items(): + if key == "value": + continue + cookie[name][key] = value + + return cookie + + +def cookie_parts(name, kaka): + """ + Give me the parts of the cookie payload + + :param name: A name of a cookie object + :param kaka: The cookie + :return: A list of parts or None if there is no cookie object with the + given name + """ + cookie_obj = SimpleCookie(as_unicode(kaka)) + morsel = cookie_obj.get(name) + if morsel: + return morsel.value.split("|") + else: + return None + + +def parse_cookie(name, sign_key, kaka, enc_key=None, sign_alg="SHA256"): + """Parses and verifies a cookie value + + Parses a cookie created by `make_cookie` and verifies + it has not been tampered with. + + You need to provide the same `sign_key` and `enc_key` + used when creating the cookie, otherwise the verification + fails. See `make_cookie` for details about the verification. + + :param sign_key: A signing key used to create the signature + :type sign_key: A :py:class:`cryptojwt.jwk.hmac.SYMKey` instance + :param kaka: The cookie + :param enc_key: The encryption key used. + :type enc_key: A :py:class:`cryptojwt.jwk.hmac.SYMKey` instance or None + :raises InvalidCookieSign: When verification fails. + :return: A tuple consisting of (payload, timestamp) or None if parsing fails + """ + if not kaka: + return None + + parts = cookie_parts(name, kaka) + + if parts: + return ver_dec_content(parts, sign_key, enc_key, sign_alg) + else: + return None + + +class CookieDealer(object): + """ + Functionality that an entity that deals with cookies need to have + access to. + """ + + def __init__( + self, + sign_key="", + enc_key="", + sign_alg="SHA256", + default_values=None, + sign_jwk=None, + enc_jwk=None, + **kwargs + ): + + if sign_key: + if isinstance(sign_key, SYMKey): + self.sign_key = sign_key + else: + self.sign_key = SYMKey(k=sign_key) + elif sign_jwk: + if isinstance(sign_jwk, dict): + self.sign_key = init_key(**sign_jwk) + else: + self.sign_key = import_jwk(sign_jwk) + else: + self.sign_key = None + + self.sign_alg = sign_alg + + if enc_key: + if isinstance(enc_key, SYMKey): + self.enc_key = enc_key + else: + self.enc_key = SYMKey(k=enc_key) + elif enc_jwk: + if isinstance(enc_jwk, dict): + self.enc_key = init_key(**enc_jwk) + else: + self.enc_key = import_jwk(enc_jwk) + else: + self.enc_key = None + + if not default_values: + default_values = {"path": "", "domain": "", "max_age": 0} + + self.default_value = default_values + + def delete_cookie(self, cookie_name=None): + """ + Create a cookie that will immediately expire when it hits the other + side. + + :param cookie_name: Name of the cookie + :return: A tuple to be added to headers + """ + if cookie_name is None: + cookie_name = self.default_value["name"] + + return self.create_cookie("", "", cookie_name=cookie_name, kill=True) + + def create_cookie( + self, + value, + typ, + cookie_name=None, + ttl=-1, + kill=False, + same_site="", + http_only=True, + ): + """ + + :param value: Part of the cookie payload + :param typ: Type of cookie + :param cookie_name: + :param ttl: Number of minutes before this cookie goes stale + :param kill: Whether the the cookie should expire on arrival + :param same_site: + :param http_only: + :return: A tuple to be added to headers + """ + if kill: + ttl = -1 + elif ttl < 0: + ttl = self.default_value["max_age"] + + if cookie_name is None: + cookie_name = self.default_value["name"] + + c_args = {} + + srvdomain = self.default_value["domain"] + if srvdomain and srvdomain not in ["localhost", "127.0.0.1", "0.0.0.0"]: + c_args["domain"] = srvdomain + + srvpath = self.default_value["path"] + if srvpath: + c_args["path"] = srvpath + + # now + timestamp = str(int(time.time())) + + # create cookie payload + try: + cookie_payload = "::".join([value, timestamp, typ]) + except TypeError: + cookie_payload = "::".join([value[0], timestamp, typ]) + + cookie = make_cookie( + cookie_name, + cookie_payload, + self.sign_key, + timestamp=timestamp, + enc_key=self.enc_key, + max_age=ttl, + sign_alg=self.sign_alg, + same_site=same_site, + http_only=http_only, + **c_args + ) + + return cookie + + def get_cookie_value(self, cookie=None, cookie_name=None): + """ + Return information stored in a Cookie + + :param cookie: A cookie instance + :param cookie_name: The name of the cookie I'm looking for + :return: tuple (value, timestamp, type) + """ + if cookie_name is None: + cookie_name = self.default_value["name"] + + if cookie is None or cookie_name is None: + return None + else: + try: + info, timestamp = parse_cookie( + cookie_name, self.sign_key, cookie, self.enc_key, self.sign_alg + ) + except (TypeError, AssertionError): + return None + else: + value, _ts, typ = info.split("::") + if timestamp == _ts: + return value, _ts, typ + return None + + def append_cookie( + self, + cookie, + name, + payload, + typ, + domain=None, + path=None, + timestamp="", + max_age=0, + same_site="None", + http_only=True, + ): + """ + Adds a cookie to a SimpleCookie instance + + :param cookie: + :param name: + :param payload: + :param typ: + :param domain: + :param path: + :param timestamp: + :param max_age: + :return: + """ + if not timestamp: + timestamp = str(int(time.time())) + + # create cookie payload + try: + _payload = "::".join([payload, timestamp, typ]) + except TypeError: + _payload = "::".join([payload[0], timestamp, typ]) + + content = make_cookie_content( + name, + _payload, + self.sign_key, + domain=domain, + path=path, + timestamp=timestamp, + enc_key=self.enc_key, + max_age=max_age, + sign_alg=self.sign_alg, + same_site=same_site, + http_only=http_only, + ) + + for name, args in content.items(): + cookie[name] = args["value"] + for key, value in args.items(): + if key == "value": + continue + cookie[name][key] = value + + return cookie + + +def compute_session_state(opbs, salt, client_id, redirect_uri): + """ + Computes a session state value. + This value is later used during session management to check whether + the log in state has changed. + + :param opbs: Cookie value + :param salt: + :param client_id: + :param redirect_uri: + :return: Session state value + """ + parsed_uri = urlparse(redirect_uri) + rp_origin_url = "{uri.scheme}://{uri.netloc}".format(uri=parsed_uri) + session_str = client_id + " " + rp_origin_url + " " + opbs + " " + salt + return hashlib.sha256(session_str.encode("utf-8")).hexdigest() + "." + salt + + +def create_session_cookie(name, opbs, **kwargs): + cookie = SimpleCookie() + cookie[name] = opbs + for key, value in kwargs.items(): + cookie[name][key] = value + return cookie + + +def append_cookie(kaka1, kaka2): + for name, args in kaka2.items(): + kaka1[name] = name + for key, value in args.items(): + if key == "value": + continue + kaka1[name][key] = value + return kaka1 + + +def new_cookie(endpoint_context, cookie_name=None, typ="sso", **kwargs): + if endpoint_context.cookie_dealer: + _val = as_unicode(b64e(as_bytes(json.dumps(kwargs)))) + return endpoint_context.cookie_dealer.create_cookie( + _val, typ=typ, cookie_name=cookie_name, ttl=endpoint_context.sso_ttl + ) + else: + return None + + +def cookie_value(b64): + try: + return json.loads(as_unicode(b64d(as_bytes(b64)))) + except Exception: + return b64 diff --git a/src/oidcop/endpoint.py b/src/oidcop/endpoint.py new file mode 100755 index 00000000..68aa9bf0 --- /dev/null +++ b/src/oidcop/endpoint.py @@ -0,0 +1,416 @@ +import logging +from typing import Callable +from typing import Optional +from typing import Union +from urllib.parse import urlparse + +from oidcmsg.exception import MissingRequiredAttribute +from oidcmsg.exception import MissingRequiredValue +from oidcmsg.message import Message +from oidcmsg.oauth2 import ResponseMessage + +from oidcop import sanitize +from oidcop.client_authn import UnknownOrNoAuthnMethod +from oidcop.client_authn import client_auth_setup +from oidcop.client_authn import verify_client +from oidcop.construct import construct_endpoint_info +from oidcop.exception import UnAuthorizedClient +from oidcop.token.exception import UnknownToken +from oidcop.util import OAUTH2_NOCACHE_HEADERS + +__author__ = "Roland Hedberg" + +LOGGER = logging.getLogger(__name__) + +""" +method call structure for Endpoints: + +parse_request + - client_authentication (*) + - post_parse_request (*) + +process_request + +do_response + - response_info + - construct + - pre_construct (*) + - _parse_args + - post_construct (*) + - update_http_args + +do_response returns a dictionary that can look like this:: + + { + 'response': + _response as a string or as a Message instance_ + 'http_headers': [ + ('Content-type', 'application/json'), + ('Pragma', 'no-cache'), + ('Cache-Control', 'no-store') + ], + 'cookie': _list of cookies_, + 'response_placement': 'body' + } + +"response" MUST be present +"http_headers" MAY be present +"cookie": MAY be present +"response_placement": If absent defaults to the endpoints response_placement +parameter value or if that is also missing 'url' +""" + + +def set_content_type(headers, content_type): + if ("Content-type", content_type) in headers: + return headers + + _headers = [h for h in headers if h[0] != "Content-type"] + _headers.append(("Content-type", content_type)) + return _headers + + +def fragment_encoding(return_type): + if return_type == ["code"]: + return False + else: + return True + + +class Endpoint(object): + request_cls = Message + response_cls = Message + error_cls = ResponseMessage + endpoint_name = "" + endpoint_path = "" + name = "" + request_format = "urlencoded" + request_placement = "query" + response_format = "json" + response_placement = "body" + client_authn_method = "" + default_capabilities = None + + def __init__(self, + server_get: Callable, + **kwargs): + self.server_get = server_get + self.pre_construct = [] + self.post_construct = [] + self.post_parse_request = [] + self.kwargs = kwargs + self.full_path = "" + + for param in [ + "request_cls", + "response_cls", + "request_format", + "request_placement", + "response_format", + "response_placement", + ]: + _val = kwargs.get(param) + if _val: + setattr(self, param, _val) + + _methods = kwargs.get("client_authn_method") + self.client_authn_method = [] + if _methods: + self.client_authn_method = client_auth_setup(_methods, server_get) + elif _methods is not None: # [] or '' or something not None but regarded as nothing. + self.client_authn_method = [None] # Ignore default value + elif self.default_capabilities: + _methods = self.default_capabilities.get("client_authn_method") + if _methods: + self.client_authn_method = client_auth_setup(_methods, server_get) + + self.endpoint_info = construct_endpoint_info(self.default_capabilities, **kwargs) + + # This is for matching against aud in JWTs + # By default the endpoint's endpoint URL is an allowed target + self.allowed_targets = [self.name] + self.client_verification_method = [] + + def parse_request(self, request: str, auth=None, **kwargs): + """ + + :param request: The request the server got + :param auth: Client authentication information + :param kwargs: extra keyword arguments + :return: + """ + LOGGER.debug("- {} -".format(self.endpoint_name)) + LOGGER.info("Request: %s" % sanitize(request)) + + _context = self.server_get("endpoint_context") + + if request: + if isinstance(request, (dict, Message)): + req = self.request_cls(**request) + else: + _cls_inst = self.request_cls() + if self.request_format == "jwt": + req = _cls_inst.deserialize( + request, + "jwt", + keyjar=_context.keyjar, + verify=_context.httpc_params["verify"], + **kwargs + ) + elif self.request_format == "url": + parts = urlparse(request) + scheme, netloc, path, params, query, fragment = parts[:6] + req = _cls_inst.deserialize(query, "urlencoded") + else: + req = _cls_inst.deserialize(request, self.request_format) + else: + req = self.request_cls() + + # Verify that the client is allowed to do this + _client_id = "" + try: + auth_info = self.client_authentication(req, auth, endpoint=self, **kwargs) + except UnknownOrNoAuthnMethod: + # If there is no required client authentication method + if not self.client_authn_method: + try: + _client_id = req["client_id"] + except KeyError: + _client_id = "" + else: + raise + else: + if "client_id" in auth_info: + req["client_id"] = auth_info["client_id"] + _client_id = auth_info["client_id"] + else: + _client_id = req.get("client_id") + + keyjar = _context.keyjar + + # verify that the request message is correct + try: + req.verify(keyjar=keyjar, opponent_id=_client_id) + except (MissingRequiredAttribute, ValueError, MissingRequiredValue) as err: + return self.error_cls(error="invalid_request", error_description="%s" % err) + + LOGGER.info("Parsed and verified request: %s" % sanitize(req)) + + # Do any endpoint specific parsing + return self.do_post_parse_request(req, _client_id, **kwargs) + + def get_client_id_from_token(self, endpoint_context, token, request=None): + return "" + + def client_authentication(self, request, auth=None, **kwargs): + """ + Do client authentication + + :param endpoint_context: A + :py:class:`oidcop.endpoint_context.SrvInfo` instance + :param request: Parsed request, a self.request_cls class instance + :param authn: Authorization info + :return: client_id or raise an exception + """ + + if "endpoint" not in kwargs: + kwargs["endpoint"] = self + + try: + authn_info = verify_client( + endpoint_context=self.server_get("endpoint_context"), + request=request, + authorization_info=auth, + get_client_id_from_token=self.get_client_id_from_token, + **kwargs + ) + except (UnknownOrNoAuthnMethod, UnknownToken): + if self.client_authn_method is None: + return {} + else: + if "none" in self.client_authn_method: + return {} + else: + raise + + LOGGER.debug("authn_info: %s", authn_info) + if ( + authn_info == {} + and self.client_authn_method + and len(self.client_authn_method) + ): + LOGGER.debug("client_authn_method: %s", self.client_authn_method) + raise UnAuthorizedClient("Authorization failed") + + return authn_info + + def do_post_parse_request(self, request, client_id="", **kwargs): + _context = self.server_get("endpoint_context") + for meth in self.post_parse_request: + if isinstance(request, self.error_cls): + break + request = meth( + request, client_id, endpoint_context=_context, **kwargs + ) + return request + + def do_pre_construct(self, response_args, request, **kwargs): + _context = self.server_get("endpoint_context") + for meth in self.pre_construct: + response_args = meth( + response_args, request, endpoint_context=_context, **kwargs + ) + + return response_args + + def do_post_construct(self, response_args, request, **kwargs): + _context = self.server_get("endpoint_context") + for meth in self.post_construct: + response_args = meth( + response_args, request, endpoint_context=_context, **kwargs + ) + + return response_args + + def process_request(self, request: Optional[Union[Message, dict]] = None, **kwargs): + """ + + :param request: The request, can be in a number of formats + :return: Arguments for the do_response method + """ + return {} + + def construct(self, + response_args: Optional[dict] = None, + request: Optional[Union[Message, dict]] = None, + **kwargs): + """ + Construct the response + + :param response_args: response arguments + :param request: The parsed request, a self.request_cls class instance + :param kwargs: Extra keyword arguments + :return: An instance of the self.response_cls class + """ + response_args = self.do_pre_construct(response_args, request, **kwargs) + + # LOGGER.debug("kwargs: %s" % sanitize(kwargs)) + response = self.response_cls(**response_args) + + return self.do_post_construct(response, request, **kwargs) + + def response_info(self, + response_args: Optional[dict] = None, + request: Optional[Union[Message, dict]] = None, + **kwargs) -> dict: + return self.construct(response_args, request, **kwargs) + + def do_response(self, + response_args: Optional[dict] = None, + request: Optional[Union[Message, dict]] = None, + error: Optional[str] = "", **kwargs) -> dict: + """ + :param response_args: Information to use when constructing the response + :param request: The original request + :param error: Possible error encountered while processing the request + """ + do_placement = True + content_type = "text/html" + _resp = {} + _response_placement = None + if response_args is None: + response_args = {} + + LOGGER.debug("do_response kwargs: %s", kwargs) + + resp = None + if error: + _response = ResponseMessage(error=error) + try: + _response["error_description"] = kwargs["error_description"] + except KeyError: + pass + elif "response_msg" in kwargs: + resp = kwargs["response_msg"] + _response_placement = kwargs.get("response_placement") + do_placement = False + _response = "" + content_type = kwargs.get("content_type") + if content_type is None: + if self.response_format == "json": + content_type = "application/json" + elif self.request_format in ["jws", "jwe", "jose"]: + content_type = "application/jose" + else: + content_type = "application/x-www-form-urlencoded" + else: + _response = self.response_info(response_args, request, **kwargs) + + if do_placement: + content_type = kwargs.get("content_type") + if content_type is None: + if self.response_placement == "body": + if self.response_format == "json": + content_type = "application/json; charset=utf-8" + resp = _response.to_json() + elif self.request_format in ["jws", "jwe", "jose"]: + content_type = "application/jose; charset=utf-8" + resp = _response + else: + content_type = "application/x-www-form-urlencoded" + resp = _response.to_urlencoded() + elif self.response_placement == "url": + # content_type = 'application/x-www-form-urlencoded' + content_type = "" + try: + fragment_enc = kwargs["fragment_enc"] + except KeyError: + _ret_type = kwargs.get("return_type") + if _ret_type: + fragment_enc = fragment_encoding(_ret_type) + else: + fragment_enc = False + + if fragment_enc: + resp = _response.request(kwargs["return_uri"], True) + else: + resp = _response.request(kwargs["return_uri"]) + else: + raise ValueError( + "Don't know where that is: '{}".format(self.response_placement) + ) + + if content_type: + try: + http_headers = set_content_type(kwargs["http_headers"], content_type) + except KeyError: + http_headers = [("Content-type", content_type)] + else: + try: + http_headers = kwargs["http_headers"] + except KeyError: + http_headers = [] + + if _response_placement: + _resp["response_placement"] = _response_placement + + http_headers.extend(OAUTH2_NOCACHE_HEADERS) + + _resp.update({"response": resp, "http_headers": http_headers}) + + try: + _resp["cookie"] = kwargs["cookie"] + except KeyError: + pass + + return _resp + + def allowed_target_uris(self): + res = [] + _context = self.server_get("endpoint_context") + for t in self.allowed_targets: + if t == "": + res.append(_context.issuer) + else: + res.append(self.server_get("endpoint", t).full_path) + return set(res) diff --git a/src/oidcop/endpoint_context.py b/src/oidcop/endpoint_context.py new file mode 100755 index 00000000..aeffdb39 --- /dev/null +++ b/src/oidcop/endpoint_context.py @@ -0,0 +1,343 @@ +import logging +from typing import Any +from typing import Optional + +from cryptojwt import KeyJar +from cryptojwt.utils import as_bytes +from jinja2 import Environment +from jinja2 import FileSystemLoader +from oidcmsg.context import OidcContext +import requests + +from oidcop import rndstr +from oidcop.scopes import SCOPE2CLAIMS +from oidcop.scopes import Scopes +from oidcop.session.claims import STANDARD_CLAIMS +from oidcop.session.manager import SessionManager +from oidcop.template_handler import Jinja2TemplateHandler +from oidcop.util import get_http_params +from oidcop.util import importer + +logger = logging.getLogger(__name__) + + +def add_path(url: str, path: str) -> str: + if url.endswith("/"): + if path.startswith("/"): + return "{}{}".format(url, path[1:]) + + return "{}{}".format(url, path) + + if path.startswith("/"): + return "{}{}".format(url, path) + + return "{}/{}".format(url, path) + + +def init_user_info(conf, cwd: str): + kwargs = conf.get("kwargs", {}) + + if isinstance(conf["class"], str): + return importer(conf["class"])(**kwargs) + + return conf["class"](**kwargs) + + +def init_service(conf, server_get=None): + kwargs = conf.get("kwargs", {}) + + kwargs["server_get"] = server_get + + if isinstance(conf["class"], str): + return importer(conf["class"])(**kwargs) + + return conf["class"](**kwargs) + + +def get_token_handler_args(conf: dict) -> dict: + """ + + :param conf: The configuration + :rtype: dict + """ + th_args = conf.get("token_handler_args", None) + if not th_args: + # create 3 keys + keydef = [ + {"type": "oct", "bytes": "24", "use": ["enc"], "kid": "code"}, + {"type": "oct", "bytes": "24", "use": ["enc"], "kid": "token"}, + {"type": "oct", "bytes": "24", "use": ["enc"], "kid": "refresh"}, + ] + + jwks_def = { + "private_path": "private/token_jwks.json", + "key_defs": keydef, + "read_only": False, + } + th_args = {"jwks_def": jwks_def} + for typ, tid in [("code", 600), ("token", 3600), ("refresh", 86400)]: + th_args[typ] = {"lifetime": tid} + + return th_args + + +class EndpointContext(OidcContext): + parameter = { + "args": {}, + # "authn_broker": AuthnBroker, + # "authz": AuthzHandling, + "cdb": {}, + "conf": {}, + # "cookie_dealer": None, + "cwd": "", + "endpoint_to_authn_method": {}, + "httpc_params": {}, + # "idtoken": IDToken, + "issuer": "", + "jti_db": {}, + "jwks_uri": "", + "login_hint_lookup": None, + "login_hint2acrs": {}, + "par_db": {}, + "provider_info": {}, + "registration_access_token": {}, + "scope2claims": {}, + "seed": "", + # "session_db": {}, + "session_manager": SessionManager, + "sso_ttl": None, + "symkey": "", + "token_args_methods": [], + # "userinfo": UserInfo, + } + + def __init__( + self, + conf: dict, + keyjar: Optional[KeyJar] = None, + cwd: Optional[str] = "", + cookie_dealer: Optional[Any] = None, + httpc: Optional[Any] = None, + ): + OidcContext.__init__(self, conf, keyjar, entity_id=conf.get("issuer", "")) + self.conf = conf + + # For my Dev environment + self.cdb = {} + self.jti_db = {} + self.registration_access_token = {} + # self.session_db = {} + + self.cwd = cwd + + # Those that use seed wants bytes but I can only store str. + try: + self.seed = as_bytes(conf["seed"]) + except KeyError: + self.seed = as_bytes(rndstr(32)) + + # Default values, to be changed below depending on configuration + # arguments for endpoints add-ons + self.args = {} + self.authn_broker = None + self.authz = None + self.cookie_dealer = cookie_dealer + self.endpoint_to_authn_method = {} + self.httpc = httpc or requests + self.idtoken = None + self.issuer = "" + self.jwks_uri = None + self.login_hint_lookup = None + self.login_hint2acrs = None + self.par_db = {} + self.provider_info = {} + self.scope2claims = SCOPE2CLAIMS + self.session_manager = None + self.sso_ttl = 14400 # 4h + self.symkey = rndstr(24) + self.template_handler = None + self.token_args_methods = [] + self.userinfo = None + + for param in [ + "issuer", + "sso_ttl", + "symkey", + "client_authn", + # "id_token_schema", + ]: + try: + setattr(self, param, conf[param]) + except KeyError: + pass + + self.th_args = get_token_handler_args(conf) + + # session db + self._sub_func = {} + self.do_sub_func() + + if "cookie_name" in conf: + self.cookie_name = conf["cookie_name"] + else: + self.cookie_name = { + "session": "oidcop", + "register": "oidc_op_rp", + "session_management": "sman", + } + + _handler = conf.get("template_handler") + if _handler: + self.template_handler = _handler + else: + _loader = conf.get("template_loader") + + if _loader is None: + _template_dir = conf.get("template_dir") + if _template_dir: + _loader = Environment(loader=FileSystemLoader(_template_dir), autoescape=True) + + if _loader: + self.template_handler = Jinja2TemplateHandler(_loader) + + # self.setup = {} + jwks_uri_path = conf["keys"]["uri_path"] + + try: + if self.issuer.endswith("/"): + self.jwks_uri = "{}{}".format(self.issuer, jwks_uri_path) + else: + self.jwks_uri = "{}/{}".format(self.issuer, jwks_uri_path) + except KeyError: + pass + + for item in [ + "cookie_dealer", + "authentication", + "id_token", + "scope2claims", + ]: + _func = getattr(self, "do_{}".format(item), None) + if _func: + _func() + + for item in ["userinfo", "login_hint_lookup", "login_hint2acrs"]: + _func = getattr(self, "do_{}".format(item), None) + if _func: + _func() + + # which signing/encryption algorithms to use in what context + self.jwx_def = {} + + # The HTTP clients request arguments + _cnf = conf.get("httpc_params") + if _cnf: + self.httpc_params = get_http_params(_cnf) + else: # Backward compatibility + self.httpc_params = {"verify": conf.get("verify_ssl")} + + self.set_scopes_handler() + self.dev_auth_db = None + self.claims_interface = None + + def set_scopes_handler(self): + _spec = self.conf.get("scopes_handler") + if _spec: + _kwargs = _spec.get("kwargs", {}) + _cls = importer(_spec["class"])(**_kwargs) + self.scopes_handler = _cls(_kwargs) + else: + self.scopes_handler = Scopes() + + def do_add_on(self, endpoints): + if self.conf.get("add_on"): + for spec in self.conf["add_on"].values(): + if isinstance(spec["function"], str): + _func = importer(spec["function"]) + else: + _func = spec["function"] + _func(endpoints, **spec["kwargs"]) + + def do_login_hint2acrs(self): + _conf = self.conf.get("login_hint2acrs") + + if _conf: + self.login_hint2acrs = init_service(_conf) + else: + self.login_hint2acrs = None + + def do_login_hint_lookup(self): + _conf = self.conf.get("login_hint_lookup") + if _conf: + _userinfo = None + _kwargs = _conf.get("kwargs") + if _kwargs: + _userinfo_conf = _kwargs.get("userinfo") + if _userinfo_conf: + _userinfo = init_user_info(_userinfo_conf, self.cwd) + + if _userinfo is None: + _userinfo = self.userinfo + + self.login_hint_lookup = init_service(_conf) + self.login_hint_lookup.userinfo = _userinfo + + def do_userinfo(self): + _conf = self.conf.get("userinfo") + if _conf: + if self.session_manager: + self.userinfo = init_user_info(_conf, self.cwd) + self.session_manager.userinfo = self.userinfo + else: + logger.warning("Cannot init_user_info if no session manager was provided.") + + def do_cookie_dealer(self): + _conf = self.conf.get("cookie_dealer") + if _conf: + if not self.cookie_dealer: + self.cookie_dealer = init_service(_conf) + + def do_sub_func(self) -> None: + """ + Loads functions that creates subject "sub" values + + :return: string + """ + _conf = self.conf.get("sub_func", {}) + for key, args in _conf.items(): + if "class" in args: + self._sub_func[key] = init_service(args) + elif "function" in args: + if isinstance(args["function"], str): + self._sub_func[key] = importer(args["function"]) + else: + self._sub_func[key] = args["function"] + + def create_providerinfo(self, capabilities): + """ + Dynamically create the provider info response + + :param capabilities: + :return: + """ + + _provider_info = capabilities + _provider_info["issuer"] = self.issuer + _provider_info["version"] = "3.0" + + # acr_values + if self.authn_broker: + acr_values = self.authn_broker.get_acr_values() + if acr_values is not None: + _provider_info["acr_values_supported"] = acr_values + + if self.jwks_uri and self.keyjar: + _provider_info["jwks_uri"] = self.jwks_uri + + _provider_info.update(self.idtoken.provider_info) + if "scopes_supported" not in _provider_info: + _provider_info["scopes_supported"] = [s for s in self.scope2claims.keys()] + if "claims_supported" not in _provider_info: + _provider_info["claims_supported"] = STANDARD_CLAIMS[:] + + return _provider_info diff --git a/src/oidcop/exception.py b/src/oidcop/exception.py new file mode 100755 index 00000000..8ab680da --- /dev/null +++ b/src/oidcop/exception.py @@ -0,0 +1,99 @@ +class OidcEndpointError(Exception): + pass + + +class InvalidRedirectURIError(OidcEndpointError): + pass + + +class InvalidSectorIdentifier(OidcEndpointError): + pass + + +class ConfigurationError(OidcEndpointError): + pass + + +class NoSuchAuthentication(OidcEndpointError): + pass + + +class TamperAllert(OidcEndpointError): + pass + + +class ToOld(OidcEndpointError): + pass + + +class MultipleUsage(OidcEndpointError): + pass + + +class FailedAuthentication(OidcEndpointError): + pass + + +class InstantiationError(OidcEndpointError): + pass + + +class ImproperlyConfigured(OidcEndpointError): + pass + + +class NotForMe(OidcEndpointError): + pass + + +class UnknownAssertionType(OidcEndpointError): + pass + + +class RedirectURIError(OidcEndpointError): + pass + + +class UnknownClient(OidcEndpointError): + pass + + +class InvalidClient(OidcEndpointError): + pass + + +class UnAuthorizedClient(OidcEndpointError): + pass + + +class UnAuthorizedClientScope(OidcEndpointError): + pass + + +class InvalidCookieSign(Exception): + pass + + +class OnlyForTestingWarning(Warning): + "Warned when using a feature that only should be used for testing." + pass + + +class ProcessError(OidcEndpointError): + pass + + +class ServiceError(OidcEndpointError): + pass + + +class InvalidRequest(OidcEndpointError): + pass + + +class CapabilitiesMisMatch(OidcEndpointError): + pass + + +class MultipleCodeUsage(OidcEndpointError): + pass diff --git a/src/oidcop/id_token.py b/src/oidcop/id_token.py new file mode 100755 index 00000000..8cf2e5d7 --- /dev/null +++ b/src/oidcop/id_token.py @@ -0,0 +1,280 @@ +import logging +import uuid + +from cryptojwt.jws.utils import left_hash +from cryptojwt.jwt import JWT + +from oidcop.construct import construct_endpoint_info +from oidcop.session import unpack_session_key +from oidcop.session.claims import claims_match +from oidcop.session.info import SessionInfo + +logger = logging.getLogger(__name__) + +DEF_SIGN_ALG = { + "id_token": "RS256", + "userinfo": "RS256", + "request_object": "RS256", + "client_secret_jwt": "HS256", + "private_key_jwt": "RS256", +} +DEF_LIFETIME = 300 + + +def include_session_id(endpoint_context, client_id, where): + """ + + :param endpoint_context: + :param client_id: + :param where: front or back + :return: + """ + _pinfo = endpoint_context.provider_info + + # Am the OP supposed to support {dir}-channel log out and if so can + # it pass sid in logout token and ID Token + for param in ["{}channel_logout_supported", "{}channel_logout_session_supported"]: + try: + _supported = _pinfo[param.format(where)] + except KeyError: + return False + else: + if not _supported: + return False + + # Does the client support back-channel logout ? + try: + _val = endpoint_context.cdb[client_id]["{}channel_logout_uri".format(where)] + except KeyError: + return False + + return True + + +def get_sign_and_encrypt_algorithms( + endpoint_context, client_info, payload_type, sign=False, encrypt=False): + args = {"sign": sign, "encrypt": encrypt} + if sign: + try: + args["sign_alg"] = client_info[ + "{}_signed_response_alg".format(payload_type) + ] + except KeyError: # Fall back to default + try: + args["sign_alg"] = endpoint_context.jwx_def["signing_alg"][payload_type] + except KeyError: + _def_sign_alg = DEF_SIGN_ALG[payload_type] + _supported = endpoint_context.provider_info.get( + "{}_signing_alg_values_supported".format(payload_type) + ) + + if not _supported: + args["sign_alg"] = _def_sign_alg + else: + if _def_sign_alg in _supported: + args["sign_alg"] = _def_sign_alg + else: + args["sign_alg"] = _supported[0] + + if encrypt: + try: + args["enc_alg"] = client_info["%s_encrypted_response_alg" % payload_type] + except KeyError: + try: + args["enc_alg"] = endpoint_context.jwx_def["encryption_alg"][ + payload_type + ] + except KeyError: + _supported = endpoint_context.provider_info.get( + "{}_encryption_alg_values_supported".format(payload_type) + ) + if _supported: + args["enc_alg"] = _supported[0] + + try: + args["enc_enc"] = client_info["%s_encrypted_response_enc" % payload_type] + except KeyError: + try: + args["enc_enc"] = endpoint_context.jwx_def["encryption_enc"][ + payload_type + ] + except KeyError: + _supported = endpoint_context.provider_info.get( + "{}_encryption_enc_values_supported".format(payload_type) + ) + if _supported: + args["enc_enc"] = _supported[0] + + return args + + +class IDToken(object): + default_capabilities = { + "id_token_signing_alg_values_supported": None, + "id_token_encryption_alg_values_supported": None, + "id_token_encryption_enc_values_supported": None, + } + + def __init__(self, server_get, **kwargs): + self.server_get = server_get + self.kwargs = kwargs + self.scope_to_claims = None + self.provider_info = construct_endpoint_info(self.default_capabilities, **kwargs + ) + + def payload( + self, + session_id, + alg="RS256", + code=None, + access_token=None, + extra_claims=None, + ): + """ + + :param session_id: Session identifier + :param alg: Which signing algorithm to use for the IdToken + :param code: Access grant + :param access_token: Access Token + :param extra_claims: extra claims to be added to the ID Token + :return: IDToken instance + """ + + _context = self.server_get("endpoint_context") + _mngr = _context.session_manager + session_information = _mngr.get_session_info(session_id, grant=True) + grant = session_information["grant"] + _args = {"sub": grant.sub} + if grant.authentication_event: + for claim, attr in {"authn_time": "auth_time", "authn_info": "acr"}.items(): + _val = grant.authentication_event.get(claim) + if _val: + _args[attr] = _val + + _claims_restriction = grant.claims.get("id_token") + if _claims_restriction == {}: + user_info = None + else: + user_info = _context.claims_interface.get_user_claims( + user_id=session_information["user_id"], + claims_restriction=_claims_restriction) + if _claims_restriction and "acr" in _claims_restriction and "acr" in _args: + if claims_match(_args["acr"], _claims_restriction["acr"]) is False: + raise ValueError("Could not match expected 'acr'") + + if user_info: + try: + user_info = user_info.to_dict() + except AttributeError: + pass + + # Make sure that there are no name clashes + for key in ["iss", "sub", "aud", "exp", "acr", "nonce", "auth_time"]: + try: + del user_info[key] + except KeyError: + pass + + _args.update(user_info) + + if extra_claims is not None: + _args.update(extra_claims) + + # Left hashes of code and/or access_token + halg = "HS%s" % alg[-3:] + if code: + _args["c_hash"] = left_hash(code.encode("utf-8"), halg) + if access_token: + _args["at_hash"] = left_hash(access_token.encode("utf-8"), halg) + + authn_req = grant.authorization_request + if authn_req: + try: + _args["nonce"] = authn_req["nonce"] + except KeyError: + pass + + return _args + + def sign_encrypt( + self, + session_id, + client_id, + code=None, + access_token=None, + sign=True, + encrypt=False, + lifetime=None, + extra_claims=None, + ): + """ + Signed and or encrypt a IDToken + + :param lifetime: How long the ID Token should be valid + :param session_id: Session information + :param client_id: Client ID + :param code: Access grant + :param access_token: Access Token + :param sign: If the JWT should be signed + :param encrypt: If the JWT should be encrypted + :param extra_claims: Extra claims to be added to the ID Token + :return: IDToken as a signed and/or encrypted JWT + """ + + _context = self.server_get("endpoint_context") + + client_info = _context.cdb[client_id] + alg_dict = get_sign_and_encrypt_algorithms( + _context, client_info, "id_token", sign=sign, encrypt=encrypt + ) + + _payload = self.payload( + session_id=session_id, + alg=alg_dict["sign_alg"], + code=code, + access_token=access_token, + extra_claims=extra_claims + ) + + if lifetime is None: + lifetime = DEF_LIFETIME + + _jwt = JWT( + _context.keyjar, iss=_context.issuer, lifetime=lifetime, **alg_dict + ) + + return _jwt.pack(_payload, recv=client_id) + + def make(self, session_id, **kwargs): + _context = self.server_get("endpoint_context") + + user_id, client_id, grant_id = unpack_session_key(session_id) + + # Should I add session ID. This is about Single Logout. + if include_session_id(_context, client_id, "back") or include_session_id( + _context, client_id, "front"): + + # Note that this session ID is not the session ID the session manager is using. + # It must be possible to map from one to the other. + logout_session_id = uuid.uuid4().get_hex() + _item = SessionInfo() + _item.user_id = user_id + _item.client_id = client_id + # Store the map + _mngr = _context.session_manager + _mngr.set([logout_session_id], _item) + # add identifier to extra arguments + xargs = {"sid": logout_session_id} + else: + xargs = {} + + lifetime = self.kwargs.get("lifetime") + + return self.sign_encrypt( + session_id, + client_id, + sign=True, + lifetime=lifetime, + extra_claims=xargs, + **kwargs + ) diff --git a/src/oidcop/login_hint.py b/src/oidcop/login_hint.py new file mode 100644 index 00000000..209f3e81 --- /dev/null +++ b/src/oidcop/login_hint.py @@ -0,0 +1,30 @@ +from urllib.parse import urlparse + + +class LoginHintLookup(object): + def __init__(self, userinfo=None, server_get=None): + self.userinfo = userinfo + self.default_country_code = "46" + self.server_get = server_get + + def __call__(self, arg): + if arg.startswith("tel:"): + _pnr = arg[4:] + if _pnr[0] == "+": + pass + else: + _pnr = "+" + self.default_country_code + _pnr[1:] + return self.userinfo.search(phone_number=_pnr) + + +class LoginHint2Acrs(object): + def __init__(self, scheme_map, server_get=None): + self.scheme_map = scheme_map + self.server_get = server_get + + def __call__(self, hint): + p = urlparse(hint) + try: + return self.scheme_map[p.scheme] + except KeyError: + return [] diff --git a/src/oidcop/oauth2/__init__.py b/src/oidcop/oauth2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/oidcop/oauth2/add_on/__init__.py b/src/oidcop/oauth2/add_on/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/oidcop/oauth2/add_on/device_authorization.py b/src/oidcop/oauth2/add_on/device_authorization.py new file mode 100644 index 00000000..ee707ea8 --- /dev/null +++ b/src/oidcop/oauth2/add_on/device_authorization.py @@ -0,0 +1,7 @@ + +def add_support(endpoint, **kwargs): + _context = endpoint["token"].endpoint_context + + _db = kwargs.get("db") + if not _db: + _context.dev_auth_db = {} diff --git a/src/oidcop/oauth2/add_on/dpop.py b/src/oidcop/oauth2/add_on/dpop.py new file mode 100644 index 00000000..c9305f9a --- /dev/null +++ b/src/oidcop/oauth2/add_on/dpop.py @@ -0,0 +1,168 @@ +from typing import Optional + +from cryptojwt import JWS +from cryptojwt.jwk.jwk import key_from_jwk_dict +from cryptojwt.jws.jws import factory +from oidcmsg.message import Message +from oidcmsg.message import SINGLE_REQUIRED_INT +from oidcmsg.message import SINGLE_REQUIRED_JSON +from oidcmsg.message import SINGLE_REQUIRED_STRING + +from oidcop.client_authn import AuthnFailure +from oidcop.client_authn import ClientAuthnMethod +from oidcop.client_authn import basic_authn + + +class DPoPProof(Message): + c_param = { + # header + "typ": SINGLE_REQUIRED_STRING, + "alg": SINGLE_REQUIRED_STRING, + "jwk": SINGLE_REQUIRED_JSON, + # body + "jti": SINGLE_REQUIRED_STRING, + "htm": SINGLE_REQUIRED_STRING, + "htu": SINGLE_REQUIRED_STRING, + "iat": SINGLE_REQUIRED_INT + } + header_params = {"typ", "alg", "jwk"} + body_params = {"jti", "htm", "htu", "iat"} + + def __init__(self, set_defaults=True, **kwargs): + self.key = None + Message.__init__(self, set_defaults=set_defaults, **kwargs) + + if self.key: + pass + elif "jwk" in self: + self.key = key_from_jwk_dict(self["jwk"]) + self.key.deserialize() + + def from_dict(self, dictionary, **kwargs): + Message.from_dict(self, dictionary, **kwargs) + + if "jwk" in self: + self.key = key_from_jwk_dict(self["jwk"]) + self.key.deserialize() + + return self + + def verify(self, **kwargs): + Message.verify(self, **kwargs) + if self["typ"] != "dpop+jwt": + raise ValueError("Wrong type") + if self["alg"] == "none": + raise ValueError("'none' is not allowed as signing algorithm") + + def create_header(self) -> str: + payload = {k: self[k] for k in self.body_params} + _jws = JWS(payload, alg=self["alg"]) + _headers = {k: self[k] for k in self.header_params} + _sjwt = _jws.sign_compact(keys=[self.key], **_headers) + return _sjwt + + def verify_header(self, dpop_header) -> Optional["DPoPProof"]: + _jws = factory(dpop_header) + if _jws: + _jwt = _jws.jwt + if "jwk" in _jwt.headers: + _pub_key = key_from_jwk_dict(_jwt.headers["jwk"]) + _pub_key.deserialize() + _info = _jws.verify_compact(keys=[_pub_key], sigalg=_jwt.headers["alg"]) + for k, v in _jwt.headers.items(): + self[k] = v + + for k, v in _info.items(): + self[k] = v + else: + raise Exception() + + return self + else: + return None + + +def post_parse_request(request, client_id, endpoint_context, **kwargs): + """ + Expect http_info attribute in kwargs. http_info should be a dictionary + containing HTTP information. + + :param request: + :param client_id: + :param endpoint_context: + :param kwargs: + :return: + """ + + _http_info = kwargs.get("http_info") + if not _http_info: + return request + + _dpop = DPoPProof().verify_header(_http_info["headers"]["dpop"]) + + # The signature of the JWS is verified, now for checking the + # content + + if _dpop["htu"] != _http_info["url"]: + raise ValueError("htu in DPoP does not match the HTTP URI") + + if _dpop["htm"] != _http_info["method"]: + raise ValueError("htm in DPoP does not match the HTTP method") + + if not _dpop.key: + _dpop.key = key_from_jwk_dict(_dpop["jwk"]) + + _jkt = str(_dpop.key.thumbprint("SHA-256")) + try: + endpoint_context.cdb[client_id]["dpop_jkt"][_jkt] = _dpop.key + except KeyError: + endpoint_context.cdb[client_id]["dpop_jkt"] = {_jkt: _dpop.key} + + # Need something I can add as a reference when minting tokens + request["dpop_jkt"] = _jkt + return request + + +def token_args(endpoint_context, client_id, token_args: Optional[dict] = None): + if "dpop.jkt" in endpoint_context.cdb[client_id]: + token_args.update({"cnf": {"jkt": endpoint_context.cdb[client_id]["dpop_jkt"]}}) + + return token_args + + +def add_support(endpoint, **kwargs): + # + _endp = endpoint["token"] + _endp.post_parse_request.append(post_parse_request) + + # Endpoint Context stuff + # _endp.endpoint_context.token_args_methods.append(token_args) + _algs_supported = kwargs.get("dpop_signing_alg_values_supported") + if not _algs_supported: + _algs_supported = ["RS256"] + + _endp.server_get( + "endpoint_context").provider_info["dpop_signing_alg_values_supported"] = _algs_supported + + # Other endpoint this may come in handy + + +# DPoP-bound access token in the "Authorization" header and the DPoP proof in the "DPoP" header + +class DPoPClientAuth(ClientAuthnMethod): + tag = "dpop_client_auth" + + def is_usable(self, request=None, authorization_info=None, http_headers=None): + if authorization_info is not None and authorization_info.startswith("DPoP "): + return True + return False + + def verify(self, authorization_info, **kwargs): + client_info = basic_authn(authorization_info) + _context= self.server_get("endpoint_context") + if _context.cdb[client_info["id"]]["client_secret"] == client_info["secret"]: + return {"client_id": client_info["id"]} + else: + raise AuthnFailure() + + diff --git a/src/oidcop/oauth2/add_on/dpop_token.py b/src/oidcop/oauth2/add_on/dpop_token.py new file mode 100644 index 00000000..0917759a --- /dev/null +++ b/src/oidcop/oauth2/add_on/dpop_token.py @@ -0,0 +1,188 @@ +import logging +from typing import Union + +from cryptojwt.jwe.exception import JWEException +from cryptojwt.jws.exception import NoSuitableSigningKeys +from cryptojwt.jwt import utc_time_sans_frac +from oidcop.oidc.token import RefreshTokenHelper +from oidcmsg.message import Message + +from oidcop.oidc.token import AccessTokenHelper + +logger = logging.getLogger(__name__) + + +class DPOPAccessTokenHelper(AccessTokenHelper): + def process_request(self, req: Union[Message, dict], **kwargs): + """ + + :param req: + :param kwargs: + :return: + """ + _context = self.endpoint.server_get("endpoint_context") + + _mngr = _context.session_manager + _log_debug = logger.debug + + if req["grant_type"] != "authorization_code": + return self.error_cls( + error="invalid_request", error_description="Unknown grant_type" + ) + + try: + _access_code = req["code"].replace(" ", "+") + except KeyError: # Missing code parameter - absolutely fatal + return self.error_cls( + error="invalid_request", error_description="Missing code" + ) + + _session_info = _mngr.get_session_info_by_token(_access_code, grant=True) + grant = _session_info["grant"] + + code = grant.get_token(_access_code) + _authn_req = grant.authorization_request + + # If redirect_uri was in the initial authorization request + # verify that the one given here is the correct one. + if "redirect_uri" in _authn_req: + if req["redirect_uri"] != _authn_req["redirect_uri"]: + return self.error_cls( + error="invalid_request", error_description="redirect_uri mismatch" + ) + + _log_debug("All checks OK") + + issue_refresh = False + if "issue_refresh" in kwargs: + issue_refresh = kwargs["issue_refresh"] + else: + if "offline_access" in grant.scope: + issue_refresh = True + + _response = { + "token_type": "Bearer", + "scope": grant.scope, + } + + if "dpop_jkt" in req: + token_args = {"cnf": {"jkt": req["dpop_jkt"]}} + else: + token_args = {} + + token = self._mint_token(type="access_token", + grant=grant, + session_id=_session_info["session_id"], + client_id=_session_info["client_id"], + based_on=code, + token_args=token_args) + if "dpop_jkt" in req: + if token.extension is None: + token.extension = {"dpop_jkt": req["dpop_jkt"]} + else: + token.extension["dpop_jkt"] = req["dpop_jkt"] + + _response["access_token"] = token.value + _response["expires_in"] = token.expires_at - utc_time_sans_frac() + + if issue_refresh: + refresh_token = self._mint_token(type="refresh_token", + grant=grant, + session_id=_session_info["session_id"], + client_id=_session_info["client_id"], + based_on=code) + if "dpop_jkt" in req: + if refresh_token.extension is None: + refresh_token.extension = {"dpop_jkt": req["dpop_jkt"]} + else: + refresh_token.extension["dpop_jkt"] = req["dpop_jkt"] + + _response["refresh_token"] = refresh_token.value + + code.register_usage() + + # since the grant content has changed. Make sure it's stored + _mngr[_session_info["session_id"]] = grant + + if "openid" in _authn_req["scope"]: + try: + _idtoken = _context.idtoken.make(_session_info["session_id"]) + except (JWEException, NoSuitableSigningKeys) as err: + logger.warning(str(err)) + resp = self.error_cls( + error="invalid_request", + error_description="Could not sign/encrypt id_token", + ) + return resp + + _response["id_token"] = _idtoken + + return _response + + +class DPOPRefreshTokenHelper(RefreshTokenHelper): + def process_request(self, req: Union[Message, dict], **kwargs): + _context = self.endpoint.server_get("endpoint_context") + _mngr = _context.session_manager + + if req["grant_type"] != "refresh_token": + return self.error_cls( + error="invalid_request", error_description="Wrong grant_type" + ) + + token_value = req["refresh_token"] + _session_info = _mngr.get_session_info_by_token(token_value, grant=True) + token = _mngr.find_token(_session_info["session_id"], token_value) + + _grant = _session_info["grant"] + access_token = self._mint_token(type="access_token", + grant=_grant, + session_id=_session_info["session_id"], + client_id=_session_info["client_id"], + based_on=token) + + if "dpop_jkt" in req: + if access_token.extension is None: + access_token.extension = {"dpop_jkt": req["dpop_jkt"]} + else: + access_token.extension["dpop_jkt"] = req["dpop_jkt"] + + _resp = { + "access_token": access_token.value, + "token_type": access_token.token_type, + "scope": _grant.scope + } + + if access_token.expires_at: + _resp["expires_in"] = access_token.expires_at - utc_time_sans_frac() + + _mints = token.usage_rules.get("supports_minting") + if "refresh_token" in _mints: + refresh_token = self._mint_token(type="refresh_token", + grant=_grant, + session_id=_session_info["session_id"], + client_id=_session_info["client_id"], + based_on=token) + refresh_token.usage_rules = token.usage_rules.copy() + if "dpop_jkt" in req: + if refresh_token.extension is None: + refresh_token.extension = {"dpop_jkt": req["dpop_jkt"]} + else: + refresh_token.extension["dpop_jkt"] = req["dpop_jkt"] + + _resp["refresh_token"] = refresh_token.value + + if "id_token" in _mints: + try: + _idtoken = _context.idtoken.make(_session_info["session_id"]) + except (JWEException, NoSuitableSigningKeys) as err: + logger.warning(str(err)) + resp = self.error_cls( + error="invalid_request", + error_description="Could not sign/encrypt id_token", + ) + return resp + + _resp["id_token"] = _idtoken + + return _resp diff --git a/src/oidcop/oauth2/authorization.py b/src/oidcop/oauth2/authorization.py new file mode 100755 index 00000000..b3aadfe6 --- /dev/null +++ b/src/oidcop/oauth2/authorization.py @@ -0,0 +1,1013 @@ +import json +import logging +from typing import Union +from urllib.parse import unquote +from urllib.parse import urlencode +from urllib.parse import urlparse + +from cryptojwt import BadSyntax +from cryptojwt import as_unicode +from cryptojwt import b64d +from cryptojwt.jwe.exception import JWEException +from cryptojwt.jws.exception import NoSuitableSigningKeys +from cryptojwt.utils import as_bytes +from cryptojwt.utils import b64e +from oidcmsg import oauth2 +from oidcmsg.exception import ParameterError +from oidcmsg.exception import URIError +from oidcmsg.message import Message +from oidcmsg.oidc import verified_claim_name +from oidcmsg.time_util import utc_time_sans_frac + +from oidcop import rndstr +from oidcop.authn_event import create_authn_event +from oidcop.cookie import append_cookie +from oidcop.cookie import compute_session_state +from oidcop.cookie import new_cookie +from oidcop.endpoint import Endpoint +from oidcop.exception import InvalidRequest +from oidcop.exception import NoSuchAuthentication +from oidcop.exception import RedirectURIError +from oidcop.exception import ServiceError +from oidcop.exception import TamperAllert +from oidcop.exception import ToOld +from oidcop.exception import UnAuthorizedClientScope +from oidcop.exception import UnknownClient +from oidcop.session import Revoked +from oidcop.session import unpack_session_key +from oidcop.token.exception import UnknownToken +from oidcop.user_authn.authn_context import pick_auth +from oidcop.util import split_uri + +logger = logging.getLogger(__name__) + +# For the time being. This is JAR specific and should probably be configurable. +ALG_PARAMS = { + "sign": [ + "request_object_signing_alg", + "request_object_signing_alg_values_supported", + ], + "enc_alg": [ + "request_object_encryption_alg", + "request_object_encryption_alg_values_supported", + ], + "enc_enc": [ + "request_object_encryption_enc", + "request_object_encryption_enc_values_supported", + ], +} + +FORM_POST = """ + + Submit This Form + + +
+ {inputs} +
+ +""" + + +def inputs(form_args): + """ + Creates list of input elements + """ + element = [] + html_field = '' + for name, value in form_args.items(): + element.append(html_field.format(name, value)) + return "\n".join(element) + + +def max_age(request): + verified_request = verified_claim_name("request") + return request.get(verified_request, {}).get("max_age") or request.get("max_age", 0) + + +def verify_uri(endpoint_context, request, uri_type, client_id=None): + """ + A redirect URI + MUST NOT contain a fragment + MAY contain query component + + :param endpoint_context: An EndpointContext instance + :param request: The authorization request + :param uri_type: redirect_uri or post_logout_redirect_uri + :return: An error response if the redirect URI is faulty otherwise + None + """ + _cid = request.get("client_id", client_id) + + if not _cid: + logger.error("No client id found") + raise UnknownClient("No client_id provided") + else: + logger.debug("Client ID: {}".format(_cid)) + + _redirect_uri = unquote(request[uri_type]) + + part = urlparse(_redirect_uri) + if part.fragment: + raise URIError("Contains fragment") + + (_base, _query) = split_uri(_redirect_uri) + # if _query: + # _query = parse_qs(_query) + + match = False + # Get the clients registered redirect uris + client_info = endpoint_context.cdb.get(_cid, {}) + if not client_info: + raise KeyError("No such client") + logger.debug("Client info: {}".format(client_info)) + redirect_uris = client_info.get("{}s".format(uri_type)) + if not redirect_uris: + if _cid not in endpoint_context.cdb: + logger.debug("CIDs: {}".format(list(endpoint_context.cdb.keys()))) + raise KeyError("No such client") + raise ValueError("No registered {}".format(uri_type)) + else: + for regbase, rquery in redirect_uris: + # The URI MUST exactly match one of the Redirection URI + if _base == regbase: + # every registered query component must exist in the uri + if rquery: + if not _query: + raise ValueError("Missing query part") + + for key, vals in rquery.items(): + if key not in _query: + raise ValueError('"{}" not in query part'.format(key)) + + for val in vals: + if val not in _query[key]: + raise ValueError( + "{}={} value not in query part".format(key, val) + ) + + # and vice versa, every query component in the uri + # must be registered + if _query: + if not rquery: + raise ValueError("No registered query part") + + for key, vals in _query.items(): + if key not in rquery: + raise ValueError('"{}" extra in query part'.format(key)) + for val in vals: + if val not in rquery[key]: + raise ValueError( + "Extra {}={} value in query part".format(key, val) + ) + match = True + break + if not match: + raise RedirectURIError("Doesn't match any registered uris") + + +def join_query(base, query): + """ + + :param base: URL base + :param query: query part as a dictionary + :return: + """ + if query: + return "{}?{}".format(base, urlencode(query, doseq=True)) + else: + return base + + +def get_uri(endpoint_context, request, uri_type): + """ verify that the redirect URI is reasonable. + + :param endpoint_context: An EndpointContext instance + :param request: The Authorization request + :param uri_type: 'redirect_uri' or 'post_logout_redirect_uri' + :return: redirect_uri + """ + uri = "" + + if uri_type in request: + verify_uri(endpoint_context, request, uri_type) + uri = request[uri_type] + else: + uris = "{}s".format(uri_type) + client_id = str(request["client_id"]) + if client_id in endpoint_context.cdb: + _specs = endpoint_context.cdb[client_id].get(uris) + if not _specs: + raise ParameterError("Missing {} and none registered".format(uri_type)) + + if len(_specs) > 1: + raise ParameterError( + "Missing {} and more than one registered".format(uri_type) + ) + + uri = join_query(*_specs[0]) + + return uri + + +def authn_args_gather(request, authn_class_ref, cinfo, **kwargs): + """ + Gather information to be used by the authentication method + + :param request: The request either as a dictionary or as a Message instance + :param authn_class_ref: Authentication class reference + :param cinfo: Client information + :param kwargs: Extra keyword arguments + :return: Authentication arguments + """ + authn_args = { + "authn_class_ref": authn_class_ref, + "return_uri": request["redirect_uri"], + } + + if isinstance(request, Message): + authn_args["query"] = request.to_urlencoded() + elif isinstance(request, dict): + authn_args["query"] = urlencode(request) + else: + ValueError("Wrong request format") + + if "req_user" in kwargs: + authn_args["as_user"] = (kwargs["req_user"],) + + # Below are OIDC specific. Just ignore if OAuth2 + if cinfo: + for attr in ["policy_uri", "logo_uri", "tos_uri"]: + if cinfo.get(attr): + authn_args[attr] = cinfo[attr] + + for attr in ["ui_locales", "acr_values", "login_hint"]: + if request.get(attr): + authn_args[attr] = request[attr] + + return authn_args + + +def check_unknown_scopes_policy(request_info, cinfo, endpoint_context): + op_capabilities = endpoint_context.conf['capabilities'] + client_allowed_scopes = cinfo.get('allowed_scopes') or \ + op_capabilities['scopes_supported'] + + # this prevents that authz would be released for unavailable scopes + for scope in request_info['scope']: + if op_capabilities.get('deny_unknown_scopes') and \ + scope not in client_allowed_scopes: + _msg = '{} requested an unauthorized scope ({})' + logger.warning(_msg.format(cinfo['client_id'], + scope)) + raise UnAuthorizedClientScope() + + +class Authorization(Endpoint): + request_cls = oauth2.AuthorizationRequest + response_cls = oauth2.AuthorizationResponse + error_cls = oauth2.AuthorizationErrorResponse + request_format = "urlencoded" + response_format = "urlencoded" + response_placement = "url" + endpoint_name = "authorization_endpoint" + name = "authorization" + default_capabilities = { + "claims_parameter_supported": True, + "request_parameter_supported": True, + "request_uri_parameter_supported": True, + "response_types_supported": ["code", "token", "code token"], + "response_modes_supported": ["query", "fragment", "form_post"], + "request_object_signing_alg_values_supported": None, + "request_object_encryption_alg_values_supported": None, + "request_object_encryption_enc_values_supported": None, + "grant_types_supported": ["authorization_code", "implicit"], + "scopes_supported": [], + } + + def __init__(self, server_get, **kwargs): + Endpoint.__init__(self, server_get, **kwargs) + self.post_parse_request.append(self._do_request_uri) + self.post_parse_request.append(self._post_parse_request) + self.allowed_request_algorithms = AllowedAlgorithms(ALG_PARAMS) + + def filter_request(self, endpoint_context, req): + return req + + def extra_response_args(self, aresp): + return aresp + + def verify_response_type(self, request, cinfo): + # Checking response types + _registered = [set(rt.split(" ")) for rt in cinfo.get("response_types", [])] + if not _registered: + # If no response_type is registered by the client then we'll + # use code. + _registered = [{"code"}] + + # Is the asked for response_type among those that are permitted + return set(request["response_type"]) in _registered + + def mint_token(self, token_type, grant, session_id, based_on=None): + _mngr = self.server_get("endpoint_context").session_manager + usage_rules = grant.usage_rules.get(token_type, {}) + + token = grant.mint_token( + session_id=session_id, + endpoint_context=self.server_get("endpoint_context"), + token_type=token_type, + based_on=based_on, + usage_rules=usage_rules + ) + + _exp_in = usage_rules.get("expires_in") + if isinstance(_exp_in, str): + _exp_in = int(_exp_in) + if _exp_in: + token.expires_at = utc_time_sans_frac() + _exp_in + + self.server_get("endpoint_context").session_manager.set(unpack_session_key(session_id), + grant) + + return token + + def _do_request_uri(self, request, client_id, endpoint_context, **kwargs): + _request_uri = request.get("request_uri") + if _request_uri: + # Do I do pushed authorization requests ? + _endp = self.server_get("endpoint", 'pushed_authorization') + if _endp: + # Is it a UUID urn + if _request_uri.startswith("urn:uuid:"): + _req = endpoint_context.par_db.get(_request_uri) + if _req: + del endpoint_context.par_db[_request_uri] # One time usage + return _req + else: + raise ValueError("Got a request_uri I can not resolve") + + # Do I support request_uri ? + _supported = endpoint_context.provider_info.get( + "request_uri_parameter_supported", True + ) + _registered = endpoint_context.cdb[client_id].get("request_uris") + # Not registered should be handled else where + if _registered: + # Before matching remove a possible fragment + _p = _request_uri.split("#") + # ignore registered fragments for now. + if _p[0] not in [l[0] for l in _registered]: + raise ValueError("A request_uri outside the registered") + + # Fetch the request + _resp = endpoint_context.httpc.get( + _request_uri, **endpoint_context.httpc_params + ) + if _resp.status_code == 200: + args = {"keyjar": endpoint_context.keyjar, "issuer": client_id} + _ver_request = self.request_cls().from_jwt(_resp.text, **args) + self.allowed_request_algorithms( + client_id, + endpoint_context, + _ver_request.jws_header.get("alg", "RS256"), + "sign", + ) + if _ver_request.jwe_header is not None: + self.allowed_request_algorithms( + client_id, + endpoint_context, + _ver_request.jws_header.get("alg"), + "enc_alg", + ) + self.allowed_request_algorithms( + client_id, + endpoint_context, + _ver_request.jws_header.get("enc"), + "enc_enc", + ) + # The protected info overwrites the non-protected + for k, v in _ver_request.items(): + request[k] = v + + request[verified_claim_name("request")] = _ver_request + else: + raise ServiceError("Got a %s response", _resp.status) + + return request + + def _post_parse_request(self, request, client_id, endpoint_context, **kwargs): + """ + Verify the authorization request. + + :param endpoint_context: + :param request: + :param client_id: + :param kwargs: + :return: + """ + if not request: + logger.debug("No AuthzRequest") + return self.error_cls( + error="invalid_request", error_description="Can not parse AuthzRequest" + ) + + request = self.filter_request(endpoint_context, request) + + _cinfo = endpoint_context.cdb.get(client_id) + if not _cinfo: + logger.error( + "Client ID ({}) not in client database".format(request["client_id"]) + ) + return self.error_cls( + error="unauthorized_client", error_description="unknown client" + ) + + # Is the asked for response_type among those that are permitted + if not self.verify_response_type(request, _cinfo): + return self.error_cls( + error="invalid_request", + error_description="Trying to use unregistered response_type", + ) + + # Get a verified redirect URI + try: + redirect_uri = get_uri(endpoint_context, request, "redirect_uri") + except (RedirectURIError, ParameterError, UnknownClient) as err: + return self.error_cls( + error="invalid_request", + error_description="{}:{}".format(err.__class__.__name__, err), + ) + else: + request["redirect_uri"] = redirect_uri + + return request + + def pick_authn_method(self, request, redirect_uri, acr=None, **kwargs): + _context = self.server_get("endpoint_context") + auth_id = kwargs.get("auth_method_id") + if auth_id: + return _context.authn_broker[auth_id] + + if acr: + res = _context.authn_broker.pick(acr) + else: + res = pick_auth(_context, request) + + if res: + return res + else: + return { + "error": "access_denied", + "error_description": "ACR I do not support", + "return_uri": redirect_uri, + "return_type": request["response_type"], + } + + def setup_auth(self, request, redirect_uri, cinfo, cookie, acr=None, **kwargs): + """ + + :param request: The authorization/authentication request + :param redirect_uri: + :param cinfo: client info + :param cookie: + :param acr: Default ACR, if nothing else is specified + :param kwargs: + :return: + """ + + res = self.pick_authn_method(request, redirect_uri, acr, **kwargs) + + authn = res["method"] + authn_class_ref = res["acr"] + + _context = self.server_get("endpoint_context") + try: + _auth_info = kwargs.get("authn", "") + if "upm_answer" in request and request["upm_answer"] == "true": + _max_age = 0 + else: + _max_age = max_age(request) + + identity, _ts = authn.authenticated_as( + cookie, authorization=_auth_info, max_age=_max_age + ) + except (NoSuchAuthentication, TamperAllert): + identity = None + _ts = 0 + except ToOld: + logger.info("Too old authentication") + identity = None + _ts = 0 + except UnknownToken: + logger.info("Unknown Token") + identity = None + _ts = 0 + else: + if identity: + try: # If identity['uid'] is in fact a base64 encoded JSON string + _id = b64d(as_bytes(identity["uid"])) + except BadSyntax: + pass + else: + identity = json.loads(as_unicode(_id)) + + try: + _csi = _context.session_manager[identity.get("sid")] + except Revoked: + identity = None + else: + if _csi.is_active() is False: + identity = None + + authn_args = authn_args_gather(request, authn_class_ref, cinfo, **kwargs) + _mngr = _context.session_manager + _session_id = "" + + # To authenticate or Not + if identity is None: # No! + logger.info("No active authentication") + logger.debug( + "Known clients: {}".format(list(_context.cdb.keys())) + ) + + if "prompt" in request and "none" in request["prompt"]: + # Need to authenticate but not allowed + return { + "error": "login_required", + "return_uri": redirect_uri, + "return_type": request["response_type"], + } + else: + return {"function": authn, "args": authn_args} + else: + logger.info("Active authentication") + if re_authenticate(request, authn): + # demand re-authentication + return {"function": authn, "args": authn_args} + else: + # I get back a dictionary + user = identity["uid"] + if "req_user" in kwargs: + if user != kwargs["req_user"]: + logger.debug("Wanted to be someone else!") + if "prompt" in request and "none" in request["prompt"]: + # Need to authenticate but not allowed + return { + "error": "login_required", + "return_uri": redirect_uri, + } + else: + return {"function": authn, "args": authn_args} + + if "sid" in identity: + _session_id = identity["sid"] + + # make sure the client is the same + _uid, _cid, _gid = unpack_session_key(_session_id) + if request["client_id"] != _cid: + return {"function": authn, "args": authn_args} + + grant = _mngr[_session_id] + if grant.is_active() is False: + return {"function": authn, "args": authn_args} + elif request != grant.authorization_request: + authn_event = _mngr.get_authentication_event(session_id=_session_id) + if authn_event.is_valid() is False: # if not valid, do new login + return {"function": authn, "args": authn_args} + + # create new grant + _session_id = _mngr.create_grant(authn_event=authn_event, + auth_req=request, + user_id=user, + client_id=request["client_id"]) + + if _session_id: + authn_event = _mngr.get_authentication_event(session_id=_session_id) + if authn_event.is_valid() is False: # if not valid, do new login + return {"function": authn, "args": authn_args} + else: + authn_event = create_authn_event( + identity["uid"], + authn_info=authn_class_ref, + time_stamp=_ts, + ) + _exp_in = authn.kwargs.get("expires_in") + if _exp_in and "valid_until" in authn_event: + authn_event["valid_until"] = utc_time_sans_frac() + _exp_in + + _token_usage_rules = _context.authz.usage_rules(request["client_id"]) + _session_id = _mngr.create_session(authn_event=authn_event, auth_req=request, + user_id=user, client_id=request["client_id"], + token_usage_rules=_token_usage_rules) + + return {"session_id": _session_id, "identity": identity, "user": user} + + def aresp_check(self, aresp, request): + return "" + + def response_mode(self, request, **kwargs): + resp_mode = request["response_mode"] + if resp_mode == "form_post": + msg = FORM_POST.format( + inputs=inputs(kwargs["response_args"].to_dict()), + action=kwargs["return_uri"], + ) + kwargs.update( + { + "response_msg": msg, + "content_type": "text/html", + "response_placement": "body", + } + ) + elif resp_mode == "fragment": + if "fragment_enc" in kwargs: + if not kwargs["fragment_enc"]: + # Can't be done + raise InvalidRequest("wrong response_mode") + else: + kwargs["fragment_enc"] = True + elif resp_mode == "query": + if "fragment_enc" in kwargs: + if kwargs["fragment_enc"]: + # Can't be done + raise InvalidRequest("wrong response_mode") + else: + raise InvalidRequest("Unknown response_mode") + return kwargs + + def error_response(self, response_info, error, error_description): + resp = self.error_cls( + error=error, error_description=str(error_description) + ) + response_info["response_args"] = resp + return response_info + + def create_authn_response(self, request: Union[dict, Message], sid: str) -> dict: + """ + + :param request: + :param sid: + :return: + """ + # create the response + aresp = self.response_cls() + if request.get("state"): + aresp["state"] = request["state"] + + if "response_type" in request and request["response_type"] == ["none"]: + fragment_enc = False + else: + _context = self.server_get("endpoint_context") + _mngr = _context.session_manager + _sinfo = _mngr.get_session_info(sid, grant=True) + + if request.get("scope"): + aresp["scope"] = request["scope"] + + rtype = set(request["response_type"][:]) + handled_response_type = [] + + fragment_enc = True + if len(rtype) == 1 and "code" in rtype: + fragment_enc = False + + grant = _sinfo["grant"] + + if "code" in request["response_type"]: + _code = self.mint_token( + token_type='authorization_code', + grant=grant, + session_id=_sinfo["session_id"]) + aresp["code"] = _code.value + handled_response_type.append("code") + else: + _code = None + + if "token" in rtype: + if _code: + based_on = _code + else: + based_on = None + + _access_token = self.mint_token(token_type="access_token", + grant=grant, + session_id=_sinfo["session_id"], + based_on=based_on) + aresp['access_token'] = _access_token.value + aresp['token_type'] = "Bearer" + if _access_token.expires_at: + aresp["expires_in"] = _access_token.expires_at - utc_time_sans_frac() + handled_response_type.append("token") + else: + _access_token = None + + if "id_token" in request["response_type"]: + kwargs = {} + if {"code", "id_token", "token"}.issubset(rtype): + kwargs = {"code": _code.value, "access_token": _access_token.value} + elif {"code", "id_token"}.issubset(rtype): + kwargs = {"code": _code.value} + elif {"id_token", "token"}.issubset(rtype): + kwargs = {"access_token": _access_token.value} + + try: + id_token = _context.idtoken.make(sid, **kwargs) + except (JWEException, NoSuitableSigningKeys) as err: + logger.warning(str(err)) + resp = self.error_cls( + error="invalid_request", + error_description="Could not sign/encrypt id_token", + ) + return {"response_args": resp, "fragment_enc": fragment_enc} + + aresp["id_token"] = id_token + _mngr.update([_sinfo["user_id"], _sinfo["client_id"]], + {"id_token": id_token}) + handled_response_type.append("id_token") + + not_handled = rtype.difference(handled_response_type) + if not_handled: + resp = self.error_cls( + error="invalid_request", error_description="unsupported_response_type" + ) + return {"response_args": resp, "fragment_enc": fragment_enc} + + aresp = self.extra_response_args(aresp) + + return {"response_args": aresp, "fragment_enc": fragment_enc} + + def post_authentication(self, request: Union[dict, Message], + session_id: str, **kwargs) -> dict: + """ + Things that are done after a successful authentication. + + :param request: The authorization request + :param session_id: Session identifier + :param kwargs: + :return: A dictionary with 'response_args' + """ + + response_info = {} + _context = self.server_get("endpoint_context") + _mngr = _context.session_manager + + # Do the authorization + try: + grant = _context.authz(session_id, request=request) + except ToOld as err: + return self.error_response( + response_info, + "access_denied", + "Authentication to old {}".format(err.args), + ) + except Exception as err: + return self.error_response( + response_info, "access_denied", "{}".format(err.args) + ) + else: + user_id, client_id, grant_id = unpack_session_key(session_id) + try: + _mngr.set([user_id, client_id, grant_id], grant) + except Exception as err: + return self.error_response( + response_info, "server_error", "{}".format(err.args) + ) + + logger.debug("response type: %s" % request["response_type"]) + + response_info = self.create_authn_response(request, session_id) + response_info["session_id"] = session_id + + logger.debug("Known clients: {}".format(list(_context.cdb.keys()))) + + try: + redirect_uri = get_uri(_context, request, "redirect_uri") + except (RedirectURIError, ParameterError) as err: + return self.error_response( + response_info, "invalid_request", "{}".format(err.args) + ) + else: + response_info["return_uri"] = redirect_uri + + # Must not use HTTP unless implicit grant type and native application + # info = self.aresp_check(response_info['response_args'], request) + # if isinstance(info, ResponseMessage): + # return info + + _cookie = new_cookie( + _context, + sid=session_id, + state=request.get("state"), + cookie_name=_context.cookie_name["session"], + ) + + # Now about the response_mode. Should not be set if it's obvious + # from the response_type. Knows about 'query', 'fragment' and + # 'form_post'. + + if "response_mode" in request: + try: + response_info = self.response_mode(request, **response_info) + except InvalidRequest as err: + return self.error_response( + response_info, "invalid_request", "{}".format(err.args) + ) + + response_info["cookie"] = [_cookie] + + return response_info + + def authz_part2(self, request, session_id, **kwargs): + """ + After the authentication this is where you should end up + + :param user: + :param request: The Authorization Request + :param session_id: Session identifier + :param kwargs: possible other parameters + :return: A redirect to the redirect_uri of the client + """ + + try: + resp_info = self.post_authentication(request, session_id, **kwargs) + except Exception as err: + return self.error_response({}, "server_error", err) + + _context = self.server_get("endpoint_context") + + if "check_session_iframe" in _context.provider_info: + salt = rndstr() + try: + authn_event = _context.session_manager.get_authentication_event(session_id) + except KeyError: + return self.error_response({}, "server_error", "No such session") + else: + if authn_event.is_valid() is False: + return self.error_response({}, "server_error", "Authentication has timed out") + + _state = b64e( + as_bytes(json.dumps({"authn_time": authn_event["authn_time"]})) + ) + + opbs_value = '' + if hasattr(_context.cookie_dealer, 'create_cookie'): + session_cookie = _context.cookie_dealer.create_cookie( + as_unicode(_state), + typ="session", + cookie_name=_context.cookie_name["session_management"], + same_site="None", + http_only=False, + ) + + opbs = session_cookie[_context.cookie_name["session_management"]] + opbs_value = opbs.value + else: + session_cookie = None + logger.debug( + "Failed to set Cookie, that's not configured in main configuration.") + + logger.debug( + "compute_session_state: client_id=%s, origin=%s, opbs=%s, salt=%s", + request["client_id"], + resp_info["return_uri"], + opbs_value, + salt, + ) + + _session_state = compute_session_state( + opbs_value, salt, request["client_id"], resp_info["return_uri"] + ) + + if opbs_value and session_cookie: + if "cookie" in resp_info: + if isinstance(resp_info["cookie"], list): + resp_info["cookie"].append(session_cookie) + else: + append_cookie(resp_info["cookie"], session_cookie) + else: + resp_info["cookie"] = session_cookie + + resp_info["response_args"]["session_state"] = _session_state + + # Mix-Up mitigation + resp_info["response_args"]["iss"] = _context.issuer + resp_info["response_args"]["client_id"] = request["client_id"] + + return resp_info + + def do_request_user(self, request_info, **kwargs): + return kwargs + + def process_request(self, request: Union[Message, dict], **kwargs): + """ The AuthorizationRequest endpoint + + :param request: The authorization request as a Message instance + :return: dictionary + """ + + if isinstance(request, self.error_cls): + return request + + _cid = request["client_id"] + _context = self.server_get("endpoint_context") + cinfo = _context.cdb[_cid] + logger.debug("client {}: {}".format(_cid, cinfo)) + + # this apply the default optionally deny_unknown_scopes policy + if cinfo: + check_unknown_scopes_policy(request, cinfo, _context) + + cookie = kwargs.get("cookie", "") + if cookie: + del kwargs["cookie"] + + kwargs = self.do_request_user(request_info=request, **kwargs) + + info = self.setup_auth( + request, request["redirect_uri"], cinfo, cookie, **kwargs + ) + + if "error" in info: + return info + + _function = info.get("function") + if not _function: + logger.debug("- authenticated -") + logger.debug("AREQ keys: %s" % request.keys()) + return self.authz_part2(request=request, cookie=cookie, **info) + + try: + # Run the authentication function + return { + "http_response": _function(**info["args"]), + "return_uri": request["redirect_uri"], + } + except Exception as err: + logger.exception(err) + return {"http_response": "Internal error: {}".format(err)} + + +class AllowedAlgorithms: + def __init__(self, algorithm_parameters): + self.algorithm_parameters = algorithm_parameters + + def __call__(self, client_id, endpoint_context, alg, alg_type): + _cinfo = endpoint_context.cdb[client_id] + _pinfo = endpoint_context.provider_info + + _reg, _sup = self.algorithm_parameters[alg_type] + _allowed = _cinfo.get(_reg) + if _allowed is None: + _allowed = _pinfo.get(_sup) + + if alg not in _allowed: + logger.error( + "Signing alg user: {} not among allowed: {}".format(alg, _allowed) + ) + raise ValueError("Not allowed '%s' algorithm used", alg) + + +def re_authenticate(request, authn): + return False + +# class Authorization(authorization.Authorization): +# request_cls = oauth2.AuthorizationRequest +# response_cls = oauth2.AuthorizationResponse +# error_cls = oauth2.AuthorizationErrorResponse +# request_format = "urlencoded" +# response_format = "urlencoded" +# response_placement = "url" +# endpoint_name = "authorization_endpoint" +# name = "authorization" +# default_capabilities = { +# "claims_parameter_supported": True, +# "request_parameter_supported": True, +# "request_uri_parameter_supported": True, +# "response_types_supported": ["code", "token", "code token"], +# "response_modes_supported": ["query", "fragment", "form_post"], +# "request_object_signing_alg_values_supported": None, +# "request_object_encryption_alg_values_supported": None, +# "request_object_encryption_enc_values_supported": None, +# "grant_types_supported": ["authorization_code", "implicit"], +# "scopes_supported": [], +# } +# +# def __init__(self, endpoint_context, **kwargs): +# authorization.Authorization.__init__(self, endpoint_context, **kwargs) +# # self.pre_construct.append(self._pre_construct) +# self.post_parse_request.append(self._do_request_uri) +# self.post_parse_request.append(self._post_parse_request) +# # Has to be done elsewhere. To make sure things happen in order. +# # self.scopes_supported = available_scopes(endpoint_context) +# +# def setup_client_session(self, user_id: str, request: dict) -> str: +# _mngr = self.endpoint_context.session_manager +# client_id = request['client_id'] +# +# client_info = ClientSessionInfo( +# authorization_request=request, +# sub=_mngr.sub_func['public'](user_id, salt=_mngr.salt) +# ) +# +# _mngr.set([user_id, client_id], client_info) +# return session_key(user_id, client_id) diff --git a/src/oidcop/oauth2/device_authorization.py b/src/oidcop/oauth2/device_authorization.py new file mode 100644 index 00000000..5b2833f0 --- /dev/null +++ b/src/oidcop/oauth2/device_authorization.py @@ -0,0 +1,62 @@ +from oidcmsg.oauth2.device_authorization import AuthorizationRequest +from oidcmsg.oauth2.device_authorization import AuthorizationResponse +from oidcmsg.time_util import utc_time_sans_frac + +from oidcop import rndstr +from oidcop.endpoint import Endpoint + + +class AuthorizationEndpoint(Endpoint): + request_cls = AuthorizationRequest + response_cls = AuthorizationResponse + request_format = "urlencoded" + response_format = "json" + response_placement = "body" + endpoint_name = "device_authorization_endpoint" + name = "device_authorization" + + def __init__(self, server_get, **kwargs): + Endpoint.__init__(self, server_get, **kwargs) + self.verification_uri = kwargs.get("verification_uri") + self.expires_in = kwargs.get("expires_in", 300) + self.interval = kwargs.get("interval", 5) + + def process_request(self, request=None, **kwargs): + """ + Produces a device code and an end-user + code and provides the end-user verification URI. + + :param request: + :param kwargs: + :return: + """ + _device_code = rndstr(32) + _user_code = rndstr(8) + + _response = { + "device_code": _device_code, + "user_code": _user_code, + "verification_uri": self.verification_uri, + "expires_in": self.expires_in, + "interval": self.interval, + } + _info = { + "device_code": _device_code, + "user_code": _user_code, + "exp": utc_time_sans_frac() + self.expires_in, + "interval": self.interval, + } + + self.server_get("endpoint_context").dev_auth_db.set(_user_code, _info) + return {"response_args": _response} + + def verification_endpoint(self, query): + """ + Where the device's pull query is handled. + + :param query: + :return: + """ + _response = {} + + return _response diff --git a/src/oidcop/oauth2/introspection.py b/src/oidcop/oauth2/introspection.py new file mode 100644 index 00000000..16aee09a --- /dev/null +++ b/src/oidcop/oauth2/introspection.py @@ -0,0 +1,111 @@ +"""Implements RFC7662""" +import logging + +from oidcmsg import oauth2 + +from oidcop.endpoint import Endpoint +from oidcop.token.exception import UnknownToken + +LOGGER = logging.getLogger(__name__) + + +class Introspection(Endpoint): + """Implements RFC 7662""" + + request_cls = oauth2.TokenIntrospectionRequest + response_cls = oauth2.TokenIntrospectionResponse + request_format = "urlencoded" + response_format = "json" + endpoint_name = "introspection_endpoint" + name = "introspection" + + def __init__(self, server_get, **kwargs): + Endpoint.__init__(self, server_get, **kwargs) + self.offset = kwargs.get("offset", 0) + + def _introspect(self, token, client_id, grant): + # Make sure that the token is an access_token or a refresh_token + if token.type not in ["access_token", "refresh_token"]: + return None + + if not token.is_active(): + return None + + scope = token.scope + if not scope: + scope = grant.scope + aud = token.resources + if not aud: + aud = grant.resources + + _context = self.server_get("endpoint_context") + ret = { + "active": True, + "scope": " ".join(scope), + "client_id": client_id, + "token_type": token.type, + "exp": token.expires_at, + "iat": token.issued_at, + "sub": grant.sub, + "iss": _context.issuer + } + + if aud: + ret["aud"] = aud + + token_args = {} + for meth in _context.token_args_methods: + token_args = meth(_context, client_id, token_args) + + if token_args: + ret.update(token_args) + + return ret + + def process_request(self, request=None, **kwargs): + """ + + :param request: The authorization request as a dictionary + :param kwargs: + :return: + """ + _introspect_request = self.request_cls(**request) + if "error" in _introspect_request: + return _introspect_request + + request_token = _introspect_request["token"] + _resp = self.response_cls(active=False) + _context = self.server_get("endpoint_context") + + try: + _session_info = _context.session_manager.get_session_info_by_token( + request_token, grant=True) + except UnknownToken: + return {"response_args": _resp} + + _token = _context.session_manager.find_token(_session_info["session_id"], request_token) + + _info = self._introspect(_token, _session_info["client_id"], _session_info["grant"]) + if _info is None: + return {"response_args": _resp} + + if "release" in self.kwargs: + if "username" in self.kwargs["release"]: + try: + _info["username"] = _session_info["user_id"] + except KeyError: + pass + + _resp.update(_info) + _resp.weed() + + _claims_restriction = _session_info["grant"].claims.get("introspection") + if _claims_restriction: + user_info = _context.claims_interface.get_user_claims( + _session_info["user_id"], _claims_restriction) + if user_info: + _resp.update(user_info) + + _resp["active"] = True + + return {"response_args": _resp} diff --git a/src/oidcop/oauth2/pushed_authorization.py b/src/oidcop/oauth2/pushed_authorization.py new file mode 100644 index 00000000..d1f84f81 --- /dev/null +++ b/src/oidcop/oauth2/pushed_authorization.py @@ -0,0 +1,38 @@ +import uuid + +from oidcmsg import oauth2 + +from oidcop.oauth2.authorization import Authorization + + +class PushedAuthorization(Authorization): + request_cls = oauth2.PushedAuthorizationRequest + response_cls = oauth2.Message + endpoint_name = "pushed_authorization_request_endpoint" + request_placement = "body" + request_format = "urlencoded" + response_placement = "body" + response_format = "json" + name = "pushed_authorization" + + def __init__(self, server_get, **kwargs): + Authorization.__init__(self, server_get, **kwargs) + # self.pre_construct.append(self._pre_construct) + self.post_parse_request.append(self._post_parse_request) + self.ttl = kwargs.get("ttl", 3600) + + def process_request(self, request=None, **kwargs): + """ + Store the request and return a URI. + + :param request: + """ + # create URN + + _urn = "urn:uuid:{}".format(uuid.uuid4()) + self.server_get("endpoint_context").par_db[_urn] = request + + return { + "http_response": {"request_uri": _urn, "expires_in": self.ttl}, + "return_uri": request["redirect_uri"], + } diff --git a/src/oidcop/oidc/__init__.py b/src/oidcop/oidc/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/src/oidcop/oidc/add_on/__init__.py b/src/oidcop/oidc/add_on/__init__.py new file mode 100644 index 00000000..480ee0ab --- /dev/null +++ b/src/oidcop/oidc/add_on/__init__.py @@ -0,0 +1,2 @@ +from .custom_scopes import add_custom_scopes +from .pkce import add_pkce_support diff --git a/src/oidcop/oidc/add_on/custom_scopes.py b/src/oidcop/oidc/add_on/custom_scopes.py new file mode 100644 index 00000000..1366548f --- /dev/null +++ b/src/oidcop/oidc/add_on/custom_scopes.py @@ -0,0 +1,28 @@ +import logging + +from oidcop.scopes import SCOPE2CLAIMS + +LOGGER = logging.getLogger(__name__) + + +def add_custom_scopes(endpoint, **kwargs): + """ + :param endpoint: A dictionary with endpoint instances as values + """ + # Just need an endpoint, anyone will do + _endpoint = list(endpoint.values())[0] + + _scopes2claims = SCOPE2CLAIMS.copy() + _scopes2claims.update(kwargs) + _context = _endpoint.server_get("endpoint_context") + _context.scope2claims = _scopes2claims + + pi = _context.provider_info + _scopes = set(pi.get("scopes_supported", [])) + _scopes.update(set(kwargs.keys())) + pi["scopes_supported"] = list(_scopes) + + _claims = set(pi.get("claims_supported", [])) + for vals in kwargs.values(): + _claims.update(set(vals)) + pi["claims_supported"] = list(_claims) diff --git a/src/oidcop/oidc/add_on/pkce.py b/src/oidcop/oidc/add_on/pkce.py new file mode 100644 index 00000000..25089726 --- /dev/null +++ b/src/oidcop/oidc/add_on/pkce.py @@ -0,0 +1,157 @@ +import hashlib +import logging + +from cryptojwt.utils import b64e +from oidcmsg.oauth2 import AuthorizationErrorResponse +from oidcmsg.oidc import TokenErrorResponse + +LOGGER = logging.getLogger(__name__) + + +def hash_fun(f): + def wrapper(code_verifier): + _h = f(code_verifier.encode("ascii")).digest() + _cc = b64e(_h) + return _cc.decode("ascii") + + return wrapper + + +CC_METHOD = { + "plain": lambda x: x, + "S256": hash_fun(hashlib.sha256), + "S384": hash_fun(hashlib.sha384), + "S512": hash_fun(hashlib.sha512), +} + + +def post_authn_parse(request, client_id, endpoint_context, **kwargs): + """ + + :param request: + :param client_id: + :param endpoint_context: + :param kwargs: + :return: + """ + if ( + endpoint_context.args["pkce"]["essential"] + and "code_challenge" not in request + ): + return AuthorizationErrorResponse( + error="invalid_request", + error_description="Missing required code_challenge", + ) + + if "code_challenge_method" not in request: + request["code_challenge_method"] = "plain" + + if ( + "code_challenge" in request + and ( + request["code_challenge_method"] + not in endpoint_context.args["pkce"]["code_challenge_methods"] + ) + ): + return AuthorizationErrorResponse( + error="invalid_request", + error_description="Unsupported code_challenge_method={}".format( + request["code_challenge_method"] + ), + ) + + return request + + +def verify_code_challenge( + code_verifier, code_challenge, code_challenge_method="S256" +): + """ + Verify a PKCE (RFC7636) code challenge. + + + :param code_verifier: The origin + :param code_challenge: The transformed verifier used as challenge + :return: + """ + if CC_METHOD[code_challenge_method](code_verifier) != code_challenge: + LOGGER.error("PKCE Code Challenge check failed") + return False + + LOGGER.debug("PKCE Code Challenge check succeeded") + return True + + +def post_token_parse(request, client_id, endpoint_context, **kwargs): + """ + To be used as a post_parse_request function. + + :param token_request: + :return: + """ + if isinstance(request, AuthorizationErrorResponse): + return request + + try: + _session_info = endpoint_context.session_manager.get_session_info_by_token( + request["code"], grant=True) + except KeyError: + return TokenErrorResponse( + error="invalid_grant", error_description="Unknown access grant" + ) + + _authn_req = _session_info["grant"].authorization_request + + if "code_challenge" in _authn_req: + if "code_verifier" not in request: + return TokenErrorResponse( + error="invalid_grant", + error_description="Missing code_verifier", + ) + + _method = _authn_req["code_challenge_method"] + + if not verify_code_challenge( + request["code_verifier"], + _authn_req["code_challenge"], + _method, + ): + return TokenErrorResponse( + error="invalid_grant", error_description="PKCE check failed" + ) + + return request + + +def add_pkce_support(endpoint, **kwargs): + authn_endpoint = endpoint.get("authorization") + if authn_endpoint is None: + LOGGER.warning( + "No authorization endpoint found, skipping PKCE configuration" + ) + return + + token_endpoint = endpoint.get("token") + if token_endpoint is None: + LOGGER.warning( + "No token endpoint found, skipping PKCE configuration" + ) + return + + authn_endpoint.post_parse_request.append(post_authn_parse) + token_endpoint.post_parse_request.append(post_token_parse) + + if "essential" not in kwargs: + kwargs["essential"] = False + + code_challenge_methods = kwargs.get( + "code_challenge_methods", CC_METHOD.keys() + ) + + kwargs["code_challenge_methods"] = {} + for method in code_challenge_methods: + if method not in CC_METHOD: + raise ValueError("Unsupported method: {}".format(method)) + kwargs["code_challenge_methods"][method] = CC_METHOD[method] + + authn_endpoint.server_get("endpoint_context").args["pkce"] = kwargs diff --git a/src/oidcop/oidc/authorization.py b/src/oidcop/oidc/authorization.py new file mode 100755 index 00000000..5d80deda --- /dev/null +++ b/src/oidcop/oidc/authorization.py @@ -0,0 +1,145 @@ +import logging +from typing import Callable +from urllib.parse import urlsplit + +from oidcmsg import oidc +from oidcmsg.oidc import Claims +from oidcmsg.oidc import verified_claim_name + +from oidcop.oauth2 import authorization +from oidcop.session import session_key +from oidcop.session.info import ClientSessionInfo + +logger = logging.getLogger(__name__) + + +def proposed_user(request): + cn = verified_claim_name("it_token_hint") + if request.get(cn): + return request[cn].get("sub", "") + return "" + + +def acr_claims(request): + acrdef = None + + _claims = request.get("claims") + if isinstance(_claims, str): + _claims = Claims().from_json(_claims) + + if _claims: + _id_token_claim = _claims.get("id_token") + if _id_token_claim: + acrdef = _id_token_claim.get("acr") + + if isinstance(acrdef, dict): + if acrdef.get("value"): + return [acrdef["value"]] + elif acrdef.get("values"): + return acrdef["values"] + + +def host_component(url): + res = urlsplit(url) + return "{}://{}".format(res.scheme, res.netloc) + + +ALG_PARAMS = { + "sign": [ + "request_object_signing_alg", + "request_object_signing_alg_values_supported", + ], + "enc_alg": [ + "request_object_encryption_alg", + "request_object_encryption_alg_values_supported", + ], + "enc_enc": [ + "request_object_encryption_enc", + "request_object_encryption_enc_values_supported", + ], +} + + +def re_authenticate(request, authn): + if "prompt" in request and "login" in request["prompt"]: + if authn.done(request): + return True + + return False + + +class Authorization(authorization.Authorization): + request_cls = oidc.AuthorizationRequest + response_cls = oidc.AuthorizationResponse + error_cls = oidc.AuthorizationErrorResponse + request_format = "urlencoded" + response_format = "urlencoded" + response_placement = "url" + endpoint_name = "authorization_endpoint" + name = "authorization" + default_capabilities = { + "claims_parameter_supported": True, + "request_parameter_supported": True, + "request_uri_parameter_supported": True, + "response_types_supported": [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "id_token token", + "code id_token token", + ], + "response_modes_supported": ["query", "fragment", "form_post"], + "request_object_signing_alg_values_supported": None, + "request_object_encryption_alg_values_supported": None, + "request_object_encryption_enc_values_supported": None, + "grant_types_supported": ["authorization_code", "implicit"], + "claim_types_supported": ["normal", "aggregated", "distributed"], + } + + def __init__(self, server_get: Callable, **kwargs): + authorization.Authorization.__init__(self, server_get, **kwargs) + # self.pre_construct.append(self._pre_construct) + self.post_parse_request.append(self._do_request_uri) + self.post_parse_request.append(self._post_parse_request) + + def setup_client_session(self, user_id: str, request: dict) -> str: + _mngr = self.server_get("endpoint_context").session_manager + client_id = request['client_id'] + + _client_info = self.server_get("endpoint_context").cdb[client_id] + sub_type = _client_info.get("subject_type") + if sub_type and sub_type == "pairwise": + sector_identifier_uri = _client_info.get("sector_identifier_uri") + if sector_identifier_uri is None: + sector_identifier_uri = host_component(_client_info["redirect_uris"][0]) + + client_info = ClientSessionInfo( + authorization_request=request, + sub=_mngr.sub_func[sub_type](user_id, salt=_mngr.salt, + sector_identifier=sector_identifier_uri) + ) + else: + sub_type = self.kwargs.get("subject_type") + if not sub_type: + sub_type = "public" + + client_info = ClientSessionInfo( + authorization_request=request, + sub=_mngr.sub_func[sub_type](user_id, salt=_mngr.salt) + ) + + _mngr.set([user_id, client_id], client_info) + return session_key(user_id, client_id) + + def do_request_user(self, request_info, **kwargs): + if proposed_user(request_info): + kwargs["req_user"] = proposed_user(request_info) + else: + if request_info.get("login_hint"): + _login_hint = request_info["login_hint"] + _context = self.server_get("endpoint_context") + if _context.login_hint_lookup: + kwargs["req_user"] = _context.login_hint_lookup[_login_hint] + return kwargs diff --git a/src/oidcop/oidc/discovery.py b/src/oidcop/oidc/discovery.py new file mode 100755 index 00000000..ba79f291 --- /dev/null +++ b/src/oidcop/oidc/discovery.py @@ -0,0 +1,39 @@ +from oidcmsg import oidc +from oidcmsg.oidc import JRD +from oidcmsg.oidc import Link + +from oidcop.endpoint import Endpoint + +OIC_ISSUER = "http://openid.net/specs/connect/1.0/issuer" + + +class Discovery(Endpoint): + request_cls = oidc.DiscoveryRequest + response_cls = JRD + request_format = "urlencoded" + response_format = "json" + name = "discovery" + + def do_response(self, response_args=None, request=None, **kwargs): + """ + **Placeholder for the time being** + + :param response_args: + :param request: + :param kwargs: request arguments + :return: Response information + """ + + links = [Link(href=h, rel=OIC_ISSUER) for h in kwargs["hrefs"]] + + _response = JRD(subject=kwargs["subject"], links=links) + + info = { + "response": _response.to_json(), + "http_headers": [("Content-type", "application/json")], + } + + return info + + def process_request(self, request=None, **kwargs): + return {"subject": request["resource"], "hrefs": [self.server_get("endpoint_context").issuer]} diff --git a/src/oidcop/oidc/provider_config.py b/src/oidcop/oidc/provider_config.py new file mode 100755 index 00000000..2042761c --- /dev/null +++ b/src/oidcop/oidc/provider_config.py @@ -0,0 +1,32 @@ +import logging + +from oidcmsg import oidc + +from oidcop.endpoint import Endpoint + +logger = logging.getLogger(__name__) + + +class ProviderConfiguration(Endpoint): + request_cls = oidc.Message + response_cls = oidc.ProviderConfigurationResponse + request_format = "" + response_format = "json" + name = "provider_config" + default_capabilities = {"require_request_uri_registration": None} + + def __init__(self, server_get, **kwargs): + Endpoint.__init__(self, server_get=server_get, **kwargs) + self.pre_construct.append(self.add_endpoints) + + def add_endpoints(self, request, client_id, endpoint_context, **kwargs): + for endpoint in ["authorization_endpoint", "registration_endpoint", "token_endpoint", + "userinfo_endpoint", "end_session_endpoint", ]: + endp_instance = self.server_get("endpoint", endpoint) + if endp_instance: + request[endpoint] = endp_instance.endpoint_path + + return request + + def process_request(self, request=None, **kwargs): + return {"response_args": self.server_get("endpoint_context").provider_info} diff --git a/src/oidcop/oidc/read_registration.py b/src/oidcop/oidc/read_registration.py new file mode 100644 index 00000000..942c7966 --- /dev/null +++ b/src/oidcop/oidc/read_registration.py @@ -0,0 +1,31 @@ +from oidcmsg.message import Message +from oidcmsg.oauth2 import ResponseMessage +from oidcmsg.oidc import RegistrationResponse + +from oidcop.endpoint import Endpoint +from oidcop.oidc.registration import comb_uri + + +class RegistrationRead(Endpoint): + request_cls = Message + response_cls = RegistrationResponse + error_response = ResponseMessage + request_format = "urlencoded" + request_placement = "url" + response_format = "json" + name = "registration_read" + + def get_client_id_from_token(self, endpoint_context, token, request=None): + if "client_id" in request: + if ( + request["client_id"] + == self.server_get("endpoint_context").registration_access_token[token] + ): + return request["client_id"] + return "" + + def process_request(self, request=None, **kwargs): + _cli_info = self.server_get("endpoint_context").cdb[request["client_id"]] + args = { k: v for k, v in _cli_info.items() if k in RegistrationResponse.c_param} + comb_uri(args) + return {"response_args": RegistrationResponse(**args)} diff --git a/src/oidcop/oidc/registration.py b/src/oidcop/oidc/registration.py new file mode 100755 index 00000000..e9dd9d50 --- /dev/null +++ b/src/oidcop/oidc/registration.py @@ -0,0 +1,474 @@ +import hashlib +import hmac +import json +import logging +import time +from random import random +from urllib.parse import parse_qs +from urllib.parse import urlencode +from urllib.parse import urlparse + +from cryptojwt.jws.utils import alg2keytype +from cryptojwt.utils import as_bytes +from oidcmsg.exception import MessageException +from oidcmsg.oauth2 import ResponseMessage +from oidcmsg.oidc import ClientRegistrationErrorResponse +from oidcmsg.oidc import RegistrationRequest +from oidcmsg.oidc import RegistrationResponse +from oidcmsg.time_util import utc_time_sans_frac + +from oidcop import rndstr +from oidcop import sanitize +from oidcop.cookie import new_cookie +from oidcop.endpoint import Endpoint +from oidcop.exception import CapabilitiesMisMatch +from oidcop.exception import InvalidRedirectURIError +from oidcop.exception import InvalidSectorIdentifier +from oidcop.util import split_uri + +PREFERENCE2PROVIDER = { + # "require_signed_request_object": "request_object_algs_supported", + "request_object_signing_alg": "request_object_signing_alg_values_supported", + "request_object_encryption_alg": "request_object_encryption_alg_values_supported", + "request_object_encryption_enc": "request_object_encryption_enc_values_supported", + "userinfo_signed_response_alg": "userinfo_signing_alg_values_supported", + "userinfo_encrypted_response_alg": "userinfo_encryption_alg_values_supported", + "userinfo_encrypted_response_enc": "userinfo_encryption_enc_values_supported", + "id_token_signed_response_alg": "id_token_signing_alg_values_supported", + "id_token_encrypted_response_alg": "id_token_encryption_alg_values_supported", + "id_token_encrypted_response_enc": "id_token_encryption_enc_values_supported", + "default_acr_values": "acr_values_supported", + "subject_type": "subject_types_supported", + "token_endpoint_auth_method": "token_endpoint_auth_methods_supported", + "token_endpoint_auth_signing_alg": "token_endpoint_auth_signing_alg_values_supported", + "response_types": "response_types_supported", + "grant_types": "grant_types_supported", +} + +logger = logging.getLogger(__name__) + + +def match_sp_sep(first, second): + """ + Verify that all the values in 'first' appear in 'second'. + The values can either be in the form of lists or as space separated + items. + + :param first: + :param second: + :return: True/False + """ + if isinstance(first, list): + one = [set(v.split(" ")) for v in first] + else: + one = [{v} for v in first.split(" ")] + + if isinstance(second, list): + other = [set(v.split(" ")) for v in second] + else: + other = [{v} for v in second.split(" ")] + + # all values in one must appear in other + if any(rt not in other for rt in one): + return False + return True + + +def verify_url(url, urlset): + part = urlparse(url) + + for reg, qp in urlset: + _part = urlparse(reg) + if part.scheme == _part.scheme and part.netloc == _part.netloc: + return True + + return False + + +def secret(seed, sid): + msg = "{}{:.6f}{}".format(time.time(), random(), sid).encode("utf-8") + csum = hmac.new(as_bytes(seed), msg, hashlib.sha224) + return csum.hexdigest() + + +def comb_uri(args): + for param in ["redirect_uris", "post_logout_redirect_uris"]: + if param not in args: + continue + + val = [] + for base, query_dict in args[param]: + if query_dict: + query_string = urlencode( + [(key, v) for key in query_dict for v in query_dict[key]] + ) + val.append("%s?%s" % (base, query_string)) + else: + val.append(base) + + args[param] = val + + request_uris = args.get('request_uris') + if request_uris: + val = [] + for base,frag in request_uris: + if frag: + val.append('{}#{}'.format(base, frag)) + else: + val.append(base) + args['request_uris'] = val + + +class Registration(Endpoint): + request_cls = RegistrationRequest + response_cls = RegistrationResponse + error_response = ClientRegistrationErrorResponse + request_format = "json" + request_placement = "body" + response_format = "json" + endpoint_name = "registration_endpoint" + name = "registration" + + # default + # response_placement = 'body' + + def match_client_request(self, request): + _context = self.server_get("endpoint_context") + for _pref, _prov in PREFERENCE2PROVIDER.items(): + if _pref in request: + if _pref in ["response_types", "default_acr_values"]: + if not match_sp_sep(request[_pref], _context.provider_info[_prov]): + raise CapabilitiesMisMatch(_pref) + else: + if isinstance(request[_pref], str): + if request[_pref] not in _context.provider_info[_prov]: + raise CapabilitiesMisMatch(_pref) + else: + if not set(request[_pref]).issubset( + set(_context.provider_info[_prov]) + ): + raise CapabilitiesMisMatch(_pref) + + def do_client_registration(self, request, client_id, ignore=None): + if ignore is None: + ignore = [] + _context = self.server_get("endpoint_context") + _cinfo = _context.cdb[client_id].copy() + logger.debug("_cinfo: %s" % sanitize(_cinfo)) + + for key, val in request.items(): + if key not in ignore: + _cinfo[key] = val + + if "post_logout_redirect_uris" in request: + plruri = [] + for uri in request["post_logout_redirect_uris"]: + if urlparse(uri).fragment: + err = self.error_cls( + error="invalid_configuration_parameter", + error_description="post_logout_redirect_uris contains fragment", + ) + return err + plruri.append(split_uri(uri)) + _cinfo["post_logout_redirect_uris"] = plruri + + if "redirect_uris" in request: + try: + ruri = self.verify_redirect_uris(request) + _cinfo["redirect_uris"] = ruri + except InvalidRedirectURIError as e: + return self.error_cls( + error="invalid_redirect_uri", error_description=str(e) + ) + + if "request_uris" in request: + _uris = [] + for uri in request["request_uris"]: + _up = urlparse(uri) + if _up.query: + err = self.error_cls( + error="invalid_configuration_parameter", + error_description="request_uris contains query part", + ) + return err + if _up.fragment: + # store base and fragment + _uris.append(uri.split('#')) + else: + _uris.append([uri, '']) + _cinfo["request_uris"] = _uris + + if "sector_identifier_uri" in request: + try: + ( + _cinfo["si_redirects"], + _cinfo["sector_id"], + ) = self._verify_sector_identifier(request) + except InvalidSectorIdentifier as err: + return ResponseMessage( + error="invalid_configuration_parameter", error_description=str(err) + ) + + for item in ["policy_uri", "logo_uri", "tos_uri"]: + if item in request: + if verify_url(request[item], _cinfo["redirect_uris"]): + _cinfo[item] = request[item] + else: + return ResponseMessage( + error="invalid_configuration_parameter", + error_description="%s pointed to illegal URL" % item, + ) + + # Do I have the necessary keys + for item in ["id_token_signed_response_alg", "userinfo_signed_response_alg"]: + if item in request: + if request[item] in _context.provider_info[PREFERENCE2PROVIDER[item]]: + ktyp = alg2keytype(request[item]) + # do I have this ktyp and for EC type keys the curve + if ktyp not in ["none", "oct"]: + _k = [] + for iss in ["", _context.issuer]: + _k.extend( + _context.keyjar.get_signing_key( + ktyp, alg=request[item], owner=iss + ) + ) + if not _k: + logger.warning( + 'Lacking support for "{}"'.format(request[item]) + ) + del _cinfo[item] + + t = {"jwks_uri": "", "jwks": None} + + for item in ["jwks_uri", "jwks"]: + if item in request: + t[item] = request[item] + + # if it can't load keys because the URL is false it will + # just silently fail. Waiting for better times. + _context.keyjar.load_keys(client_id, jwks_uri=t["jwks_uri"], jwks=t["jwks"]) + n_keys = 0 + for kb in _context.keyjar.get(client_id, []): + n_keys += len(kb.keys()) + msg = "found {} keys for client_id={}" + logger.debug(msg.format(n_keys, client_id)) + + return _cinfo + + @staticmethod + def verify_redirect_uris(registration_request): + verified_redirect_uris = [] + client_type = registration_request.get("application_type", "web") + + must_https = False + if client_type == "web": + must_https = True + if registration_request.get("response_types") == ["code"]: + must_https = False + + for uri in registration_request["redirect_uris"]: + _custom = False + p = urlparse(uri) + if client_type == "native": + if p.scheme not in ["http", "https"]: # Custom scheme + _custom = True + elif p.scheme == "http" and p.hostname in ["localhost", "127.0.0.1"]: + pass + else: + logger.error( + "InvalidRedirectURI: scheme:%s, hostname:%s", + p.scheme, + p.hostname, + ) + raise InvalidRedirectURIError( + "Redirect_uri must use custom " "scheme or http and localhost" + ) + elif must_https and p.scheme != "https": + msg = "None https redirect_uri not allowed" + raise InvalidRedirectURIError(msg) + elif p.scheme not in ["http", "https"]: + # Custom scheme + raise InvalidRedirectURIError( + "Custom redirect_uri not allowed for web client" + ) + elif p.fragment: + raise InvalidRedirectURIError("redirect_uri contains fragment") + + if _custom: # Can not verify a custom scheme + verified_redirect_uris.append((uri, {})) + else: + base, query = split_uri(uri) + if query: + verified_redirect_uris.append((base, query)) + else: + verified_redirect_uris.append((base, {})) + + return verified_redirect_uris + + def _verify_sector_identifier(self, request): + """ + Verify `sector_identifier_uri` is reachable and that it contains + `redirect_uri`s. + + :param request: Provider registration request + :return: si_redirects, sector_id + :raises: InvalidSectorIdentifier + """ + si_url = request["sector_identifier_uri"] + try: + res = self.server_get("endpoint_context").httpc.get( + si_url, **self.server_get("endpoint_context").httpc_params + ) + logger.debug("sector_identifier_uri => %s", sanitize(res.text)) + except Exception as err: + logger.error(err) + # res = None + raise InvalidSectorIdentifier("Couldn't read from sector_identifier_uri") + + try: + si_redirects = json.loads(res.text) + except ValueError: + raise InvalidSectorIdentifier( + "Error deserializing sector_identifier_uri content" + ) + + if "redirect_uris" in request: + logger.debug("redirect_uris: %s", request["redirect_uris"]) + for uri in request["redirect_uris"]: + if uri not in si_redirects: + raise InvalidSectorIdentifier( + "redirect_uri missing from sector_identifiers" + ) + + return si_redirects, si_url + + def add_registration_api(self, cinfo, client_id, context): + _rat = rndstr(32) + + cinfo["registration_access_token"] = _rat + endpoint = self.server_get("endpoints") + cinfo["registration_client_uri"] = "{}?client_id={}".format( + endpoint["registration_read"].full_path, client_id + ) + + context.registration_access_token[_rat] = client_id + + def client_secret_expiration_time(self): + """ + Returns client_secret expiration time. + """ + if not self.kwargs.get("client_secret_expires", True): + return 0 + + _expiration_time = self.kwargs.get("client_secret_expires_in", 2592000) + return utc_time_sans_frac() + _expiration_time + + def add_client_secret(self, cinfo, client_id, context): + client_secret = secret(context.seed, client_id) + cinfo["client_secret"] = client_secret + _eat = self.client_secret_expiration_time() + if _eat: + cinfo["client_secret_expires_at"] = _eat + + return client_secret + + def client_registration_setup(self, request, new_id=True, set_secret=True): + try: + request.verify() + except (MessageException, ValueError) as err: + logger.error('request.verify() on %s', request) + return ResponseMessage( + error="invalid_configuration_request", error_description="%s" % err + ) + + request.rm_blanks() + try: + self.match_client_request(request) + except CapabilitiesMisMatch as err: + return ResponseMessage( + error="invalid_request", + error_description="Don't support proposed %s" % err, + ) + + _context = self.server_get("endpoint_context") + if new_id: + # create new id och secret + client_id = rndstr(12) + # cdb client_id MUST be unique! + while client_id in _context.cdb: + client_id = rndstr(12) + if "client_id" in request: + del request["client_id"] + else: + client_id = request.get("client_id") + if not client_id: + raise ValueError("Missing client_id") + + _cinfo = {"client_id": client_id, "client_salt": rndstr(8)} + + if self.server_get("endpoint", "registration_read"): + self.add_registration_api(_cinfo, client_id, _context) + + if new_id: + _cinfo["client_id_issued_at"] = utc_time_sans_frac() + + client_secret = "" + if set_secret: + client_secret = self.add_client_secret(_cinfo, client_id, _context) + + logger.debug("Stored client info in CDB under cid={}".format(client_id)) + + _context.cdb[client_id] = _cinfo + _cinfo = self.do_client_registration( + request, + client_id, + ignore=["redirect_uris", "policy_uri", "logo_uri", "tos_uri"], + ) + if isinstance(_cinfo, ResponseMessage): + return _cinfo + + args = dict( + [(k, v) for k, v in _cinfo.items() if k in self.response_cls.c_param] + ) + + comb_uri(args) + response = self.response_cls(**args) + + # Add the client_secret as a symmetric key to the key jar + if client_secret: + _context.keyjar.add_symmetric(client_id, str(client_secret)) + + logger.debug("Stored updated client info in CDB under cid={}".format(client_id)) + logger.debug("ClientInfo: {}".format(_cinfo)) + _context.cdb[client_id] = _cinfo + + # Not all databases can be sync'ed + if hasattr(_context.cdb, "sync") and callable(_context.cdb.sync): + _context.cdb.sync() + + msg = "registration_response: {}" + logger.info(msg.format(sanitize(response.to_dict()))) + + return response + + def process_request(self, request=None, new_id=True, set_secret=True, **kwargs): + try: + reg_resp = self.client_registration_setup(request, new_id, set_secret) + except Exception as err: + logger.error('client_registration_setup: %s', request) + return ResponseMessage( + error="invalid_configuration_request", error_description="%s" % err + ) + + if "error" in reg_resp: + return reg_resp + else: + _context = self.server_get("endpoint_context") + _cookie = new_cookie( + _context, + cookie_name=_context.cookie_name["register"], + client_id=reg_resp["client_id"], + ) + + return {"response_args": reg_resp, "cookie": _cookie} diff --git a/src/oidcop/oidc/session.py b/src/oidcop/oidc/session.py new file mode 100644 index 00000000..836b6f6c --- /dev/null +++ b/src/oidcop/oidc/session.py @@ -0,0 +1,404 @@ +import json +import logging +from urllib.parse import parse_qs +from urllib.parse import urlencode +from urllib.parse import urlparse + +from cryptojwt import as_unicode +from cryptojwt import b64d +from cryptojwt.jwe.aes import AES_GCMEncrypter +from cryptojwt.jwe.utils import split_ctx_and_tag +from cryptojwt.jws.exception import JWSException +from cryptojwt.jws.jws import factory +from cryptojwt.jws.utils import alg2keytype +from cryptojwt.jwt import JWT +from cryptojwt.utils import as_bytes +from cryptojwt.utils import b64e +from oidcmsg.exception import InvalidRequest +from oidcmsg.message import Message +from oidcmsg.oauth2 import ResponseMessage +from oidcmsg.oidc import verified_claim_name +from oidcmsg.oidc.session import BACK_CHANNEL_LOGOUT_EVENT +from oidcmsg.oidc.session import EndSessionRequest + +from oidcop import rndstr +from oidcop.client_authn import UnknownOrNoAuthnMethod +from oidcop.cookie import append_cookie +from oidcop.endpoint import Endpoint +from oidcop.endpoint_context import add_path +from oidcop.oauth2.authorization import verify_uri +from oidcop.session import session_key + +logger = logging.getLogger(__name__) + + +def do_front_channel_logout_iframe(cinfo, iss, sid): + """ + + :param cinfo: Client info + :param iss: Issuer ID + :param sid: Session ID + :return: IFrame + """ + try: + frontchannel_logout_uri = cinfo["frontchannel_logout_uri"] + except KeyError: + return None + + try: + flsr = cinfo["frontchannel_logout_session_required"] + except KeyError: + flsr = False + + if flsr: + _query = {"iss": iss, "sid": sid} + if "?" in frontchannel_logout_uri: + p = urlparse(frontchannel_logout_uri) + _args = parse_qs(p.query) + _args.update(_query) + _query = _args + _np = p._replace(query="") + frontchannel_logout_uri = _np.geturl() + + _iframe = '