diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 00000000..206bd562 --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,59 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: oidc-op + +on: + push: + branches: [ master, develop ] + pull_request: + branches: [ master, develop ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: + - '3.7' + - '3.8' + - '3.9' + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + python setup.py install + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 src/oidcop --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 src/oidcop --max-line-length 120 --count --exit-zero --statistics + + - name: Test with pytest + run: | + pytest --cov=oidcop tests/ + - name: Bandit Security Scan + run: | + bandit --skip B105,B106,B107 -r src/oidcop/ + #- name: Upload coverage to Codecov + #uses: codecov/codecov-action@v1 + #with: + #token: ${{ secrets.CODECOV_TOKEN }} + #file: example/coverage.xml + #flags: unittests + #env_vars: OS,PYTHON + #name: codecov-umbrella + #fail_ci_if_error: true + #path_to_write_report: ./codecov_report.txt diff --git a/.gitignore b/.gitignore index cc7f589b..831dbd0f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -django_op/db.sqlite3 -templates static/ private/ conf.yaml @@ -117,3 +115,5 @@ venv.bak/ src/oidcendpoint.egg-info/ .iframes/ +tests/pairwise.salt +tests/public.salt diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 00000000..54146535 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,7 @@ +[settings] +force_single_line = 1 +known_first_party = oidcop +known_third_party = cryptojwt, oidcmsg +known_future_library = future,past +default_section = THIRDPARTY +line_length = 100 \ No newline at end of file diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 00000000..274c940b --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,23 @@ +# .readthedocs.yml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/source/conf.py + +# Build documentation with MkDocs +#mkdocs: +# configuration: mkdocs.yml + +# Optionally build your docs in additional formats such as PDF and ePub +formats: all + +# Optionally set the version of Python and requirements required to build your docs +python: + version: 3.8 + install: + - requirements: requirements-docs.txt diff --git a/README.md b/README.md index caeb2797..26c1da66 100644 --- a/README.md +++ b/README.md @@ -1,233 +1,39 @@ # oidc-op -Examples of a OIDC OPs with CherryPy, Flask and Django. -**NOT** something you should even image running in a production environment. +This project is a Python implementation of an **OIDC Provider** on top of [jwtconnect.io](https://jwtconnect.io/) that shows to you how to 'build' an OP using the classes and functions provided by oidc-op. -### Introduction +If you want to add or replace functionality the official documentation should be able to tell you how. +If you are just going to build a standard OP you only have to understand how to write your configuration file. +In `example/` folder you'll find some complete examples based on flask and django. -This project are here to show you how to 'build' an OP using the -classes and functions provided by oidcendpoint. +Idpy OIDC-op implements the following standards: -If you are just going to build a standard OP you only have to write the -configuration file. If you want to add or replace functionality this document -should be able to tell you how. +* [OpenID Connect Core 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-core-1_0.html) +* [Web Finger](https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery) +* [OpenID Connect Discovery 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-discovery-1_0.html) +* [OpenID Connect Dynamic Client Registration 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-registration-1_0.html) +* [OpenID Connect Session Management 1.0](https://openid.net/specs/openid-connect-session-1_0.html) +* [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html) +* [OpenID Connect Front-Channel Logout 1.0](https://openid.net/specs/openid-connect-frontchannel-1_0.html) +* [OAuth2 Token introspection](https://tools.ietf.org/html/rfc7662) -Setting up an OP means making a number if decisions. Like, should the OP support: +It also comes with the following `add_on` modules. -- [Web Finger](https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery) -- [dynamic discovery](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig) -- [dynamic client registration](https://openid.net/specs/openid-connect-registration-1_0.html) +* Custom scopes, that extends [OIDC standard ScopeClaims](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) +* [Proof Key for Code Exchange by OAuth Public Clients (PKCE)](https://tools.ietf.org/html/rfc7636) +* [OAuth2 RAR](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rar) +* [OAuth2 DPoP](https://tools.ietf.org/id/draft-fett-oauth-dpop-04.html) -All these are services you can access at endpoints. The total set of endpoints -that this package supports are +The entire project code is open sourced and therefore licensed under the [Apache 2.0](https://en.wikipedia.org/wiki/Apache_License) -- webfinger -- provider_info -- registration -- authorization -- token -- refresh_token -- userinfo -- end_session +For any futher information please read the [Official Documentation](#TODO). -### Configuration directives +# Contribute +[Join in](https://idpy.org/contribute/). -_issuer_ -The issuer ID of the OP, unique value. +# Authors -_capabilities_ - -This covers most of the basic functionality of the OP. The key words are the -same as defined in -https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata . -A couple of things are defined else where. Like the endpoints, issuer id, -jwks_uri and the authentication methods at the token endpoint. - -An example: - - response_types_supported: - - code - - token - - id_token - - "code token" - - "code id_token" - - "id_token token" - - "code id_token token" - - none - response_modes_supported: - - query - - fragment - - form_post - subject_types_supported: - - public - - pairwise - grant_types_supported: - - authorization_code - - implicit - - urn:ietf:params:oauth:grant-type:jwt-bearer - - refresh_token - claim_types_supported: - - normal - - aggregated - - distributed - - claims_parameter_supported: True - request_parameter_supported: True - request_uri_parameter_supported: True - frontchannel_logout_supported: True - frontchannel_logout_session_supported: True - backchannel_logout_supported: True - backchannel_logout_session_supported: True - check_session_iframe: https://127.0.0.1:5000/check_session_iframe - - -_id_token_ - -Defines which class that handles creating an ID Token and possibly also -arguments used when initiating that class. - -An example: - - id_token: - class: oidcendpoint.id_token.IDToken - kwargs: - default_claims: - email: - essential: True - email_verified: - essential: True - - -### OIDC Provider example setup - -Create an environment -```` -virtualenv -ppython3 env -source env/bin/activate -```` - -##### Install oidc-op -```` -pip install git+https://github.com/rohe/oidc-op.git - -# get the usage examples -git clone https://github.com/rohe/oidc-op.git -```` - -##### Configure a Django OP - -See - -https://github.com/peppelinux/django-oidc-op - -##### Configure a Flask OP - -```` -pip install flask -cd oidc-op/ - -# configuration: create a private folder -cp -R flask_op/private . - -# copy required files -cp flask_op/passwd.json private/ -cp flask_op/conf.yaml private/ -cp -R flask_op/templates . - -# create a JWK for cookie signing -jwkgen --kty=SYM --kid cookie > private/cookie_sign_jwk.json -```` - -##### About JWK Set (JWKS) files -see: https://cryptojwt.readthedocs.io/en/latest/keyhandling.html - -You can use `cryptojwt.key_jar.init_key_jar` to create JWKS file. -An easy way can be to configure the auto creation of JWKS files directly in your conf.yaml file. -Using `read_only: False` in `OIDC_KEYS` it will create the path within the JWKS files. -Change it to `True` if you don't want to overwrite them on each execution. - -```` -# in conf.yaml -# -OIDC_KEYS: - 'private_path': './private/jwks.json' - 'key_defs': *keydef - 'public_path': './static/jwks.json' - # this will create the jwks files if they absent - 'read_only': False -```` - -In the JWTConnect-Python-CryptoJWT distribution there is also a script you can use to construct a JWK. - -You can for instance do: -```` -$ jwkgen --kty=RSA -{ - "d": "b9ucfay9vxDvz_nRZMVSUR9eRvHNMo0tc8Bl7tWkwxTis7LBXxmbMH1yzLs8omUil_u2a-Z_6VlKENxacuejYYcOhs6bfaU3iOqJbGi2p4t2i1oxjuF-cX6BZ5aHB5Wfb1uTXXobHokjcjVVDmBr_fNYBEPtZsVYqyN9sR9KE_ZLHEPks3IER09aX9G3wiB_PgcxQDRAl72qucsBz9_W9KS-TVWs-qCEqtXLmx9AAN6P8SjUcHAzEb0ZCJAYCkVu34wgNjxVaGyYN1qMA-1iOOVz--wtMyBwc5atSDBDgUApxFyj_DHSeBl81IHedcPjS9azxqFhumP7oJJyfecfSQ", - "e": "AQAB", - "kid": "cHZQbWRrMzRZak53U1pfSUNjY0dKd2xXaXRKenktdUduUjVBVTl3VE5ndw", - "kty": "RSA", - "n": "73XCXV2iiubSCEaFe26OpVnsBFlXwXh_yDCDyBqFgAFi5WdZTpRMJZoK0nn_vv2MvrXqFnw6IfXkwdsRGlMsNldVy36003gKa584CNksxfenwJZcF-huASUrSJEFr-3c0fMT_pLyAc7yf3rNCdRegzbBXSvIGKQpaeIjIFYftAPd9tjGA_SuYWVQDsSh3MeGbB4wt0lArAyFZ4f5o7SSxSDRCUF3ng3CB_QKUAaDHHgXrcNG_gPpgqQZjsDJ0VwMXjFKxQmskbH-dfsQ05znQsYn3pjcd_TEZ-Yu765_L5uxUrkEy_KnQXe1iqaQHcnfBWKXt18NAuBfgmKsv8gnxQ", - "p": "_RPgbiQcFu8Ekp-tC-Kschpag9iaLc9aDqrxE6GWuThEdExGngP_p1I7Qd7gXHHTMXLp1c4gH2cKx4AkfQyKny2RJGtV2onQButUU5r0gwnlqqycIA2Dc9JiH85PX2Z889TKJUlVETfYbezHbKhdsazjjsXCQ6p9JfkmgfBQOXM", - "q": "8jmgnadtwjMt96iOaoL51irPRXONO82tLM2AAZAK5Obsj23bZ9LFiw2Joh5oCSFdoUcRhbbIhCIv2aT4T_XKnDGnddrkxpF5Xgu0-hPNYnJx5m4kuzerot4j79Tx6qO-bshaaGz50MHs1vHSeFaDVN4fvh_hDWpV1BCNI0PKK-c" -} -SHA-256: pvPmdk34YjNwSZ_ICccGJwlWitJzy-uGnR5AU9wTNgw -```` - - -##### Run the server -```` -python -m flask_op.server private/conf.yaml -```` - -Then open your browser to `https://127.0.0.1:5000/.well-known/openid-configuration` to get the OpenID Provider Configuration resource. - - -##### Install OidcRP and configure flask-rp - -It uses `JWTConnect-Python-OidcRP` as Relaing Party for tests, see [related page](https://github.com/openid/JWTConnect-Python-OidcRP). -You can run a working instance of `JWTConnect-Python-OidcRP.flask_rp` with: - -```` -pip install git+https://github.com/openid/JWTConnect-Python-OidcRP.git - -# get entire project to have examples files -git clone https://github.com/openid/JWTConnect-Python-OidcRP.git -cd JWTConnect-Python-OidcRP - -# run it as it come -python3 -m flask_rp.wsgi flask_rp/conf.yaml - -# if you use django_op -RP_LOGFILE_NAME="./flrp.django.log" python3 -m flask_rp.wsgi django_op/example/data/oidc_rp/conf.django.yaml -```` - -Now you can connect to `https://127.0.0.1:8090/` to see the RP landing page and select your authentication endpoint. - - -### Authentication examples - -![RP](doc/images/1.png) - -Get to the RP landing page to choose your authentication endpoint. The first option aims to use _Provider Discovery_. - ----------------------------------- - -![OP Auth](doc/images/2.png) - -AS/OP accepted our authentication request and prompt to us the login form. Read passwd.json file to get credentials. - ----------------------------------- - -![Access](doc/images/3.png) - -The identity representation with the information fetched from the user info endpoint. - ----------------------------------- - -![Logout](doc/images/4.png) - -We can even test the single logout +- Roland Hedberg 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/django_op/.gitignore b/django_op/.gitignore deleted file mode 100644 index 302d5602..00000000 --- a/django_op/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -*/db.sqlite3 -example/db.sqlite3 -example/data/static/* -!example/data/static/README.md -__pycache__/* -*.pyc - diff --git a/django_op/README.md b/django_op/README.md deleted file mode 100644 index 3fd068d2..00000000 --- a/django_op/README.md +++ /dev/null @@ -1,6 +0,0 @@ -djangoioidc-op --------------- - -Moved permanently to: - -https://github.com/peppelinux/django-oidc-op diff --git a/doc/source/howto.rst b/doc/source/howto.rst deleted file mode 100644 index 2cd7cf92..00000000 --- a/doc/source/howto.rst +++ /dev/null @@ -1,110 +0,0 @@ -.. _oidcop: - -*************************** -The OpenID Connect Provider -*************************** - -============ -Introduction -============ - -This documentation are here to show you how to 'build' an OP using the -classes and functions provided by oidcendpoint. - -If you are just going to build a standard OP you only have to write the -configuration file. If you want to add or replace functionality this document -should be able to tell you how. - -Setting up an OP means making a number if decisions. Like, should the OP support -Web Finger (https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery), -dynamic discovery (https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig) -and dynamic client registration (https://openid.net/specs/openid-connect-registration-1_0.html). - -All these are services you can access at endpoints. The total set of endpoints -that this package supports are - -- webfinger -- provider_info -- registration -- authorization -- token -- refresh_token -- userinfo -- end_session - - -======================== -Configuration directives -======================== - ------- -issuer ------- - -The issuer ID of the OP. - ------------- -capabilities ------------- - -This covers most of the basic functionality of the OP. The key words are the -same as defined in -https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata . -A couple of things are defined else where. Like the endpoints, issuer id, -jwks_uri and the authentication methods at the token endpoint. - -An example:: - - response_types_supported: - - code - - token - - id_token - - "code token" - - "code id_token" - - "id_token token" - - "code id_token token" - - none - response_modes_supported: - - query - - fragment - - form_post - subject_types_supported: - - public - - pairwise - grant_types_supported: - - authorization_code - - implicit - - urn:ietf:params:oauth:grant-type:jwt-bearer - - refresh_token - claim_types_supported: - - normal - - aggregated - - distributed - claims_parameter_supported: True - request_parameter_supported: True - request_uri_parameter_supported: True - frontchannel_logout_supported: True - frontchannel_logout_session_supported: True - backchannel_logout_supported: True - backchannel_logout_session_supported: True - check_session_iframe: https://127.0.0.1:5000/check_session_iframe - - --------- -id_token --------- - -Defines which class that handles creating an ID Token and possibly also -arguments used when initiating that class. -An example:: - - id_token: - class: oidcendpoint.id_token.IDToken - kwargs: - default_claims: - email: - essential: True - email_verified: - essential: True - - diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..d0c3cbf1 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..6247f7e2 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/doc/images/1.png b/docs/source/_images/1.png similarity index 100% rename from doc/images/1.png rename to docs/source/_images/1.png diff --git a/doc/images/2.png b/docs/source/_images/2.png similarity index 100% rename from doc/images/2.png rename to docs/source/_images/2.png diff --git a/doc/images/3.png b/docs/source/_images/3.png similarity index 100% rename from doc/images/3.png rename to docs/source/_images/3.png diff --git a/doc/images/4.png b/docs/source/_images/4.png similarity index 100% rename from doc/images/4.png rename to docs/source/_images/4.png diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css new file mode 100644 index 00000000..5584d1b7 --- /dev/null +++ b/docs/source/_static/custom.css @@ -0,0 +1,82 @@ +body, +h1, h2, +.rst-content .toctree-wrapper p.caption, +h3, h4, h5, h6, +legend{ + font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; +} + +.wy-side-nav-search{ + background: #ffffff; +} + +.wy-side-nav-search>a, +.wy-side-nav-search .wy-dropdown>a{ + color: #9b9c9e; + font-weight: normal; +} + +.wy-menu-vertical header, +.wy-menu-vertical p.caption{ + color: #fff; + font-size:85%; +} + +.wy-nav-top{ + background: #fff; + border-bottom: 1px solid #f7f5f5; +} + +.wy-nav-top a{ + display: block; + color: #9b9c9e; + font-weight: normal; +} + +.wy-nav-top i{ + color: #BE0417; +} + +.wy-nav-top img{ + border-radius: 0; + background: none; + width: 65%; +} + +img{ + height: auto !important; +} + +.document{ + text-align: justify; +} + +h1{ + text-align: left; +} + +#logo_main{ + margin-bottom: 0; +} + +#title_under_logo{ + margin-bottom: 1em; +} + +.alert-danger { + color: #721c24; + background-color: #f8d7da; + border-color: #f5c6cb; +} +.alert-primary { + color: #004085; + background-color: #cce5ff; + border-color: #b8daff; +} +.alert { + position: relative; + padding: .75rem 1.25rem; + margin-bottom: 1rem; + border: 1px solid transparent; + border-radius: .25rem; +} diff --git a/docs/source/_static/logo.png b/docs/source/_static/logo.png new file mode 100644 index 00000000..25756b6e Binary files /dev/null and b/docs/source/_static/logo.png differ diff --git a/docs/source/_templates/idpy_template/footer.html b/docs/source/_templates/idpy_template/footer.html new file mode 100644 index 00000000..8cbf152b --- /dev/null +++ b/docs/source/_templates/idpy_template/footer.html @@ -0,0 +1,53 @@ + diff --git a/docs/source/_templates/idpy_template/layout.html b/docs/source/_templates/idpy_template/layout.html new file mode 100644 index 00000000..1bca49b8 --- /dev/null +++ b/docs/source/_templates/idpy_template/layout.html @@ -0,0 +1,53 @@ +{% extends "!layout.html" %} + +{% block extrahead %} + + +{% endblock %} + + + +{% block sidebartitle %} + +{% if logo %} + {# Not strictly valid HTML, but it's the only way to display/scale + it properly, without weird scripting or heaps of work + #} + +{% endif %} + +{% if logo and theme_logo_only %} + + +{% if theme_display_version %} + {%- set nav_version = version %} + {% if READTHEDOCS and current_version %} + {%- set nav_version = current_version %} + {% endif %} + {% if nav_version %} +
+ {{ nav_version }} +
+ {% endif %} +{% endif %} + +{% include "searchbox.html" %} + +{% endblock %} + + + + +{% block mobile_nav %} + + + + + + {{ project }} + +{% endblock %} diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 00000000..34382356 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,56 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) +from recommonmark.parser import CommonMarkParser + +# -- Project information ----------------------------------------------------- + +project = 'oidcop' +copyright = '2021, Identity Python' +author = 'Giuseppe De Marco, Roland Hedberg' + +# The full version, including alpha/beta/rc tags +release = '0.1.0' + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ['sphinxcontrib.images', 'recommonmark'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates/idpy_template'] +html_logo = "_static/logo.png" + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +source_suffix = ['.rst', '.md'] diff --git a/docs/source/contents/conf.rst b/docs/source/contents/conf.rst new file mode 100644 index 00000000..d12ba6e1 --- /dev/null +++ b/docs/source/contents/conf.rst @@ -0,0 +1,602 @@ +======================== +Configuration directives +======================== + +------ +issuer +------ + +The issuer ID of the OP, a unique value in URI format. + + +-------------- +session params +-------------- + +Configuration parameters used by session manager + + "session_params": { + "password": "__password_used_to_encrypt_access_token_sid_value", + "salt": "salt involved in session sub hash ", + "sub_func": { + "public": { + "class": "oidcop.session.manager.PublicID", + "kwargs": { + "salt": "sdfsdfdsf" + } + }, + "pairwise": { + "class": "oidcop.session.manager.PairWiseID", + "kwargs": { + "salt": "sdfsdfsdf" + } + } + } + }, + +password +######## + +Encryption key used to encrypt the SessionID (sid) in access_token. +If unset it will be random. + + +salt +#### + +Salt, value or filename, used in sub_funcs (pairwise, public) for creating the opaque hash of *sub* claim. + +------ +add_on +------ + +An example:: + + "add_on": { + "pkce": { + "function": "oidcop.oidc.add_on.pkce.add_pkce_support", + "kwargs": { + "essential": false, + "code_challenge_method": "S256 S384 S512" + } + }, + "claims": { + "function": "oidcop.oidc.add_on.custom_scopes.add_custom_scopes", + "kwargs": { + "research_and_scholarship": [ + "name", + "given_name", + "family_name", + "email", + "email_verified", + "sub", + "iss", + "eduperson_scoped_affiliation" + ] + } + } + } + +-------------- +authentication +-------------- + +An example:: + + "authentication": { + "user": { + "acr": "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword", + "class": "oidcop.user_authn.user.UserPassJinja2", + "kwargs": { + "verify_endpoint": "verify/user", + "template": "user_pass.jinja2", + "db": { + "class": "oidcop.util.JSONDictDB", + "kwargs": { + "filename": "passwd.json" + } + }, + "page_header": "Testing log in", + "submit_btn": "Get me in!", + "user_label": "Nickname", + "passwd_label": "Secret sauce" + } + } + }, + +------------ +capabilities +------------ + +This covers most of the basic functionality of the OP. The key words are the +same as defined in `OIDC Discovery `_. +A couple of things are defined else where. Like the endpoints, issuer id, +jwks_uri and the authentication methods at the token endpoint. + +An example:: + + response_types_supported: + - code + - token + - id_token + - "code token" + - "code id_token" + - "id_token token" + - "code id_token token" + - none + response_modes_supported: + - query + - fragment + - form_post + subject_types_supported: + - public + - pairwise + grant_types_supported: + - authorization_code + - implicit + - urn:ietf:params:oauth:grant-type:jwt-bearer + - refresh_token + claim_types_supported: + - normal + - aggregated + - distributed + claims_parameter_supported: True + request_parameter_supported: True + request_uri_parameter_supported: True + frontchannel_logout_supported: True + frontchannel_logout_session_supported: True + backchannel_logout_supported: True + backchannel_logout_session_supported: True + check_session_iframe: https://127.0.0.1:5000/check_session_iframe + + +----------- +cookie_name +----------- + +An example:: + + "cookie_name": { + "session": "oidc_op", + "register": "oidc_op_rp", + "session_management": "sman" + }, + +------------- +cookie_dealer +------------- + +An example:: + + "cookie_dealer": { + "class": "oidcop.cookie.CookieDealer", + "kwargs": { + "sign_jwk": { + "filename": "private/cookie_sign_jwk.json", + "type": "OCT", + "kid": "cookie_sign_key_id" + }, + "enc_jwk": { + "filename": "private/cookie_enc_jwk.json", + "type": "OCT", + "kid": "cookie_enc_key_id" + }, + "default_values": { + "name": "oidc_op", + "domain": "127.0.0.1", + "path": "/", + "max_age": 3600 + } + } + }, + +-------- +endpoint +-------- + +An example:: + + "endpoint": { + "webfinger": { + "path": ".well-known/webfinger", + "class": "oidcop.oidc.discovery.Discovery", + "kwargs": { + "client_authn_method": null + } + }, + "provider_info": { + "path": ".well-known/openid-configuration", + "class": "oidcop.oidc.provider_config.ProviderConfiguration", + "kwargs": { + "client_authn_method": null + } + }, + "registration": { + "path": "registration", + "class": "oidcop.oidc.registration.Registration", + "kwargs": { + "client_authn_method": None, + "client_secret_expiration_time": 432000, + "client_id_generator": { + "class": 'oidcop.oidc.registration.random_client_id', + "kwargs": { + "seed": "that-optional-random-value" + } + } + } + }, + "registration_api": { + "path": "registration_api", + "class": "oidcop.oidc.read_registration.RegistrationRead", + "kwargs": { + "client_authn_method": [ + "bearer_header" + ] + } + }, + "introspection": { + "path": "introspection", + "class": "oidcop.oauth2.introspection.Introspection", + "kwargs": { + "client_authn_method": [ + "client_secret_post", + "client_secret_basic", + "client_secret_jwt", + "private_key_jwt" + ] + "release": [ + "username" + ] + } + }, + "authorization": { + "path": "authorization", + "class": "oidcop.oidc.authorization.Authorization", + "kwargs": { + "client_authn_method": null, + "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", + "none" + ], + "response_modes_supported": [ + "query", + "fragment", + "form_post" + ] + } + }, + "token": { + "path": "token", + "class": "oidcop.oidc.token.Token", + "kwargs": { + "client_authn_method": [ + "client_secret_post", + "client_secret_basic", + "client_secret_jwt", + "private_key_jwt" + ] + } + }, + "userinfo": { + "path": "userinfo", + "class": "oidcop.oidc.userinfo.UserInfo", + "kwargs": { + "claim_types_supported": [ + "normal", + "aggregated", + "distributed" + ] + } + }, + "end_session": { + "path": "session", + "class": "oidcop.oidc.session.Session", + "kwargs": { + "logout_verify_url": "verify_logout", + "post_logout_uri_path": "post_logout", + "signing_alg": "ES256", + "frontchannel_logout_supported": true, + "frontchannel_logout_session_supported": true, + "backchannel_logout_supported": true, + "backchannel_logout_session_supported": true, + "check_session_iframe": "check_session_iframe" + } + } + } + +You can specify which algoritms are supported, for example in userinfo_endpoint:: + + "userinfo_signing_alg_values_supported": OIDC_SIGN_ALGS, + "userinfo_encryption_alg_values_supported": OIDC_ENC_ALGS, + +Or in authorization endpoint:: + + "request_object_encryption_alg_values_supported": OIDC_ENC_ALGS, + +------------ +httpc_params +------------ + +Parameters submitted to the web client (python requests). +In this case the TLS certificate will not be verified, to be intended exclusively for development purposes + +Example :: + + "httpc_params": { + "verify": false + }, + +---- +keys +---- + +An example:: + + "keys": { + "private_path": "private/jwks.json", + "key_defs": [ + { + "type": "RSA", + "use": [ + "sig" + ] + }, + { + "type": "EC", + "crv": "P-256", + "use": [ + "sig" + ] + } + ], + "public_path": "static/jwks.json", + "read_only": false, + "uri_path": "static/jwks.json" + }, + +*read_only* means that on each restart the keys will created and overwritten with new ones. +This can be useful during the first time the project have been executed, then to keep them as they are *read_only* would be configured to *True*. + +--------------- +login_hint2acrs +--------------- + +OIDC Login hint support, it's optional. +It matches the login_hint paramenter to one or more Authentication Contexts. + +An example:: + + "login_hint2acrs": { + "class": "oidcop.login_hint.LoginHint2Acrs", + "kwargs": { + "scheme_map": { + "email": [ + "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword" + ] + } + } + }, + +oidc-op supports the following authn contexts: + +- UNSPECIFIED, urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified +- INTERNETPROTOCOLPASSWORD, urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword +- MOBILETWOFACTORCONTRACT, urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract +- PASSWORDPROTECTEDTRANSPORT, urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport +- PASSWORD, urn:oasis:names:tc:SAML:2.0:ac:classes:Password +- TLSCLIENT, urn:oasis:names:tc:SAML:2.0:ac:classes:TLSClient +- TIMESYNCTOKEN, urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken + + +----- +authz +----- + +This configuration section refers to the authorization/authentication endpoint behaviour. +Scopes bound to an access token are strictly related to grant management, as part of what that endpoint does. +Regarding grant authorization we should have something like the following example. + +If you omit this section from the configuration (thus using some sort of default profile) +you'll have an Implicit grant authorization that leads granting nothing. +Add the below to your configuration and you'll see things changing. + + +An example:: + + "authz": { + "class": "oidcop.authz.AuthzHandling", + "kwargs": { + "grant_config": { + "usage_rules": { + "authorization_code": { + "supports_minting": ["access_token", "refresh_token", "id_token"], + "max_usage": 1 + }, + "access_token": {}, + "refresh_token": { + "supports_minting": ["access_token", "refresh_token"] + } + }, + "expires_in": 43200 + } + } + }, + +------------ +template_dir +------------ + +The HTML Template directory used by Jinja2, used by endpoint context + template loader, as:: + + Environment(loader=FileSystemLoader(template_dir), autoescape=True) + +An example:: + + "template_dir": "templates" + +For any further customization of template here an example of what used in django-oidc-op + + "authentication": { + "user": { + "acr": "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword", + "class": "oidc_provider.users.UserPassDjango", + "kwargs": { + "verify_endpoint": "verify/oidc_user_login/", + "template": "oidc_login.html", + + "page_header": "Testing log in", + "submit_btn": "Get me in!", + "user_label": "Nickname", + "passwd_label": "Secret sauce" + } + } + }, + +------------------ +token_handler_args +------------------ + +Token handler is an intermediate interface used by and endpoint to manage + the tokens' default behaviour, like lifetime and minting policies. + With it we can create a token that's linked to another, and keep relations between many tokens + in session and grants management. + +An example:: + + "token_handler_args": { + "jwks_def": { + "private_path": "private/token_jwks.json", + "read_only": false, + "key_defs": [ + { + "type": "oct", + "bytes": 24, + "use": [ + "enc" + ], + "kid": "code" + }, + { + "type": "oct", + "bytes": 24, + "use": [ + "enc" + ], + "kid": "refresh" + } + ] + }, + "code": { + "kwargs": { + "lifetime": 600 + } + }, + "token": { + "class": "oidcop.token.jwt_token.JWTToken", + "kwargs": { + "lifetime": 3600, + "add_claims": [ + "email", + "email_verified", + "phone_number", + "phone_number_verified" + ], + "add_claim_by_scope": true, + "aud": ["https://example.org/appl"] + } + }, + "refresh": { + "kwargs": { + "lifetime": 86400 + } + } + "id_token": { + "class": "oidcop.token.id_token.IDToken", + "kwargs": { + "base_claims": { + "email": None, + "email_verified": None, + }, + } + } + +jwks_defs can be replaced eventually by `jwks_file`:: + + "jwks_file": f"{OIDC_JWKS_PRIVATE_PATH}/token_jwks.json", + +You can even select wich algorithms to support in id_token, eg:: + + "id_token": { + "class": "oidcop.token.id_token.IDToken", + "kwargs": { + "id_token_signing_alg_values_supported": [ + "RS256", + "RS512", + "ES256", + "ES512", + "PS256", + "PS512", + ], + "id_token_encryption_alg_values_supported": [ + "RSA-OAEP", + "RSA-OAEP-256", + "A192KW", + "A256KW", + "ECDH-ES", + "ECDH-ES+A128KW", + "ECDH-ES+A192KW", + "ECDH-ES+A256KW", + ], + "id_token_encryption_enc_values_supported": [ + 'A128CBC-HS256', + 'A192CBC-HS384', + 'A256CBC-HS512', + 'A128GCM', + 'A192GCM', + 'A256GCM' + ], + } + } + +-------- +userinfo +-------- + +An example:: + + "userinfo": { + "class": "oidcop.user_info.UserInfo", + "kwargs": { + "db_file": "users.json" + } + } + +This is somethig that can be customized. +For example in the django-oidc-op implementation is used something like +the following:: + + "userinfo": { + "class": "oidc_provider.users.UserInfo", + "kwargs": { + "claims_map": { + "phone_number": "telephone", + "family_name": "last_name", + "given_name": "first_name", + "email": "email", + "verified_email": "email", + "gender": "gender", + "birthdate": "get_oidc_birthdate", + "updated_at": "get_oidc_lastlogin" + } + } + } diff --git a/docs/source/contents/developers.md b/docs/source/contents/developers.md new file mode 100644 index 00000000..cac13634 --- /dev/null +++ b/docs/source/contents/developers.md @@ -0,0 +1,7 @@ +Tests +----- + +```` +pip install -r requirements-dev.txt +pytest --cov=oidcop tests/ +```` diff --git a/docs/source/contents/faq.md b/docs/source/contents/faq.md new file mode 100644 index 00000000..c38a5f5d --- /dev/null +++ b/docs/source/contents/faq.md @@ -0,0 +1,4 @@ +FAQ +----- + +* diff --git a/docs/source/contents/intro.rst b/docs/source/contents/intro.rst new file mode 100644 index 00000000..5486e076 --- /dev/null +++ b/docs/source/contents/intro.rst @@ -0,0 +1,116 @@ +*************************** +The OpenID Connect Provider +*************************** + +============ +Introduction +============ + +This documentation are here to show you how to 'build' an OP using the +classes and functions provided by oidcop. + +OAuth2 and thereby OpenID Connect (OIDC) are built on a request-response paradigm. +The RP issues a request and the OP returns a response. + +The OIDC core standard defines a set of such request-responses. +This is a basic list of request-responses and the normal sequence in which they +occur: + +1. Provider discovery (WebFinger) +2. Provider Info Discovery +3. Client registration +4. Authorization/Authentication +5. Access token +6. User info + +If you are just going to build a standard OP you only have to write the +configuration file and of course add authentication and user consent services. +If you want to add or replace functionality this document should be able to +tell you how. + +Setting up an OP means making a number if decisions. Like, should the OP support +WebFinger_ , `dynamic discovery`_ and/or `dynamic client registration`_ . + +All these are services you can access at endpoints. The total set of endpoints +that this package supports are + +- webfinger +- provider_info +- registration +- authorization +- token +- refresh_token +- userinfo +- end_session + +.. _WebFinger: https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery +.. _dynamic discovery: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig +.. _dynamic client registration: https://openid.net/specs/openid-connect-registration-1_0.html + +=============== +Endpoint layout +=============== + +When an endpoint receives a request it has to do a number of things: + +- Verify that the client can issue the request (client authentication/authorization) +- Verify that the request is correct and that it contains the necessary information. +- Process the request, which includes applying server policies and gathering information. +- Construct the response + +I should note at this point that this package is expected to work within the +confines of a web server framework such that the actual receiving and sending +of the HTTP messages are dealt with by the framework. + +Based on the actions an endpoint has to perform a method call structure +has been constructed. It looks like this: + +1. parse_request + + - client_authentication (*) + - post_parse_request (*) + +2. process_request + +3. do_response + + - response_info + - construct + - pre_construct (*) + - _parse_args + - post_construct (*) + - update_http_args + +Steps marked with '*' are places where extensions can be applied. + +*parse_request* expects as input the request itself in a number of formats and +also, if available, information about client authentication. The later is +normally the authorization element of the HTTP header. + +*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' + } + +cookie + MAY be present +http_headers + MAY be present +http_response + Already clear and formatted HTTP response +response + MUST be present +response_placement + If absent defaults to the endpoints response_placement parameter value or + if that is also missing 'url' +redirect_location + Where to send a redirect diff --git a/docs/source/contents/session_management.rst b/docs/source/contents/session_management.rst new file mode 100644 index 00000000..26e99371 --- /dev/null +++ b/docs/source/contents/session_management.rst @@ -0,0 +1,557 @@ +Session Management +================== + +- `About session management`_ + - `Design criteria`_ + - `Database layout`_ +- `The information structure`_ + - `Session key`_ + - `User session information`_ + - `Client session information`_ + - `Grant information`_ + - `Token`_ +- `Session Info API`_ +- `Grant API`_ +- `Token API`_ + +- `Session Manager API`_ + - `create_session`_ + - `add_grant`_ + - `find_token`_ + - `get_authentication_event`_ + - `get_client_session_info`_ + - `get_grant_by_response_type`_ + - `get_session_info`_ + - `get_session_info_by_token`_ + - `get_sids_by_user_id`_ + - `get_user_info`_ + - `grants`_ + - `revoke_client_session`_ + - `revoke_grant`_ + - `revoke_token`_ + + +About session management +------------------------ +.. _`About session management`: + +The OIDC Session Management draft defines session to be: + + Continuous period of time during which an End-User accesses a Relying + Party relying on the Authentication of the End-User performed by the + OpenID Provider. + +Note that we are dealing with a Single Sign On (SSO) context here. +If for some reason the OP does not want to support SSO then the +session management has to be done a bit differently. In that case each +session (user_id,client_id) would have its own authentication event. Not one +shared between the sessions. + +Design criteria ++++++++++++++++ +.. _`Design criteria`: + +So a session is defined by a user and a Relying Party. If one adds to that +that a user can have several sessions active at the same time each one against +a unique Relying Party we have the bases for session management. + +Furthermore the user may well decide on different rules for different +relying parties for releasing user +attributes, where and how issued access tokens could be used and whether +refresh tokens should be issued or not. + +We also need to keep track on which tokens where used to mint new tokens +such that we can easily revoked a suite of tokens all with a common ancestor. + +Database layout ++++++++++++++++ +.. _`Database layout`: + +The database is organized in 3 levels. The top one being the users. +Below that the Relying Parties and at the bottom what is called grants. + +Grants organize authorization codes, access tokens and refresh tokens (and +possibly other types of tokens) in a comprehensive way. More about that below. + +There may be many Relying Parties below a user and many grants below a +Relying Party. + +The information structure +------------------------- +.. _`The information structure`: + +As stated above there are 3 layers: user session information, client session +information and grants. But first the keys to the information. + +Session key ++++++++++++ +.. _`Session key`: + +A key to the session information is based on a list. The first item being the +user identifier, the second the client identifier and the third the grant +identifier. +If you only want the user session information then the key is a list with one +item the user id. If you want the client session information the key is a +list with 2 items (user_id, client_id). And lastly if you want a grant then +the key is a list with 3 elements (user_id, client_id, grant_id). + +Example:: + "diana;;KtEST70jZx1x;;85544c9cace411ebab53559c5425fcc0" + +A *session identifier* is constructed using the **session_key** function. +It takes as input the 3 elements list.:: + + session_id = session_key(user_id, client_id, grant_id) + + +Using the function **unpack_session_key** you can get the elements from a +session_id.:: + + user_id, client_id, grant_id = unpack_session_id(session_id) + + +User session information +++++++++++++++++++++++++ +.. _`User session information`: + +Houses the authentication event information which is the same for all session +connected to a user. +Here we also have a list of all the clients that this user has a session with. +Expressed as a dictionary this can look like this:: + + { + 'authentication_event': { + 'uid': 'diana', + 'authn_info': "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword", + 'authn_time': 1605515787, + 'valid_until': 1605519387 + }, + 'subordinate': ['client_1'] + } + + +Client session information +++++++++++++++++++++++++++ +.. _`Client session information`: + +The client specific information of the session information. +Presently only the authorization request and the subject identifier (sub). +The subordinates to this set of information are the grants:: + + { + 'authorization_request':{ + 'client_id': 'client_1', + 'redirect_uri': 'https://example.com/cb', + 'scope': ['openid', 'research_and_scholarship'], + 'state': 'STATE', + 'response_type': ['code'] + }, + 'sub': '117afe8d7bb0ace8e7fb2706034ab2d3fbf17f0fd4c949aa9c23aedd051cc9e3', + 'subordinate': ['e996c61227e711eba173acde48001122'], + 'revoked': False + } + +Grant information ++++++++++++++++++ +.. _`Grant information`: + +Grants are created by an authorization subsystem in an OP. If the grant is +created in connection with an user authentication the authorization system +might normally ask the user for usage consent and then base the construction +of the grant on that consent. + +If an authorization server can act as a Security Token Service (STS) as +defined by `Token Exchange [RFC-8693] `_ +then no user is involved. In the context of session management the STS is +equivalent to a user. + +Grant information contains information about user consent and issued tokens.:: + + { + "type": "grant", + "scope": ["openid", "research_and_scholarship"], + "authorization_details": null, + "claims": { + "userinfo": { + "sub": null, + "name": null, + "given_name": null, + "family_name": null, + "email": null, + "email_verified": null, + "eduperson_scoped_affiliation": null + } + }, + "resources": ["client_1"], + "issued_at": 1605452123, + "not_before": 0, + "expires_at": 0, + "revoked": false, + "issued_token": [ + { + "type": "authorization_code", + "issued_at": 1605452123, + "not_before": 0, + "expires_at": 1605452423, + "revoked": false, + "value": "Z0FBQUFBQmZzVUZieDFWZy1fbjE2ckxvZkFTVC1ZTHJIVlk0Z09tOVk1M0RsOVNDbkdfLTIxTUhILWs4T29kM1lmV015UEN1UGxrWkxLTkVXOEg1WVJLNjh3MGlhMVdSRWhYcUY4cGdBQkJEbzJUWUQ3UGxTUWlJVDNFUHFlb29PWUFKcjNXeHdRM1hDYzRIZnFrYjhVZnIyTFhvZ2Y0NUhjR1VBdzE0STVEWmJ3WkttTk1OYXQtTHNtdHJwYk1nWnl3MUJqSkdWZGFtdVNfY21VNXQxY3VzalpIczBWbGFueVk0TVZ2N2d2d0hVWTF4WG56TDJ6bz0=", + "usage_rules": { + "expires_in": 300, + "supports_minting": [ + "access_token", + "refresh_token", + "id_token" + ], + "max_usage": 1 + }, + "used": 0, + "based_on": null, + "id": "96d19bea275211eba43bacde48001122" + }, + { + "type": "access_token", + "issued_at": 1605452123, + "not_before": 0, + "expires_at": 1605452723, + "revoked": false, + "value": "Z0FBQUFBQmZzVUZiaWVRbi1IS2k0VW4wVDY1ZmJHeEVCR1hVODBaQXR6MWkzelNBRFpOS2tRM3p4WWY5Y1J6dk5IWWpnelRETGVpSG52b0d4RGhjOWphdWp4eW5xZEJwQzliaS16cXFCcmRFbVJqUldsR1Z3SHdTVVlWbkpHak54TmJaSTV2T3NEQ0Y1WFkxQkFyamZHbmd4V0RHQ3k1MVczYlYwakEyM010SGoyZk9tUVVxbWdYUzBvMmRRNVlZMUhRSnM4WFd2QzRkVmtWNVJ1aVdJSXQyWnpVTlRiZnMtcVhKTklGdzBzdDJ3RkRnc1A1UEw2Yz0=", + "usage_rules": { + "expires_in": 600, + }, + "used": 0, + "based_on": "Z0FBQUFBQmZzVUZieDFWZy1fbjE2ckxvZkFTVC1ZTHJIVlk0Z09tOVk1M0RsOVNDbkdfLTIxTUhILWs4T29kM1lmV015UEN1UGxrWkxLTkVXOEg1WVJLNjh3MGlhMVdSRWhYcUY4cGdBQkJEbzJUWUQ3UGxTUWlJVDNFUHFlb29PWUFKcjNXeHdRM1hDYzRIZnFrYjhVZnIyTFhvZ2Y0NUhjR1VBdzE0STVEWmJ3WkttTk1OYXQtTHNtdHJwYk1nWnl3MUJqSkdWZGFtdVNfY21VNXQxY3VzalpIczBWbGFueVk0TVZ2N2d2d0hVWTF4WG56TDJ6bz0=", + "id": "96d1c840275211eba43bacde48001122" + } + ], + "id": "96d16d3c275211eba43bacde48001122" + } + +The parameters are described below + +scope +::::: + +This is the scope that was chosen for this grant. Either by the user or by +some rules that the Authorization Server runs by. + +authorization_details +::::::::::::::::::::: + +Presently a place hold. But this is expected to be information on how the +authorization was performed. What input was used and so on. + +claims +:::::: + +The set of claims that should be returned in different circumstances. The +syntax that is defined in +https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter +is used. With one addition, beside *userinfo* and *id_token* we have added +*introspection*. + +resources +::::::::: + +This are the resource servers and other entities that should be accepted +as users of issued access tokens. + +issued_at +::::::::: + +When the grant was created. Its value is a JSON number representing the number +of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time. + +not_before +:::::::::: +If the usage of the grant should be delay, this is when it can start being used. +Its value is a JSON number representing the number +of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time. + +expires_at +:::::::::: +When the grant expires. +Its value is a JSON number representing the number +of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time. + +revoked +::::::: +If the grant has been revoked. + +issued_token +:::::::::::: +Tokens that has been issued based on this grant. There is no limitation +as to which tokens can be issued. Though presently we only have: + +- authorization_code, +- access_token and +- refresh_token + +id +:: +The grant identifier. + +Token ++++++ +.. _`Token`: + +As mention above there are presently only 3 token types that are defined: + +- authorization_code, +- access_token and +- refresh_token + +A token is described as follows:: + + { + "type": "authorization_code", + "issued_at": 1605452123, + "not_before": 0, + "expires_at": 1605452423, + "revoked": false, + "value": "Z0FBQUFBQmZzVUZieDFWZy1fbjE2ckxvZkFTVC1ZTHJIVlk0Z09tOVk1M0RsOVNDbkdfLTIxTUhILWs4T29kM1lmV015UEN1UGxrWkxLTkVXOEg1WVJLNjh3MGlhMVdSRWhYcUY4cGdBQkJEbzJUWUQ3UGxTUWlJVDNFUHFlb29PWUFKcjNXeHdRM1hDYzRIZnFrYjhVZnIyTFhvZ2Y0NUhjR1VBdzE0STVEWmJ3WkttTk1OYXQtTHNtdHJwYk1nWnl3MUJqSkdWZGFtdVNfY21VNXQxY3VzalpIczBWbGFueVk0TVZ2N2d2d0hVWTF4WG56TDJ6bz0=", + "usage_rules": { + "expires_in": 300, + "supports_minting": [ + "access_token", + "refresh_token", + "id_token" + ], + "max_usage": 1 + }, + "used": 0, + "based_on": null, + "id": "96d19bea275211eba43bacde48001122" + } + + +type +:::: +The type of token. + +issued_at +::::::::: +When the token was created. Its value is a JSON number representing the number +of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time. + +not_before +:::::::::: +If the start of the usage of the token is to be delay, this is until when. +Its value is a JSON number representing the number +of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time. + +expires_at +:::::::::: +When the token expires. +Its value is a JSON number representing the number +of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time. + +revoked +::::::: +If the token has been revoked. + +value +::::: +This is the value that appears in OIDC protocol exchanges. + +usage_rules +::::::::::: +Rules as to how this token can be used: + +expires_in + Used to calculate expires_at + +supports_minting + The tokens types that can be minted based on this token. Typically a code + can be used to mint ID tokens and access and refresh tokens. + +max_usage + How many times this token can be used (being used is presently defined as + used to mint other tokens). An authorization_code token can according to + the OIDC standard only be used once but then to, in the same session, + mint more then one token. + +used +:::: + How many times the token has been used + +based_on +:::::::: + Reference to the token that was used to mint this token. Might be empty if the + token was minted based on the grant it belongs to. + +id +:: + Token identifier + +Session Info API +---------------- +.. _`Session Info API`: + +add_subordinate ++++++++++++++++ +.. _`add_subordinate`: + + ... + +remove_subordinate +++++++++++++++++++ +.. _`removed_subordinate`: + + ... + +revoke +++++++ +.. _`revoke`: + + ... + +is_revoked +++++++++++ +.. _`is_revoked`: + + ... + +to_json ++++++++ +.. _`to_json`: + + ... + +from_json ++++++++++ +.. _`from_json`: + + ... + +Grant API +--------- +.. _`Grant API`: + + ... + +Token API +--------- +.. _`Token API`: + + ... + +Session Manager API +------------------- +.. _`Session Manager API`: + + ... + +create_session +++++++++++++++ +.. _create_session: + +Creating a new session is done by running the create_session method of +the class SessionManager. The create_session methods takes the following +arguments. + +authn_event + An AuthnEvent class instance that describes the authentication event. + +auth_req + The Authentication request + +client_id + The client Identifier + +user_id + The user identifier + +sector_identifier + A possible sector identifier to be used when constructing a pairwise + subject identifier + +sub_type + The type of subject identifier that should be constructed. It can either be + *pairwise* or *public*. + +So a typical command would look like this:: + + + authn_event = create_authn_event(self.user_id) + session_manager.create_session(authn_event=authn_event, auth_req=auth_req, + user_id=self.user_id, client_id=client_id, + sub_type=sub_type, sector_identifier=sector_identifier) + +add_grant ++++++++++ +.. _add_grant: + + add_grant(self, user_id, client_id, **kwargs) + +find_token +++++++++++ +.. _find_token: + + find_token(self, session_id, token_value) + +get_authentication_event +++++++++++++++++++++++++ +.. _get_authentication_event: + + get_authentication_event(self, session_id) + + +get_client_session_info ++++++++++++++++++++++++ +.. _get_client_session_info: + + get_client_session_info(self, session_id) + +get_grant_by_response_type +++++++++++++++++++++++++++ +.. _get_grant_by_response_type: + + get_grant_by_response_type(self, user_id, client_id) + +get_session_info +++++++++++++++++ +.. _get_session_info: + + get_session_info(self, session_id) + +get_session_info_by_token ++++++++++++++++++++++++++ +.. _get_session_info_by_token: + + get_session_info_by_token(self, token_value) + +get_sids_by_user_id ++++++++++++++++++++ +.. _get_sids_by_user_id: + + get_sids_by_user_id(self, user_id) + +get_user_info ++++++++++++++ +.. _get_user_info: + + get_user_info(self, uid) + +grants +++++++ +.. _grants: + + grants(self, session_id) + +revoke_client_session ++++++++++++++++++++++ +.. _revoke_client_session: + + revoke_client_session(self, session_id) + +revoke_grant +++++++++++++ +.. _revoke_grant: + + revoke_grant(self, session_id) + +revoke_token +++++++++++++ +.. _revoke_token: + + revoke_token(self, session_id, token_value, recursive=False) diff --git a/docs/source/contents/setup.md b/docs/source/contents/setup.md new file mode 100644 index 00000000..ad78606a --- /dev/null +++ b/docs/source/contents/setup.md @@ -0,0 +1,100 @@ +Setup +----- + +Create an environment + + virtualenv -ppython3 env + source env/bin/activate + +Install + + pip install oidcop + +Get the usage examples + + git clone https://github.com/identitypython/oidc-op.git + cd oidc-op/example/flask_op/ + bash run.sh + + +To configure a standard OIDC Provider you have to edit the oidcop configuration file. +See `example/flask_op/config.json` to get in. + + ~/DEV/IdentityPython/OIDC/oidc-op/example/flask_op$ bash run.sh + 2021-05-02 14:57:44,727 root DEBUG Configured logging using dictionary + 2021-05-02 14:57:44,728 oidcop.configure DEBUG Set server password to {'kty': 'oct', 'use': 'sig', 'k': 'n4G9OjOixYMOotXvP15grwq0peN2zq9I'} + * Serving Flask app "oidc_op" (lazy loading) + * Environment: production + WARNING: This is a development server. Do not use it in a production deployment. + Use a production WSGI server instead. + * Debug mode: on + 2021-05-02 14:57:44,764 werkzeug INFO * Running on https://127.0.0.1:5000/ (Press CTRL+C to quit) + 2021-05-02 14:57:44,765 werkzeug INFO * Restarting with stat + 2021-05-02 14:57:45,011 root DEBUG Configured logging using dictionary + 2021-05-02 14:57:45,011 oidcop.configure DEBUG Set server password to {'kty': 'oct', 'use': 'sig', 'k': 'bceYal7bK9zvlBAA7-23lsi5crcv_8Cd'} + 2021-05-02 14:57:45,037 werkzeug WARNING * Debugger is active! + 2021-05-02 14:57:45,092 werkzeug INFO * Debugger PIN: 560-973-597 + + +Then open your browser to `https://127.0.0.1:5000/.well-known/openid-configuration` to get the OpenID Provider Configuration resource. + + +-------------------- +JWK Set (JWKS) files +-------------------- +see: [cryptojwt documentation](https://cryptojwt.readthedocs.io/en/latest/keyhandling.html private/cookie_sign_jwk.json diff --git a/docs/source/contents/usage.md b/docs/source/contents/usage.md new file mode 100644 index 00000000..6c51e765 --- /dev/null +++ b/docs/source/contents/usage.md @@ -0,0 +1,92 @@ +Usage +----- + +Some examples, how to run flask_op and django_op, but also some typical configuration in relation to common use cases. + + + +Configure flask-rp +------------------ + +_JWTConnect-Python-OidcRP_ is Relaing Party for tests, see [related page](https://github.com/openid/JWTConnect-Python-OidcRP). +You can run a working instance of `JWTConnect-Python-OidcRP.flask_rp` with: + +```` +pip install git+https://github.com/openid/JWTConnect-Python-OidcRP.git + +# get entire project to have examples files +git clone https://github.com/openid/JWTConnect-Python-OidcRP.git +cd JWTConnect-Python-OidcRP/example/flask_rp + +# run it as it come +bash run.sh +```` + +Now you can connect to `https://127.0.0.1:8090/` to see the RP landing page and select your authentication endpoint. + +### Authentication examples + +![RP](../_images/1.png) + +Get to the RP landing page to choose your authentication endpoint. The first option aims to use _Provider Discovery_. + +---------------------------------- + +![OP Auth](../_images/2.png) + +AS/OP accepted our authentication request and prompt to us the login form. Read passwd.json file to get credentials. + +---------------------------------- + +![Access](../_images/3.png) + +The identity representation with the information fetched from the user info endpoint. + +---------------------------------- + +![Logout](../_images/4.png) + +We can even test the single logout + + +Introspection endpoint +---------------------- + +Here an example about how to consume oidc-op introspection endpoint. +This example uses a client with an HTTP Basic Authentication:: + + import base64 + import requests + + TOKEN = "eyJhbGciOiJFUzI1NiIsImtpZCI6IlQwZGZTM1ZVYUcxS1ZubG9VVTQwUXpJMlMyMHpjSHBRYlMxdGIzZ3hZVWhCYzNGaFZWTlpTbWhMTUEifQ.eyJzY29wZSI6IFsib3BlbmlkIiwgInByb2ZpbGUiLCAiZW1haWwiLCAiYWRkcmVzcyIsICJwaG9uZSJdLCAiYXVkIjogWyJvTHlSajdzSkozWHZBWWplRENlOHJRIl0sICJqdGkiOiAiOWQzMjkzYjZiYmNjMTFlYmEzMmU5ODU0MWIwNzE1ZWQiLCAiY2xpZW50X2lkIjogIm9MeVJqN3NKSjNYdkFZamVEQ2U4clEiLCAic3ViIjogIm9MeVJqN3NKSjNYdkFZamVEQ2U4clEiLCAic2lkIjogIlowRkJRVUZCUW1keGJIVlpkRVJKYkZaUFkxQldaa0pQVUVGc1pHOUtWWFZ3VFdkZmVEY3diMVprYmpSamRrNXRMVzB4YTNnelExOHlRbHBHYTNRNVRHZEdUUzF1UW1sMlkzVnhjRE5sUm01dFRFSmxabGRXYVhJeFpFdHVSV2xtUzBKcExWTmFaRzV3VjJodU0yNXlSbTU0U1ZWVWRrWTRRM2x2UWs1TlpVUk9SazlGVlVsRWRteGhjWGx2UWxWRFdubG9WbTFvZGpORlVUSnBkaTFaUTFCcFptZFRabWRDVWt0YVNuaGtOalZCWVhkcGJFNXpaV2xOTTFCMk0yaE1jMDV0ZGxsUlRFc3dObWxsYUcxa1lrTkhkemhuU25OaWFWZE1kVUZzZDBwWFdWbzFiRWhEZFhGTFFXWTBPVzl5VjJOUk4zaGtPRDA9IiwgInR0eXBlIjogIlQiLCAiaXNzIjogImh0dHBzOi8vMTI3LjAuMC4xOjgwMDAiLCAiaWF0IjogMTYyMTc3NzMwNSwgImV4cCI6IDE2MjE3ODA5MDV9.pVqxUNznsoZu9ND18IEMJIHDOT6_HxzoFiTLsniNdbAdXTuOoiaKeRTqtDyjT9WuUPszdHkVjt5xxeFX8gQMuA" + + data = { + 'token': TOKEN, + 'token_type_hint': 'access_token' + } + + _basic_secret = base64.b64encode( + f'{"oLyRj7sJJ3XvAYjeDCe8rQ"}:{"53fb49f2a6501ec775355c89750dc416744a3253138d5a04e409b313"}'.encode() + ) + headers = { + 'Authorization': f"Basic {_basic_secret.decode()}" + } + + requests.post('https://127.0.0.1:8000/introspection', verify=False, data=data, headers=headers) + + +oidc-op will return a json response like this:: + + { + "active": true, + "scope": "openid profile email address phone", + "client_id": "oLyRj7sJJ3XvAYjeDCe8rQ", + "token_type": "access_token", + "exp": 0, + "iat": 1621777305, + "sub": "a7b0dea2958aec275a789d7d7dc8e7d09c6316dd4fc6ae92742ed3297e14dded", + "iss": "https://127.0.0.1:8000", + "aud": [ + "oLyRj7sJJ3XvAYjeDCe8rQ" + ] + } diff --git a/docs/source/diagrams/session_relations.mermaid b/docs/source/diagrams/session_relations.mermaid new file mode 100644 index 00000000..0673d9da --- /dev/null +++ b/docs/source/diagrams/session_relations.mermaid @@ -0,0 +1,40 @@ +erDiagram + Relying-Party ||--|{ Grant : relying_party_id + Relying-Party { + String client_id + String client_salt + String registration_access_token + String registration_client_uri + String client_id_issued_at + String client_secret + datetime client_secret_expires_at + String application_type + List response_types + List contacts + String token_endpoint_auth_method + List response_types + List contacts + String token_endpoint_auth_method + List post_logout_redirect_uris + String jwks_uri + String frontchannel_logout_uri + String frontchannel_logout_session_required + String backchannel_logout_uri + String grant_types + List redirect_uris + } + User ||--o{ Grant : user_id + User { + string username + string firstName + string lastName + string email + } + Grant { + int user_id + int relying_party + String session_key + String token_type + JSon authentication_event + menthod1() + } diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 00000000..99a12111 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,73 @@ +Welcome to Idpy OIDC-op Documentation +====================================== + +This project is a Python implementation of an **OIDC Provider** on top of `jwtconnect.io `_ +that shows you how to 'build' an OP using the classes and functions provided by oidc-op. + +If you are just going to build a standard OP you only have to write the configuration file. If you want to add or replace functionality this documentation +should be able to tell you how. + +Idpy OIDC-op implements the following standards: + +* `OpenID Connect Core 1.0 incorporating errata set 1 `_ +* `Web Finger `_ +* `OpenID Connect Discovery 1.0 incorporating errata set 1 `_ +* `OpenID Connect Dynamic Client Registration 1.0 incorporating errata set 1 `_ +* `OpenID Connect Session Management 1.0 `_ +* `OpenID Connect Back-Channel Logout 1.0 `_ +* `OpenID Connect Front-Channel Logout 1.0 `_ +* `OAuth2 Token introspection `_ + + +It also comes with the following `add_on` modules. + +* Custom scopes, that extends `[OIDC standard ScopeClaims] `_ +* `Proof Key for Code Exchange by OAuth Public Clients (PKCE) `_ +* `OAuth2 RAR `_ +* `OAuth2 DPoP `_ + +The entire project code is open sourced and therefore licensed +under the `Apache 2.0 `_. + + +.. toctree:: + :maxdepth: 2 + :caption: Introduction + + contents/intro.rst + +.. toctree:: + :maxdepth: 2 + :caption: Setup + + contents/setup.rst + +.. toctree:: + :maxdepth: 2 + :caption: Configuration + + contents/conf.rst + +.. toctree:: + :maxdepth: 2 + :caption: Usage + + contents/usage.md + +.. toctree:: + :maxdepth: 2 + :caption: Session management + + contents/session_management.rst + +.. toctree:: + :maxdepth: 2 + :caption: Developer's + + contents/developers.md + +.. toctree:: + :maxdepth: 2 + :caption: FAQ + + contents/faq.md diff --git a/example/django_op/README.md b/example/django_op/README.md new file mode 100644 index 00000000..108e9bd2 --- /dev/null +++ b/example/django_op/README.md @@ -0,0 +1,3 @@ +# django-oidc-op + +The Django oidc-op implementation is available here [django-oidc-op github page](https://github.com/peppelinux/django-oidc-op/tree/develop). 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/example/flask_op/application.py b/example/flask_op/application.py new file mode 100644 index 00000000..9fa299cb --- /dev/null +++ b/example/flask_op/application.py @@ -0,0 +1,42 @@ +import os +from urllib.parse import urlparse + +from flask.app import Flask + +from oidcop.server import Server + +folder = os.path.dirname(os.path.realpath(__file__)) + + +def init_oidc_op(app): + _op_config = app.srv_config + + server = Server(_op_config, cwd=folder) + + for endp in server.endpoint.values(): + p = urlparse(endp.endpoint_path) + _vpath = p.path.split('/') + if _vpath[0] == '': + endp.vpath = _vpath[1:] + else: + endp.vpath = _vpath + + return server + + +def oidc_provider_init_app(op_config, name=None, **kwargs): + name = name or __name__ + app = Flask(name, static_url_path='', **kwargs) + app.srv_config = op_config + + try: + from .views import oidc_op_views + except ImportError: + from views import oidc_op_views + + app.register_blueprint(oidc_op_views) + + # Initialize the oidc_provider after views to be able to set correct urls + app.server = init_oidc_op(app) + + return app diff --git a/chpy/certs/cert.pem b/example/flask_op/certs/cert.pem similarity index 100% rename from chpy/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/chpy/certs/key.pem b/example/flask_op/certs/key.pem similarity index 100% rename from chpy/certs/key.pem rename to example/flask_op/certs/key.pem diff --git a/example/flask_op/config.json b/example/flask_op/config.json new file mode 100644 index 00000000..e5e42e55 --- /dev/null +++ b/example/flask_op/config.json @@ -0,0 +1,356 @@ +{ + "logging": { + "version": 1, + "root": { + "handlers": [ + "default", + "console" + ], + "level": "DEBUG" + }, + "loggers": { + "bobcat_idp": { + "level": "DEBUG" + } + }, + "handlers": { + "default": { + "class": "logging.FileHandler", + "filename": "debug.log", + "formatter": "default" + }, + "console": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + "formatter": "default" + } + }, + "formatters": { + "default": { + "format": "%(asctime)s %(name)s %(levelname)s %(message)s" + } + } + }, + "port": 5000, + "domain": "127.0.0.1", + "server_name": "{domain}:{port}", + "base_url": "https://{domain}:{port}", + "op": { + "server_info": { + "add_on": { + "pkce": { + "function": "oidcop.oidc.add_on.pkce.add_pkce_support", + "kwargs": { + "essential": false, + "code_challenge_method": "S256 S384 S512" + } + }, + "claims": { + "function": "oidcop.oidc.add_on.custom_scopes.add_custom_scopes", + "kwargs": { + "research_and_scholarship": [ + "name", + "given_name", + "family_name", + "email", + "email_verified", + "sub", + "iss", + "eduperson_scoped_affiliation" + ] + } + } + }, + "authz": { + "class": "oidcop.authz.AuthzHandling", + "kwargs": { + "grant_config": { + "usage_rules": { + "authorization_code": { + "supports_minting": [ + "access_token", + "refresh_token", + "id_token" + ], + "max_usage": 1 + }, + "access_token": {}, + "refresh_token": { + "supports_minting": [ + "access_token", + "refresh_token" + ] + } + }, + "expires_in": 43200 + } + } + }, + "authentication": { + "user": { + "acr": "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword", + "class": "oidcop.user_authn.user.UserPassJinja2", + "kwargs": { + "verify_endpoint": "verify/user", + "template": "user_pass.jinja2", + "db": { + "class": "oidcop.util.JSONDictDB", + "kwargs": { + "filename": "passwd.json" + } + }, + "page_header": "Testing log in", + "submit_btn": "Get me in!", + "user_label": "Nickname", + "passwd_label": "Secret sauce" + } + } + }, + "capabilities": { + "subject_types_supported": [ + "public", + "pairwise" + ], + "grant_types_supported": [ + "authorization_code", + "implicit", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + "refresh_token" + ] + }, + "cookie_handler": { + "class": "oidcop.cookie_handler.CookieHandler", + "kwargs": { + "keys": { + "private_path": "private/cookie_jwks.json", + "key_defs": [ + { + "type": "OCT", + "use": [ + "enc" + ], + "kid": "enc" + }, + { + "type": "OCT", + "use": [ + "sig" + ], + "kid": "sig" + } + ], + "read_only": false + }, + "name": { + "session": "oidc_op", + "register": "oidc_op_rp", + "session_management": "sman" + } + } + }, + "endpoint": { + "webfinger": { + "path": ".well-known/webfinger", + "class": "oidcop.oidc.discovery.Discovery", + "kwargs": { + "client_authn_method": null + } + }, + "provider_info": { + "path": ".well-known/openid-configuration", + "class": "oidcop.oidc.provider_config.ProviderConfiguration", + "kwargs": { + "client_authn_method": null + } + }, + "registration": { + "path": "registration", + "class": "oidcop.oidc.registration.Registration", + "kwargs": { + "client_authn_method": null, + "client_secret_expiration_time": 432000 + } + }, + "registration_api": { + "path": "registration_api", + "class": "oidcop.oidc.read_registration.RegistrationRead", + "kwargs": { + "client_authn_method": [ + "bearer_header" + ] + } + }, + "introspection": { + "path": "introspection", + "class": "oidcop.oauth2.introspection.Introspection", + "kwargs": { + "client_authn_method": [ + "client_secret_post", + "client_secret_basic", + "client_secret_jwt", + "private_key_jwt" + ], + "release": [ + "username" + ] + } + }, + "authorization": { + "path": "authorization", + "class": "oidcop.oidc.authorization.Authorization", + "kwargs": { + "client_authn_method": null, + "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", + "none" + ], + "response_modes_supported": [ + "query", + "fragment", + "form_post" + ] + } + }, + "token": { + "path": "token", + "class": "oidcop.oidc.token.Token", + "kwargs": { + "client_authn_method": [ + "client_secret_post", + "client_secret_basic", + "client_secret_jwt", + "private_key_jwt" + ] + } + }, + "userinfo": { + "path": "userinfo", + "class": "oidcop.oidc.userinfo.UserInfo", + "kwargs": { + "claim_types_supported": [ + "normal", + "aggregated", + "distributed" + ] + } + }, + "end_session": { + "path": "session", + "class": "oidcop.oidc.session.Session", + "kwargs": { + "logout_verify_url": "verify_logout", + "post_logout_uri_path": "post_logout", + "signing_alg": "ES256", + "frontchannel_logout_supported": true, + "frontchannel_logout_session_supported": true, + "backchannel_logout_supported": true, + "backchannel_logout_session_supported": true, + "check_session_iframe": "check_session_iframe" + } + } + }, + "httpc_params": { + "verify": false + }, + "issuer": "https://{domain}:{port}", + "keys": { + "private_path": "private/jwks.json", + "key_defs": [ + { + "type": "RSA", + "use": [ + "sig" + ] + }, + { + "type": "EC", + "crv": "P-256", + "use": [ + "sig" + ] + } + ], + "public_path": "static/jwks.json", + "read_only": false, + "uri_path": "static/jwks.json" + }, + "login_hint2acrs": { + "class": "oidcop.login_hint.LoginHint2Acrs", + "kwargs": { + "scheme_map": { + "email": [ + "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword" + ] + } + } + }, + "template_dir": "templates", + "token_handler_args": { + "jwks_file": "private/token_jwks.json", + "code": { + "kwargs": { + "lifetime": 600 + } + }, + "token": { + "class": "oidcop.token.jwt_token.JWTToken", + "kwargs": { + "lifetime": 3600, + "add_claims": [ + "email", + "email_verified", + "phone_number", + "phone_number_verified" + ], + "add_claim_by_scope": true, + "aud": [ + "https://example.org/appl" + ] + } + }, + "refresh": { + "kwargs": { + "lifetime": 86400 + } + }, + "id_token": { + "class": "oidcop.token.id_token.IDToken", + "kwargs": { + "base_claims": { + "email": { + "essential": true + }, + "email_verified": { + "essential": true + } + } + } + } + }, + "userinfo": { + "class": "oidcop.user_info.UserInfo", + "kwargs": { + "db_file": "users.json" + } + } + } + }, + "webserver": { + "server_cert": "certs/client.crt", + "server_key": "certs/client.key", + "ca_bundle": null, + "verify_user": false, + "port": 5000, + "domain": "127.0.0.1", + "debug": true + } +} diff --git a/flask_op/conf_192.yaml b/example/flask_op/config.yaml similarity index 67% rename from flask_op/conf_192.yaml rename to example/flask_op/config.yaml index 64c8b0ac..00efd4b8 100644 --- a/flask_op/conf_192.yaml +++ b/example/flask_op/config.yaml @@ -37,23 +37,12 @@ key_def: &key_def use: - sig -OIDC_KEYS: &oidc_keys - 'private_path': "private/jwks.json" - 'key_defs': *key_def - 'public_path': 'static/jwks.json' - 'read_only': False - # otherwise OP metadata will have jwks_uri: https://127.0.0.1:5000/None! - 'uri_path': 'static/jwks.json' op: server_info: issuer: *base_url - http_params: - verify_ssl: False - session_key: - filename: private/session_jwk.json - type: OCT - use: sig + httpc_params: + verify: False capabilities: subject_types_supported: - public @@ -64,14 +53,6 @@ op: - urn:ietf:params:oauth:grant-type:jwt-bearer - refresh_token template_dir: templates - id_token: - class: oidcendpoint.id_token.IDToken - kwargs: - default_claims: - email: - essential: True - email_verified: - essential: True token_handler_args: jwks_def: private_path: 'private/token_jwks.json' @@ -91,8 +72,16 @@ op: kid: refresh code: lifetime: 600 + id_token: + class: oidcop.token.id_token.IDToken + kwargs: + base_claims: + email: + essential: True + email_verified: + essential: True token: - class: oidcendpoint.jwt_token.JWTToken + class: oidcop.token.jwt_token.JWTToken lifetime: 3600 add_claims: - email @@ -104,42 +93,47 @@ op: - https://example.org/appl refresh: lifetime: 86400 - jwks: - *oidc_keys + keys: + private_path: "private/jwks.json" + key_defs: *key_def + public_path: 'static/jwks.json' + read_only: False + # otherwise OP metadata will have jwks_uri: https://127.0.0.1:5000/None! + uri_path: 'static/jwks.json' endpoint: webfinger: path: '.well-known/webfinger' - class: oidcendpoint.oidc.discovery.Discovery + class: oidcop.oidc.discovery.Discovery kwargs: client_authn_method: null provider_info: path: ".well-known/openid-configuration" - class: oidcendpoint.oidc.provider_config.ProviderConfiguration + class: oidcop.oidc.provider_config.ProviderConfiguration kwargs: client_authn_method: null registration: path: registration - class: oidcendpoint.oidc.registration.Registration + class: oidcop.oidc.registration.Registration kwargs: client_authn_method: null client_secret_expiration_time: 432000 registration_api: path: registration_api - class: oidcendpoint.oidc.read_registration.RegistrationRead + class: oidcop.oidc.read_registration.RegistrationRead kwargs: client_authn_method: - bearer_header introspection: path: introspection - class: oidcendpoint.oauth2.introspection.Introspection + class: oidcop.oauth2.introspection.Introspection kwargs: client_authn_method: - client_secret_post: ClientSecretPost + - client_secret_post release: - username authorization: path: authorization - class: oidcendpoint.oidc.authorization.Authorization + class: oidcop.oidc.authorization.Authorization kwargs: client_authn_method: null claims_parameter_supported: True @@ -160,7 +154,7 @@ op: - form_post token: path: token - class: oidcendpoint.oidc.token.AccessToken + class: oidcop.oidc.token.Token kwargs: client_authn_method: - client_secret_post @@ -169,7 +163,7 @@ op: - private_key_jwt userinfo: path: userinfo - class: oidcendpoint.oidc.userinfo.UserInfo + class: oidcop.oidc.userinfo.UserInfo kwargs: claim_types_supported: - normal @@ -177,7 +171,7 @@ op: - distributed end_session: path: session - class: oidcendpoint.oidc.session.Session + class: oidcop.oidc.session.Session kwargs: logout_verify_url: verify_logout post_logout_uri_path: post_logout @@ -188,59 +182,63 @@ op: backchannel_logout_session_supported: True check_session_iframe: 'check_session_iframe' userinfo: - class: oidcendpoint.user_info.UserInfo + class: oidcop.user_info.UserInfo kwargs: db_file: users.json authentication: user: - acr: oidcendpoint.user_authn.authn_context.INTERNETPROTOCOLPASSWORD - class: oidcendpoint.user_authn.user.UserPassJinja2 + acr: urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword + class: oidcop.user_authn.user.UserPassJinja2 kwargs: verify_endpoint: 'verify/user' template: user_pass.jinja2 db: - class: oidcendpoint.util.JSONDictDB + class: oidcop.util.JSONDictDB kwargs: - json_path: passwd.json + filename: passwd.json page_header: "Testing log in" submit_btn: "Get me in!" user_label: "Nickname" passwd_label: "Secret sauce" #anon: - #acr: oidcendpoint.user_authn.authn_context.UNSPECIFIED - #class: oidcendpoint.user_authn.user.NoAuthn + #acr: oidcop.user_authn.authn_context.UNSPECIFIED + #class: oidcop.user_authn.user.NoAuthn #kwargs: #user: diana - cookie_dealer: - class: oidcendpoint.cookie.CookieDealer - kwargs: - sign_jwk: - filename: 'private/cookie_sign_jwk.json' - type: OCT - kid: cookie_sign_key_id - enc_jwk: - filename: 'private/cookie_enc_jwk.json' - type: OCT - kid: cookie_enc_key_id - # enc_jwk: 'private/cookie_enc_jwk.json' - default_values: - name: oidc_op - domain: *domain - path: / - max_age: 3600 + cookie_handler: + class: oidcop.cookie_handler.CookieHandler + kwargs": + keys: + private_path: "private/cookie_jwks.json" + key_defs: + - + type: OCT + kid: enc + use: + - enc + - + type: OCT + kid: sig + use: + - sig + read_only: false + name: + session: "oidc_op" + register: "oidc_op_rp" + session_management: "sman" login_hint2acrs: - class: oidcendpoint.login_hint.LoginHint2Acrs + class: oidcop.login_hint.LoginHint2Acrs kwargs: scheme_map: email: - - oidcendpoint.user_authn.authn_context.INTERNETPROTOCOLPASSWORD + - urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword # this adds PKCE support as mandatory - disable it if needed (essential: False) add_on: pkce: - function: oidcendpoint.oidc.add_on.pkce.add_pkce_support + function: oidcop.oidc.add_on.pkce.add_pkce_support kwargs: - essential: False + essential: false code_challenge_method: #plain S256 @@ -248,7 +246,7 @@ op: S512 claims: - function: oidcendpoint.oidc.add_on.custom_scopes.add_custom_scopes + function: oidcop.oidc.add_on.custom_scopes.add_custom_scopes kwargs: research_and_scholarship: - name diff --git a/chpy/passwd.json b/example/flask_op/passwd.json similarity index 100% rename from chpy/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/example/flask_op/run.sh b/example/flask_op/run.sh new file mode 100644 index 00000000..ac121302 --- /dev/null +++ b/example/flask_op/run.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +python3 server.py config.json 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 81% rename from flask_op/server.py rename to example/flask_op/server.py index e4def45d..caada009 100755 --- a/flask_op/server.py +++ b/example/flask_op/server.py @@ -4,10 +4,9 @@ import logging import os -import OpenSSL -import werkzeug - from oidcop.configure import Configuration +from oidcop.configure import OPConfiguration +from oidcop.configure import create_from_config_file from oidcop.utils import create_context try: @@ -53,8 +52,15 @@ def main(config_file, args): logging.basicConfig(level=logging.DEBUG) - config = Configuration.create_from_config_file(config_file, base_path=dir_path) - app = oidc_provider_init_app(config, 'oidc_op') + config = create_from_config_file(Configuration, + entity_conf=[{ + "class": OPConfiguration, "attr": "op", + "path": ["op", "server_info"] + }], + filename=config_file, + base_path=dir_path) + app = oidc_provider_init_app(config.op, 'oidc_op') + app.logger = config.logger web_conf = config.webserver diff --git a/flask_op/templates/check_session_iframe.html b/example/flask_op/templates/check_session_iframe.html similarity index 100% rename from flask_op/templates/check_session_iframe.html rename to example/flask_op/templates/check_session_iframe.html diff --git a/flask_op/templates/error.html b/example/flask_op/templates/error.html similarity index 100% rename from flask_op/templates/error.html rename to example/flask_op/templates/error.html diff --git a/flask_op/templates/frontchannel_logout.html b/example/flask_op/templates/frontchannel_logout.html similarity index 100% rename from flask_op/templates/frontchannel_logout.html rename to example/flask_op/templates/frontchannel_logout.html diff --git a/flask_op/templates/index.html b/example/flask_op/templates/index.html similarity index 100% rename from flask_op/templates/index.html rename to example/flask_op/templates/index.html diff --git a/flask_op/templates/logout.html b/example/flask_op/templates/logout.html similarity index 100% rename from flask_op/templates/logout.html rename to example/flask_op/templates/logout.html diff --git a/flask_op/templates/post_logout.html b/example/flask_op/templates/post_logout.html similarity index 100% rename from flask_op/templates/post_logout.html rename to example/flask_op/templates/post_logout.html diff --git a/chpy/templates/user_pass.jinja2 b/example/flask_op/templates/user_pass.jinja2 similarity index 100% rename from chpy/templates/user_pass.jinja2 rename to example/flask_op/templates/user_pass.jinja2 diff --git a/chpy/users.json b/example/flask_op/users.json similarity index 100% rename from chpy/users.json rename to example/flask_op/users.json diff --git a/flask_op/views.py b/example/flask_op/views.py similarity index 72% rename from flask_op/views.py rename to example/flask_op/views.py index fdc19bf2..06a7f180 100644 --- a/flask_op/views.py +++ b/example/flask_op/views.py @@ -1,11 +1,9 @@ -import base64 import json import os import sys import traceback from urllib.parse import urlparse -import werkzeug from cryptojwt import as_unicode from flask import Blueprint from flask import current_app @@ -14,14 +12,15 @@ from flask import request from flask.helpers import make_response from flask.helpers import send_from_directory -from oidcendpoint.authn_event import create_authn_event -from oidcendpoint.exception import FailedAuthentication -from oidcendpoint.exception import InvalidClient -from oidcendpoint.exception import UnknownClient -from oidcendpoint.oidc.token import AccessToken from oidcmsg.oauth2 import ResponseMessage from oidcmsg.oidc import AccessTokenRequest from oidcmsg.oidc import AuthorizationRequest +import werkzeug + +from oidcop.exception import FailedAuthentication +from oidcop.exception import InvalidClient +from oidcop.exception import UnknownClient +from oidcop.oidc.token import Token # logger = logging.getLogger(__name__) @@ -29,21 +28,20 @@ def _add_cookie(resp, cookie_spec): - for key, _morsel in cookie_spec.items(): - kwargs = {'value': _morsel.value} - for param in ['expires', 'path', 'comment', 'domain', 'max-age', - 'secure', - 'version']: - if _morsel[param]: - kwargs[param] = _morsel[param] - resp.set_cookie(key, **kwargs) + kwargs = {'value': cookie_spec["value"]} + for param in ['expires', 'max-age']: + if param in cookie_spec: + kwargs[param] = cookie_spec[param] + kwargs["path"] = "/" + resp.set_cookie(cookie_spec["name"], **kwargs) def add_cookie(resp, cookie_spec): if isinstance(cookie_spec, list): for _spec in cookie_spec: _add_cookie(resp, _spec) - + elif isinstance(cookie_spec, dict): + _add_cookie(resp, cookie_spec) @oidc_op_views.route('/static/') def send_js(path): @@ -67,7 +65,7 @@ def add_headers_and_cookie(resp, info): def do_response(endpoint, req_args, error='', **args): info = endpoint.do_response(request=req_args, error=error, **args) - _log = current_app.srv_config.logger + _log = current_app.logger _log.debug('do_response: {}'.format(info)) try: @@ -118,15 +116,11 @@ def verify(authn_method): auth_args = authn_method.unpack_token(kwargs['token']) authz_request = AuthorizationRequest().from_urlencoded(auth_args['query']) - authn_event = create_authn_event( - uid=username, - salt=base64.b64encode(os.urandom(16)).decode(), - authn_info=auth_args['authn_class_ref'], - authn_time=auth_args['iat']) + endpoint = current_app.server.server_get("endpoint", 'authorization') + _session_id = endpoint.create_session(authz_request, username, auth_args['authn_class_ref'], + auth_args['iat'], authn_method) - endpoint = current_app.endpoint_context.endpoint['authorization'] - args = endpoint.authz_part2(user=username, request=authz_request, - authn_event=authn_event) + args = endpoint.authz_part2(request=authz_request, session_id=_session_id) if isinstance(args, ResponseMessage) and 'error' in args: return make_response(args.to_json(), 400) @@ -136,8 +130,8 @@ def verify(authn_method): @oidc_op_views.route('/verify/user', methods=['GET', 'POST']) def verify_user(): - authn_method = current_app.endpoint_context.authn_broker.get_method_by_id( - 'user') + authn_method = current_app.server.server_get( + "endpoint_context").authn_broker.get_method_by_id('user') try: return verify(authn_method) except FailedAuthentication as exc: @@ -146,8 +140,8 @@ def verify_user(): @oidc_op_views.route('/verify/user_pass_jinja', methods=['GET', 'POST']) def verify_user_pass_jinja(): - authn_method = current_app.endpoint_context.authn_broker.get_method_by_id( - 'user') + authn_method = current_app.server.server_get( + "endpoint_context").authn_broker.get_method_by_id('user') try: return verify(authn_method) except FailedAuthentication as exc: @@ -157,11 +151,9 @@ def verify_user_pass_jinja(): @oidc_op_views.route('/.well-known/') def well_known(service): if service == 'openid-configuration': - _endpoint = current_app.endpoint_context.endpoint['provider_config'] - # if service == 'openid-federation': - # _endpoint = current_app.endpoint_context.endpoint['provider_info'] + _endpoint = current_app.server.server_get("endpoint", 'provider_config') elif service == 'webfinger': - _endpoint = current_app.endpoint_context.endpoint['discovery'] + _endpoint = current_app.server.server_get("endpoint", 'discovery') else: return make_response('Not supported', 400) @@ -171,65 +163,73 @@ def well_known(service): @oidc_op_views.route('/registration', methods=['GET', 'POST']) def registration(): return service_endpoint( - current_app.endpoint_context.endpoint['registration']) + current_app.server.server_get("endpoint", 'registration')) @oidc_op_views.route('/registration_api', methods=['GET']) def registration_api(): return service_endpoint( - current_app.endpoint_context.endpoint['registration_read']) + current_app.server.server_get("endpoint", 'registration_read')) @oidc_op_views.route('/authorization') def authorization(): return service_endpoint( - current_app.endpoint_context.endpoint['authorization']) + current_app.server.server_get("endpoint", 'authorization')) @oidc_op_views.route('/token', methods=['GET', 'POST']) def token(): return service_endpoint( - current_app.endpoint_context.endpoint['token']) + current_app.server.server_get("endpoint", 'token')) +@oidc_op_views.route('/introspection', methods=['POST']) +def introspection_endpoint(): + return service_endpoint( + current_app.server.server_get("endpoint", 'introspection')) @oidc_op_views.route('/userinfo', methods=['GET', 'POST']) def userinfo(): return service_endpoint( - current_app.endpoint_context.endpoint['userinfo']) + current_app.server.server_get("endpoint", 'userinfo')) @oidc_op_views.route('/session', methods=['GET']) def session_endpoint(): return service_endpoint( - current_app.endpoint_context.endpoint['session']) + current_app.server.server_get("endpoint", 'session')) + + +IGNORE = ["cookie", "user-agent"] def service_endpoint(endpoint): - _log = current_app.srv_config.logger - _log.info('At the "{}" endpoint'.format(endpoint.endpoint_name)) + _log = current_app.logger + _log.info('At the "{}" endpoint'.format(endpoint.name)) - try: - authn = request.headers['Authorization'] - except KeyError: - pr_args = {} - else: - pr_args = {'auth': authn} + http_info = { + "headers": {k: v for k, v in request.headers.items(lower=True) if k not in IGNORE}, + "method": request.method, + "url": request.url, + # name is not unique + "cookie": [{"name": k, "value": v} for k, v in request.cookies.items()] + } if request.method == 'GET': try: - req_args = endpoint.parse_request(request.args.to_dict(), **pr_args) + req_args = endpoint.parse_request(request.args.to_dict(), http_info=http_info) except (InvalidClient, UnknownClient) as err: _log.error(err) return make_response(json.dumps({ 'error': 'unauthorized_client', 'error_description': str(err) - }), 400) + }), 400) except Exception as err: _log.error(err) return make_response(json.dumps({ 'error': 'invalid_request', 'error_description': str(err) - }), 400) + }), 400) else: if request.data: if isinstance(request.data, str): @@ -239,7 +239,7 @@ def service_endpoint(endpoint): else: req_args = dict([(k, v) for k, v in request.form.items()]) try: - req_args = endpoint.parse_request(req_args, **pr_args) + req_args = endpoint.parse_request(req_args, http_info=http_info) except Exception as err: _log.error(err) err_msg = ResponseMessage(error='invalid_request', error_description=str(err)) @@ -248,18 +248,11 @@ def service_endpoint(endpoint): _log.info('request: {}'.format(req_args)) if isinstance(req_args, ResponseMessage) and 'error' in req_args: return make_response(req_args.to_json(), 400) - try: - if request.cookies: - _log.debug(request.cookies) - kwargs = {'cookie': request.cookies} + if isinstance(endpoint, Token): + args = endpoint.process_request(AccessTokenRequest(**req_args), http_info=http_info) else: - kwargs = {} - - if isinstance(endpoint, AccessToken): - args = endpoint.process_request(AccessTokenRequest(**req_args), **kwargs) - else: - args = endpoint.process_request(req_args, **kwargs) + args = endpoint.process_request(req_args, http_info=http_info) except Exception as err: message = traceback.format_exception(*sys.exc_info()) _log.error(message) @@ -273,7 +266,8 @@ def service_endpoint(endpoint): if 'http_response' in args: return make_response(args['http_response'], 200) - return do_response(endpoint, req_args, **args) + response = do_response(endpoint, req_args, **args) + return response @oidc_op_views.errorhandler(werkzeug.exceptions.BadRequest) @@ -292,14 +286,15 @@ def check_session_iframe(): req_args = dict([(k, v) for k, v in request.form.items()]) if req_args: + _context = current_app.server.server_get("endpoint_context") # will contain client_id and origin - if req_args['origin'] != current_app.endpoint_context.issuer: + if req_args['origin'] != _context.issuer: return 'error' - if req_args['client_id'] != current_app.endpoint_context.cdb: + if req_args['client_id'] != _context.cdb: return 'error' return 'OK' - current_app.srv_config.logger.debug( + current_app.logger.debug( 'check_session_iframe: {}'.format(req_args)) doc = open('templates/check_session_iframe.html').read() return doc @@ -307,7 +302,7 @@ def check_session_iframe(): @oidc_op_views.route('/verify_logout', methods=['GET', 'POST']) def verify_logout(): - part = urlparse(current_app.endpoint_context.issuer) + part = urlparse(current_app.server.server_get("endpoint_context").issuer) page = render_template('logout.html', op=part.hostname, do_logout='rp_logout', sjwt=request.args['sjwt']) return page @@ -315,7 +310,7 @@ def verify_logout(): @oidc_op_views.route('/rp_logout', methods=['GET', 'POST']) def rp_logout(): - _endp = current_app.endpoint_context.endpoint['session'] + _endp = current_app.server.server_get("endpoint", 'session') _info = _endp.unpack_signed_jwt(request.form['sjwt']) try: request.form['logout'] @@ -333,8 +328,11 @@ def rp_logout(): postLogoutRedirectUri=_info['redirect_uri']) else: res = redirect(_info['redirect_uri']) + + # rohe are you sure that _kakor is the right word? :) _kakor = _endp.kill_cookies() - _add_cookie(res, _kakor) + for cookie in _kakor: + _add_cookie(res, cookie) return res diff --git a/flask_op/application.py b/flask_op/application.py deleted file mode 100644 index b8f17fca..00000000 --- a/flask_op/application.py +++ /dev/null @@ -1,49 +0,0 @@ -import os -from urllib.parse import urlparse - -from cryptojwt.key_jar import init_key_jar -from flask.app import Flask -from oidcendpoint.endpoint_context import EndpointContext - -folder = os.path.dirname(os.path.realpath(__file__)) - - -def init_oidc_op_endpoints(app): - _config = app.srv_config.op - _server_info_config = _config['server_info'] - - iss = _server_info_config['issuer'] - if '{domain}' in iss: - iss = iss.format(domain=app.srv_config.domain, - port=app.srv_config.port) - _server_info_config['issuer'] = iss - - endpoint_context = EndpointContext(_server_info_config, cwd=folder) - - for endp in endpoint_context.endpoint.values(): - p = urlparse(endp.endpoint_path) - _vpath = p.path.split('/') - if _vpath[0] == '': - endp.vpath = _vpath[1:] - else: - endp.vpath = _vpath - - return endpoint_context - - -def oidc_provider_init_app(config, name=None, **kwargs): - name = name or __name__ - app = Flask(name, static_url_path='', **kwargs) - app.srv_config = config - - try: - from .views import oidc_op_views - except ImportError: - from views import oidc_op_views - - app.register_blueprint(oidc_op_views) - - # Initialize the oidc_provider after views to be able to set correct urls - app.endpoint_context = init_oidc_op_endpoints(app) - - return app diff --git a/flask_op/conf.py b/flask_op/conf.py deleted file mode 100644 index d9e81ce6..00000000 --- a/flask_op/conf.py +++ /dev/null @@ -1,177 +0,0 @@ -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.authn_context import UNSPECIFIED -from oidcendpoint.user_authn.user import NoAuthn -from oidcendpoint.user_authn.user import UserPassJinja2 -from oidcendpoint.util import JSONDictDB - -SESSION_COOKIE_NAME = 'flop' - -RESPONSE_TYPES_SUPPORTED = [ - ["code"], ["token"], ["id_token"], ["code", "token"], ["code", "id_token"], - ["id_token", "token"], ["code", "token", "id_token"], ['none']] - -CAPABILITIES = { - "subject_types_supported": ["public", "pairwise"], - "grant_types_supported": [ - "authorization_code", "implicit", - "urn:ietf:params:oauth:grant-type:jwt-bearer", "refresh_token"], -} - -KEY_DEF = [ - {"type": "RSA", "use": ["sig"]}, - {"type": "EC", "crv": "P-256", "use": ["sig"]} -] - -PORT = 5000 -DOMAIN = '127.0.0.1' -SERVER_NAME = '{}:{}'.format(DOMAIN, str(PORT)) -BASE_URL = 'https://{}'.format(SERVER_NAME) - -# If we support session management -CAPABILITIES["check_session_iframe"] = "{}/check_session_iframe".format(BASE_URL) - -PATH = { - 'userinfo:kwargs:db_file': '{}/users.json', - 'authentication:0:kwargs:db:kwargs:json_path': '{}/passwd.json' -} - -CONFIG = { - 'provider': { - 'key_defs': [ - {"type": "RSA", "use": ["sig"]}, - {"type": "EC", "crv": "P-256", "use": ["sig"]} - ], - }, - 'server_info': { - "issuer": BASE_URL, - "password": "mycket hemlig information", - "token_expires_in": 600, - "grant_expires_in": 300, - "refresh_token_expires_in": 86400, - "verify_ssl": False, - "capabilities": CAPABILITIES, - 'template_dir': 'templates', - "jwks": { - 'private_path': 'own/jwks.json', - 'key_defs': KEY_DEF, - 'url_path': 'static/jwks.json' - }, - 'endpoint': { - 'webfinger': { - 'path': '.well-known/webfinger', - 'class': Discovery, - 'kwargs': {'client_authn_method': None} - }, - 'provider_info': { - 'path': '.well-known/openid-configuration', - 'class': ProviderConfiguration, - 'kwargs': {'client_authn_method': None} - }, - 'registration': { - 'path': 'registration', - 'class': Registration, - 'kwargs': { - 'client_authn_method': None, - 'client_secret_expiration_time': 5 * 86400 - } - }, - 'authorization': { - 'path': 'authorization', - 'class': Authorization, - 'kwargs': { - 'client_authn_method': None, - "response_types_supported": [" ".join(x) for x in RESPONSE_TYPES_SUPPORTED], - "response_modes_supported": ['query', 'fragment', 'form_post'], - "claims_parameter_supported": True, - "request_parameter_supported": True, - "request_uri_parameter_supported": True - } - }, - 'token': { - 'path': 'token', - 'class': AccessToken, - 'kwargs': { - "client_authn_method": [ - "client_secret_post", "client_secret_basic", - "client_secret_jwt", "private_key_jwt"], - - } - }, - 'userinfo': { - 'path': 'userinfo', - 'class': UserInfo, - "kwargs": { - "claim_types_supported": ["normal", "aggregated", "distributed"], - } - }, - 'end_session': { - 'path': 'session', - 'class': Session, - 'kwargs': { - 'logout_uri': "{}/verify_logout".format(BASE_URL), - 'post_logout_uri': "{}/post_logout".format(BASE_URL), - 'signing_alg': "ES256", - "frontchannel_logout_supported": True, - "frontchannel_logout_session_supported": True, - "backchannel_logout_supported": True, - "backchannel_logout_session_supported": True, - "check_session_iframe": "{}/check_session_iframe".format(BASE_URL) - } - } - }, - 'userinfo': { - 'class': user_info.UserInfo, - 'kwargs': {'db_file': 'users.json'} - }, - 'authentication': { - 'user': - { - 'acr': INTERNETPROTOCOLPASSWORD, - 'class': UserPassJinja2, - 'verify_endpoint': 'verify/user', - 'kwargs': { - 'template': 'user_pass.jinja2', - 'sym_key': '24AA/LR6HighEnergy', - 'db': { - 'class': JSONDictDB, - 'kwargs': - {'json_path': 'passwd.json'} - }, - 'page_header': "Testing log in", - 'submit_btn': "Get me in!", - 'user_label': "Nickname", - 'passwd_label': "Secret sauce" - } - }, - 'anon': - { - 'acr': UNSPECIFIED, - 'class': NoAuthn, - 'kwargs': {'user': 'diana'} - } - }, - 'cookie_dealer': { - 'symkey': 'ghsNKDDLshZTPn974nOsIGhedULrsqnsGoBFBLwUKuJhE2ch', - 'default_values': { - 'name': 'oidc_op', - 'domain': DOMAIN, - 'path': '/', - 'max_age': 3600 - } - } - }, - 'webserver': { - 'cert': '{}/certs/cert.pem', - 'key': '{}/certs/key.pem', - 'cert_chain': '', - 'port': PORT, - } -} diff --git a/flask_op/config_persistent.yaml b/flask_op/config_persistent.yaml deleted file mode 100644 index 6ff797ca..00000000 --- a/flask_op/config_persistent.yaml +++ /dev/null @@ -1,293 +0,0 @@ -logging: - version: 1 - root: - handlers: - - default - - console - level: DEBUG - loggers: - bobcat_idp: - level: DEBUG - handlers: - default: - class: logging.FileHandler - filename: 'debug.log' - formatter: default - console: - class: logging.StreamHandler - stream: 'ext://sys.stdout' - formatter: default - formatters: - default: - format: '%(asctime)s %(name)s %(levelname)s %(message)s' - -port: &port 5000 -domain: &domain 127.0.0.1 -server_name: '{domain}:{port}' -base_url: &base_url 'https://{domain}:{port}' - -key_def: &key_def - - - type: RSA - use: - - sig - - - type: EC - crv: "P-256" - use: - - sig - -OIDC_KEYS: &oidc_keys - 'private_path': "private/jwks.json" - 'key_defs': *key_def - 'public_path': 'static/jwks.json' - 'read_only': False - # otherwise OP metadata will have jwks_uri: https://127.0.0.1:5000/None! - 'uri_path': 'static/jwks.json' - - -op: - server_info: - issuer: *base_url - http_params: - verify_ssl: False - session_key: - filename: private/session_jwk.json - type: OCT - use: sig - capabilities: - subject_types_supported: - - public - - pairwise - grant_types_supported: - - authorization_code - - implicit - - urn:ietf:params:oauth:grant-type:jwt-bearer - - refresh_token - template_dir: templates - id_token: - class: oidcendpoint.id_token.IDToken - kwargs: - default_claims: - email: - essential: True - email_verified: - essential: True - token_handler_args: - jwks_def: - private_path: 'private/token_jwks.json' - read_only: False - key_defs: - - - type: oct - bytes: 24 - use: - - enc - kid: code - - - type: oct - bytes: 24 - use: - - enc - kid: refresh - code: - lifetime: 600 - token: - class: oidcendpoint.jwt_token.JWTToken - lifetime: 3600 - add_claims: - - email - - email_verified - - phone_number - - phone_number_verified - add_claim_by_scope: True - aud: - - https://example.org/appl - refresh: - lifetime: 86400 - keys: - *oidc_keys - endpoint: - webfinger: - path: '.well-known/webfinger' - class: oidcendpoint.oidc.discovery.Discovery - kwargs: - client_authn_method: null - provider_info: - path: ".well-known/openid-configuration" - class: oidcendpoint.oidc.provider_config.ProviderConfiguration - kwargs: - client_authn_method: null - registration: - path: registration - class: oidcendpoint.oidc.registration.Registration - kwargs: - client_authn_method: null - client_secret_expiration_time: 432000 - registration_api: - path: registration_api - class: oidcendpoint.oidc.read_registration.RegistrationRead - kwargs: - client_authn_method: - - bearer_header - introspection: - path: introspection - class: oidcendpoint.oauth2.introspection.Introspection - kwargs: - client_authn_method: - - client_secret_post - release: - - username - authorization: - path: authorization - class: oidcendpoint.oidc.authorization.Authorization - kwargs: - client_authn_method: null - 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" - - none - response_modes_supported: - - query - - fragment - - form_post - token: - path: token - class: oidcendpoint.oidc.token.AccessToken - kwargs: - client_authn_method: - - client_secret_post - - client_secret_basic - - client_secret_jwt - - private_key_jwt - userinfo: - path: userinfo - class: oidcendpoint.oidc.userinfo.UserInfo - kwargs: - claim_types_supported: - - normal - - aggregated - - distributed - end_session: - path: session - class: oidcendpoint.oidc.session.Session - kwargs: - logout_verify_url: verify_logout - post_logout_uri_path: post_logout - signing_alg: "ES256" - frontchannel_logout_supported: True - frontchannel_logout_session_supported: True - backchannel_logout_supported: True - backchannel_logout_session_supported: True - check_session_iframe: 'check_session_iframe' - userinfo: - class: oidcendpoint.user_info.UserInfo - kwargs: - db_file: users.json - authentication: - user: - acr: oidcendpoint.user_authn.authn_context.INTERNETPROTOCOLPASSWORD - class: oidcendpoint.user_authn.user.UserPassJinja2 - kwargs: - verify_endpoint: 'verify/user' - template: user_pass.jinja2 - db: - class: oidcendpoint.util.JSONDictDB - kwargs: - json_path: passwd.json - page_header: "Testing log in" - submit_btn: "Get me in!" - user_label: "Nickname" - passwd_label: "Secret sauce" - #anon: - #acr: oidcendpoint.user_authn.authn_context.UNSPECIFIED - #class: oidcendpoint.user_authn.user.NoAuthn - #kwargs: - #user: diana - cookie_dealer: - class: oidcendpoint.cookie.CookieDealer - kwargs: - sign_jwk: - filename: 'private/cookie_sign_jwk.json' - type: OCT - kid: cookie_sign_key_id - enc_jwk: - filename: 'private/cookie_enc_jwk.json' - type: OCT - kid: cookie_enc_key_id - # enc_jwk: 'private/cookie_enc_jwk.json' - default_values: - name: oidc_op - domain: *domain - path: / - max_age: 3600 - login_hint2acrs: - class: oidcendpoint.login_hint.LoginHint2Acrs - kwargs: - scheme_map: - email: - - oidcendpoint.user_authn.authn_context.INTERNETPROTOCOLPASSWORD - - # this adds PKCE support as mandatory - disable it if needed (essential: False) - add_on: - pkce: - function: oidcendpoint.oidc.add_on.pkce.add_pkce_support - kwargs: - essential: false - code_challenge_method: - #plain - S256 - S384 - S512 - - claims: - function: oidcendpoint.oidc.add_on.custom_scopes.add_custom_scopes - kwargs: - research_and_scholarship: - - name - - given_name - - family_name - - email - - email_verified - - sub - - iss - - eduperson_scoped_affiliation - db_conf: - keyjar: - handler: abstorage.storages.abfile.LabeledAbstractFileSystem - fdir: storage/keyjar - key_conv: abstorage.converter.QPKey - value_conv: cryptojwt.serialize.item.KeyIssuer - label: 'x' - default: - handler: abstorage.storages.abfile.AbstractFileSystem - fdir: storage - key_conv: abstorage.converter.QPKey - value_conv: abstorage.converter.JSON - session: - handler: abstorage.storages.abfile.AbstractFileSystem - fdir: storage/session - key_conv: abstorage.converter.QPKey - value_conv: abstorage.converter.JSON - sso: - handler: abstorage.storages.abfile.AbstractFileSystem - fdir: storage/sso - key_conv: abstorage.converter.QPKey - value_conv: abstorage.converter.JSON - -webserver: - server_cert: 'certs/89296913_127.0.0.1.cert' - server_key: 'certs/89296913_127.0.0.1.key' - ca_bundle: null - verify_user: false - port: *port - domain: *domain - debug: true diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..cff54529 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[build-system] +requires = [ + "setuptools>=42", + "wheel" +] +build-backend = "setuptools.build_meta" + +[metadata] +name = "oidcop" +version = "2.0.0" +author = "Roland Hedberg" +author_email = "roland@catalogix.se" +description = "Python implementation of an OAuth2 AS and an OIDC Provider" +long_description = "file: README.md" +long_description_content_type = "text/markdown" +url = "https://github.com/IdentityPython/oidc-op" +license = "Apache-2.0" +classifiers =[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent" +] + +[options] +package_dir = "src" +packages = "find:" +python= "^3.6" + +[tool.black] +line-length = 100 + +[tool.isort] +force_single_line = true +known_first_party = "oidcop" +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +line_length = 100 + +[tool.coverage.run] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", +] diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 00000000..3069ee0d --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,4 @@ +sphinx +recommonmark +sphinx_rtd_theme +sphinxcontrib-images diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..9126daf7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +oidcmsg>=1.3.0 +pyyaml +jinja2>=2.11.3 +responses>=0.13.0 diff --git a/setup.py b/setup.py index 94d824ef..8b03c90b 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,11 @@ def run_tests(self): "Programming Language :: Python :: 3.8", "Topic :: Software Development :: Libraries :: Python Modules"], install_requires=[ + "oidcmsg==1.3.2", + "cryptojwt==1.5.2", "pyyaml", - 'oidcendpoint>=1.1.0' + "jinja2>=2.11.3", + "responses>=0.13.0" ], zip_safe=False, cmdclass={'test': PyTest}, diff --git a/src/oidcop/__init__.py b/src/oidcop/__init__.py index 39cae215..1335e726 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 secrets + +__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 url safe characters + + :param size: The length of the string + :return: string + """ + return secrets.token_urlsafe(size) + # 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..0b5bf0e8 --- /dev/null +++ b/src/oidcop/authn_event.py @@ -0,0 +1,72 @@ +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..04203cff --- /dev/null +++ b/src/oidcop/authz/__init__.py @@ -0,0 +1,118 @@ +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: Optional[str] = ""): + if "usage_rules" in self.grant_config: + _usage_rules = copy.deepcopy(self.grant_config["usage_rules"]) + else: + _usage_rules = {} + + if not client_id: + return _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) + elif _pc == {}: + _usage_rules[_token_type] = {} + 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: + session_info = self.server_get("endpoint_context").session_manager.get_session_info( + session_id=session_id, grant=True + ) + grant = session_info["grant"] + + args = self.grant_config.copy() + + for key, val in args.items(): + if key == "expires_in": + grant.set_expires_at(val) + elif key == "usage_rules": + setattr(grant, key, self.usage_rules(request.get("client_id"))) + else: + setattr(grant, key, val) + + if resources is None: + grant.resources = [session_info["client_id"]] + else: + grant.resources = resources + + # After this is where user consent should be handled + scopes = request.get("scope", []) + grant.scope = scopes + grant.claims = self.server_get("endpoint_context").claims_interface.get_claims_all_usage( + session_id=session_id, scopes=scopes + ) + + 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..f02868d9 --- /dev/null +++ b/src/oidcop/client_authn.py @@ -0,0 +1,431 @@ +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 server_get: A method that can be used to get general server information. + """ + 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(authorization_token): + if not authorization_token.startswith("Basic "): + raise AuthnFailure("Wrong type of authorization token") + + _tok = as_bytes(authorization_token[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_token=None): + if authorization_token is not None and authorization_token.startswith("Basic "): + return True + return False + + def verify(self, authorization_token, **kwargs): + client_info = basic_authn(authorization_token) + + 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_token=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_token=None): + if authorization_token is not None and authorization_token.startswith("Bearer "): + return True + return False + + def verify(self, authorization_token, **kwargs): + return {"token": authorization_token.split(" ", 1)[1]} + + +class BearerBody(ClientSecretPost): + """ + Same as Client Secret Post + """ + + tag = "bearer_body" + + def is_usable(self, request=None, authorization_token=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_token=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[_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], + http_info: Optional[dict] = None, + get_client_id_from_token: Optional[Callable] = None, + endpoint=None, # Optional[Endpoint] + also_known_as: Optional[str] = None, +): + """ + Initiated Guessing ! + + :param also_known_as: + :param endpoint: Endpoint instance + :param endpoint_context: EndpointContext instance + :param request: The request + :param http_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. + """ + + if http_info and "headers" in http_info: + authorization_token = http_info["headers"].get("authorization") + else: + authorization_token = None + + auth_info = {} + _methods = getattr(endpoint, 'client_authn_method', []) + + for _method in _methods: + if _method is None: + continue + if _method.is_usable(request, authorization_token): + try: + auth_info = _method.verify( + request=request, authorization_token=authorization_token, 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 client_id not in endpoint_context.cdb: + raise UnknownClient("Unknown Client ID") + + _cinfo = endpoint_context.cdb[client_id] + if isinstance(_cinfo, str): + if _cinfo not 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..f1bd5c80 100755 --- a/src/oidcop/configure.py +++ b/src/oidcop/configure.py @@ -1,81 +1,548 @@ """Configuration management for IDP""" - +import copy +import importlib +import json +import logging import os from typing import Dict +from typing import List from typing import Optional - -from cryptojwt.key_bundle import init_key -from oidcmsg import add_base_path +from typing import Union from oidcop.logging import configure_logging from oidcop.utils import load_yaml_config -try: - from secrets import token_urlsafe as rnd_token -except ImportError: - from oidcendpoint import rndstr as rnd_token - -DEFAULT_ITEM_PATHS = { - "webserver": ['server_key', 'server_cert'], - "op": { - "server_info": { - "session_key": ["filename"], - "template_dir": None, - "token_handler_args": { - "jwks_def": ["private_path", "public_path"] +DEFAULT_FILE_ATTRIBUTE_NAMES = [ + "server_key", + "server_cert", + "filename", + "template_dir", + "private_path", + "public_path", + "db_file", + "jwks_file" +] + +OP_DEFAULT_CONFIG = { + "capabilities": { + "subject_types_supported": ["public", "pairwise"], + "grant_types_supported": [ + "authorization_code", + "implicit", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + "refresh_token", + ], + }, + "cookie_handler": { + "class": "oidcop.cookie_handler.CookieHandler", + "kwargs": { + "keys": { + "private_path": "private/cookie_jwks.json", + "key_defs": [ + {"type": "OCT", "use": ["enc"], "kid": "enc"}, + {"type": "OCT", "use": ["sig"], "kid": "sig"}, + ], + "read_only": False, }, - "keys": ["private_path", "public_path"], - "cookie_dealer": { - "kwargs": { - "sign_jwk": ["private_path", "public_path"], - "enc_jwk": ["private_path", "public_path"] - } + "name": {"session": "oidc_op", "register": "oidc_op_rp", + "session_management": "sman", }, + }, + }, + "claims_interface": {"class": "oidcop.session.claims.ClaimsInterface", "kwargs": {}}, + "authz": { + "class": "oidcop.authz.AuthzHandling", + "kwargs": { + "grant_config": { + "usage_rules": { + "authorization_code": { + "supports_minting": ["access_token", "refresh_token", "id_token", ], + "max_usage": 1, + }, + "access_token": {}, + "refresh_token": {"supports_minting": ["access_token", "refresh_token"]}, + }, + "expires_in": 43200, } - } - } + }, + }, + "httpc_params": {"verify": False}, + "issuer": "https://{domain}:{port}", + "template_dir": "templates", + "token_handler_args": { + "jwks_file": "private/token_jwks.json", + "code": {"kwargs": {"lifetime": 600}}, + "token": {"class": "oidcop.token.jwt_token.JWTToken", "kwargs": {"lifetime": 3600}, }, + "refresh": {"class": "oidcop.token.jwt_token.JWTToken", "kwargs": {"lifetime": 86400}, }, + "id_token": {"class": "oidcop.token.id_token.IDToken", "kwargs": {}}, + }, } +AS_DEFAULT_CONFIG = copy.deepcopy(OP_DEFAULT_CONFIG) +AS_DEFAULT_CONFIG["claims_interface"] = { + "class": "oidcop.session.claims.OAuth2ClaimsInterface", "kwargs": {}} + + +def add_base_path(conf: Union[dict, str], base_path: str, file_attributes: List[str]): + if isinstance(conf, str): + if conf.startswith("/"): + pass + elif conf == "": + conf = "./" + conf + else: + conf = os.path.join(base_path, conf) + elif isinstance(conf, dict): + for key, val in conf.items(): + if key in file_attributes: + if val.startswith("/"): + continue + elif val == "": + conf[key] = "./" + val + else: + conf[key] = os.path.join(base_path, val) + if isinstance(val, dict): + conf[key] = add_base_path(val, base_path, file_attributes) + + return conf + + +URIS = ["issuer", "base_url"] + + +def set_domain_and_port(conf: dict, uris: List[str], domain: str, port: int): + for key, val in conf.items(): + if key in uris: + if isinstance(val, list): + _new = [v.format(domain=domain, port=port) for v in val] + else: + _new = val.format(domain=domain, port=port) + conf[key] = _new + elif isinstance(val, dict): + conf[key] = set_domain_and_port(val, uris, domain, port) + return conf + + +def create_from_config_file( + cls, + filename: str, + base_path: str = "", + entity_conf: Optional[List[dict]] = None, + file_attributes: Optional[List[str]] = None, + domain: Optional[str] = "", + port: Optional[int] = 0, +): + if filename.endswith(".yaml"): + """Load configuration as YAML""" + _conf = load_yaml_config(filename) + elif filename.endswith(".json"): + _str = open(filename).read() + _conf = json.loads(_str) + elif filename.endswith(".py"): + head, tail = os.path.split(filename) + tail = tail[:-3] + module = importlib.import_module(tail) + _conf = getattr(module, "OIDCOP_CONFIG") + else: + raise ValueError("Unknown file type") + + return cls( + _conf, + entity_conf=entity_conf, + base_path=base_path, + file_attributes=file_attributes, + domain=domain, + port=port, + ) + -class Configuration: - """OP Configuration""" +class Base: + """ Configuration base class """ - def __init__(self, conf: Dict, base_path: str = '', item_paths: Optional[dict] = None) -> None: - self.logger = configure_logging(config=conf.get('logging')).getChild(__name__) - self.op = {} - if item_paths is None: - item_paths = DEFAULT_ITEM_PATHS + parameter = {} - if base_path and item_paths: + def __init__( + self, conf: Dict, base_path: str = "", file_attributes: Optional[List[str]] = None, + ): + if file_attributes is None: + file_attributes = DEFAULT_FILE_ATTRIBUTE_NAMES + + if base_path and file_attributes: # this adds a base path to all paths in the configuration - add_base_path(conf, item_paths, base_path) - - # OIDC provider configuration - for section in ['op', 'webserver', 'httpc_params', 'jinja_env']: - setattr(self, section, conf.get(section, {})) - - # set OP session key - _key_args = self.op['server_info'].get('session_key') - if _key_args is not None: - self.session_key = init_key(**_key_args) - # self.op['server_info']['password'] = self.session_key - self.logger.debug("Set server password to %s", self.session_key) - - # templates and Jinja environment - self.template_dir = os.path.abspath(conf.get('template_dir', 'templates')) - - # server info - self.domain = conf.get("domain") - self.port = conf.get("port") - for param in ["server_name", "base_url"]: - _pre = conf.get(param) - if _pre: - if '{domain}' in _pre: - setattr(self, param, _pre.format(domain=self.domain, port=self.port)) + add_base_path(conf, base_path, file_attributes) + + def __getitem__(self, item): + if item in self.__dict__: + return self.__dict__[item] + else: + raise KeyError + + def get(self, item, default=None): + return getattr(self, item, default) + + def __contains__(self, item): + return item in self.__dict__ + + def items(self): + for key in self.__dict__: + if key.startswith("__") and key.endswith("__"): + continue + yield key, getattr(self, key) + + +class EntityConfiguration(Base): + default_config = AS_DEFAULT_CONFIG + + def __init__( + self, + conf: Dict, + base_path: Optional[str] = "", + entity_conf: Optional[List[dict]] = None, + domain: Optional[str] = "", + port: Optional[int] = 0, + file_attributes: Optional[List[str]] = None, + ): + + conf = copy.deepcopy(conf) + Base.__init__(self, conf, base_path, file_attributes) + + self.add_on = None + self.authz = None + self.authentication = None + self.base_url = "" + self.capabilities = None + self.claims_interface = None + self.cookie_handler = None + self.endpoint = {} + self.httpc_params = {} + self.issuer = "" + self.keys = None + self.template_dir = None + self.token_handler_args = {} + self.userinfo = None + self.session_params = None + + if file_attributes is None: + file_attributes = DEFAULT_FILE_ATTRIBUTE_NAMES + + for key in self.__dict__.keys(): + _val = conf.get(key) + if not _val: + if key in self.default_config: + _dc = copy.deepcopy(self.default_config[key]) + add_base_path(_dc, base_path, file_attributes) + _val = _dc else: - setattr(self, param, _pre) + continue + setattr(self, key, _val) - @classmethod - def create_from_config_file(cls, filename: str, base_path: str = '', - item_paths: Optional[dict] = None): - """Load configuration as YAML""" - return cls(load_yaml_config(filename), base_path, item_paths) + if self.template_dir is None: + self.template_dir = os.path.abspath("templates") + else: + self.template_dir = os.path.abspath(self.template_dir) + + if not domain: + domain = conf.get("domain", "127.0.0.1") + + if not port: + port = conf.get("port", 80) + + set_domain_and_port(conf, URIS, domain=domain, port=port) + + +class OPConfiguration(EntityConfiguration): + "Provider configuration" + default_config = OP_DEFAULT_CONFIG + + def __init__( + self, + conf: Dict, + base_path: Optional[str] = "", + entity_conf: Optional[List[dict]] = None, + domain: Optional[str] = "", + port: Optional[int] = 0, + file_attributes: Optional[List[str]] = None, + ): + # OP special + self.id_token = None + self.login_hint2acrs = {} + self.login_hint_lookup = None + + EntityConfiguration.__init__(self, conf=conf, base_path=base_path, + entity_conf=entity_conf, domain=domain, port=port, + file_attributes=file_attributes) + + +class ASConfiguration(EntityConfiguration): + "Authorization server configuration" + + def __init__( + self, + conf: Dict, + base_path: Optional[str] = "", + entity_conf: Optional[List[dict]] = None, + domain: Optional[str] = "", + port: Optional[int] = 0, + file_attributes: Optional[List[str]] = None, + ): + EntityConfiguration.__init__(self, conf=conf, base_path=base_path, + entity_conf=entity_conf, domain=domain, port=port, + file_attributes=file_attributes) + + +class Configuration(Base): + """Server Configuration""" + + def __init__( + self, + conf: Dict, + entity_conf: Optional[List[dict]] = None, + base_path: str = "", + file_attributes: Optional[List[str]] = None, + domain: Optional[str] = "", + port: Optional[int] = 0, + ): + Base.__init__(self, conf, base_path, file_attributes) + + log_conf = conf.get("logging") + if log_conf: + self.logger = configure_logging(config=log_conf).getChild(__name__) + else: + self.logger = logging.getLogger("oidcop") + + self.webserver = conf.get("webserver", {}) + + if not domain: + domain = conf.get("domain", "127.0.0.1") + + if not port: + port = conf.get("port", 80) + + set_domain_and_port(conf, URIS, domain=domain, port=port) + + if entity_conf: + for econf in entity_conf: + _path = econf.get("path") + _cnf = conf + if _path: + for step in _path: + _cnf = _cnf[step] + _attr = econf["attr"] + _cls = econf["class"] + setattr( + self, + _attr, + _cls( + _cnf, + base_path=base_path, + file_attributes=file_attributes, + domain=domain, + port=port, + ), + ) + + +DEFAULT_EXTENDED_CONF = { + "add_on": { + "pkce": { + "function": "oidcop.oidc.add_on.pkce.add_pkce_support", + "kwargs": {"essential": False, "code_challenge_method": "S256 S384 S512"}, + }, + "claims": { + "function": "oidcop.oidc.add_on.custom_scopes.add_custom_scopes", + "kwargs": { + "research_and_scholarship": [ + "name", + "given_name", + "family_name", + "email", + "email_verified", + "sub", + "iss", + "eduperson_scoped_affiliation", + ] + }, + }, + }, + "authz": { + "class": "oidcop.authz.AuthzHandling", + "kwargs": { + "grant_config": { + "usage_rules": { + "authorization_code": { + "supports_minting": ["access_token", "refresh_token", "id_token", ], + "max_usage": 1, + }, + "access_token": {}, + "refresh_token": {"supports_minting": ["access_token", "refresh_token"]}, + }, + "expires_in": 43200, + } + }, + }, + "authentication": { + "user": { + "acr": "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword", + "class": "oidcop.user_authn.user.UserPassJinja2", + "kwargs": { + "verify_endpoint": "verify/user", + "template": "user_pass.jinja2", + "db": {"class": "oidcop.util.JSONDictDB", "kwargs": {"filename": "passwd.json"}, }, + "page_header": "Testing log in", + "submit_btn": "Get me in!", + "user_label": "Nickname", + "passwd_label": "Secret sauce", + }, + } + }, + "capabilities": { + "subject_types_supported": ["public", "pairwise"], + "grant_types_supported": [ + "authorization_code", + "implicit", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + "refresh_token", + ], + }, + "cookie_handler": { + "class": "oidcop.cookie_handler.CookieHandler", + "kwargs": { + "keys": { + "private_path": "private/cookie_jwks.json", + "key_defs": [ + {"type": "OCT", "use": ["enc"], "kid": "enc"}, + {"type": "OCT", "use": ["sig"], "kid": "sig"}, + ], + "read_only": False, + }, + "name": {"session": "oidc_op", "register": "oidc_op_rp", + "session_management": "sman", }, + }, + }, + "endpoint": { + "webfinger": { + "path": ".well-known/webfinger", + "class": "oidcop.oidc.discovery.Discovery", + "kwargs": {"client_authn_method": None}, + }, + "provider_info": { + "path": ".well-known/openid-configuration", + "class": "oidcop.oidc.provider_config.ProviderConfiguration", + "kwargs": {"client_authn_method": None}, + }, + "registration": { + "path": "registration", + "class": "oidcop.oidc.registration.Registration", + "kwargs": {"client_authn_method": None, "client_secret_expiration_time": 432000, }, + }, + "registration_api": { + "path": "registration_api", + "class": "oidcop.oidc.read_registration.RegistrationRead", + "kwargs": {"client_authn_method": ["bearer_header"]}, + }, + "introspection": { + "path": "introspection", + "class": "oidcop.oauth2.introspection.Introspection", + "kwargs": {"client_authn_method": ["client_secret_post"], "release": ["username"], }, + }, + "authorization": { + "path": "authorization", + "class": "oidcop.oidc.authorization.Authorization", + "kwargs": { + "client_authn_method": None, + "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", + # "none" + ], + "response_modes_supported": ["query", "fragment", "form_post"], + }, + }, + "token": { + "path": "token", + "class": "oidcop.oidc.token.Token", + "kwargs": { + "client_authn_method": [ + "client_secret_post", + "client_secret_basic", + "client_secret_jwt", + "private_key_jwt", + ] + }, + }, + "userinfo": { + "path": "userinfo", + "class": "oidcop.oidc.userinfo.UserInfo", + "kwargs": {"claim_types_supported": ["normal", "aggregated", "distributed"]}, + }, + "end_session": { + "path": "session", + "class": "oidcop.oidc.session.Session", + "kwargs": { + "logout_verify_url": "verify_logout", + "post_logout_uri_path": "post_logout", + "signing_alg": "ES256", + "frontchannel_logout_supported": True, + "frontchannel_logout_session_supported": True, + "backchannel_logout_supported": True, + "backchannel_logout_session_supported": True, + "check_session_iframe": "check_session_iframe", + }, + }, + }, + "httpc_params": {"verify": False}, + "issuer": "https://{domain}:{port}", + "keys": { + "private_path": "private/jwks.json", + "key_defs": [ + {"type": "RSA", "use": ["sig"]}, + {"type": "EC", "crv": "P-256", "use": ["sig"]}, + ], + "public_path": "static/jwks.json", + "read_only": False, + "uri_path": "static/jwks.json", + }, + "login_hint2acrs": { + "class": "oidcop.login_hint.LoginHint2Acrs", + "kwargs": { + "scheme_map": {"email": ["urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword"]} + }, + }, + "template_dir": "templates", + "token_handler_args": { + "jwks_def": { + "private_path": "private/token_jwks.json", + "read_only": False, + "key_defs": [{"type": "oct", "bytes": "24", "use": ["enc"], "kid": "code"}], + }, + "code": {"kwargs": {"lifetime": 600}}, + "token": { + "class": "oidcop.token.jwt_token.JWTToken", + "kwargs": { + "lifetime": 3600, + "add_claims_by_scope": True, + "aud": ["https://example.org/appl"], + }, + }, + "refresh": { + "class": "oidcop.token.jwt_token.JWTToken", + "kwargs": {"lifetime": 3600, "aud": ["https://example.org/appl"], }, + }, + "id_token": { + "class": "oidcop.token.id_token.IDToken", + "kwargs": { + "base_claims": { + "email": {"essential": True}, + "email_verified": {"essential": True}, + } + }, + }, + }, + "userinfo": {"class": "oidcop.user_info.UserInfo", "kwargs": {"db_file": "users.json"}, }, +} diff --git a/src/oidcop/constant.py b/src/oidcop/constant.py new file mode 100644 index 00000000..c0527b74 --- /dev/null +++ b/src/oidcop/constant.py @@ -0,0 +1 @@ +DIVIDER = ";;" diff --git a/src/oidcop/construct.py b/src/oidcop/construct.py new file mode 100644 index 00000000..3ef728f7 --- /dev/null +++ b/src/oidcop/construct.py @@ -0,0 +1,86 @@ +import logging +import re + +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} +WEAK_ALGS = ["RSA1_5", "none"] + +logger = logging.getLogger(__name__) + + +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 None: + return default_capabilities + + _info = {} + for attr, default_val in default_capabilities.items(): + if attr in kwargs: + _proposal = kwargs[attr] + _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, " + f"'{attr}' sould be {_permitted} it's instead {_proposal}" + ) + _info[attr] = _proposal + else: + if default_val is not None: + _info[attr] = default_val + elif "signing_alg_values_supported" in attr: + _info[attr] = assign_algorithms("signing_alg") + if "none" in _info[attr]: + _info[attr].remove("none") + elif "encryption_alg_values_supported" in attr: + # RSA1_5 not among defaults + _info[attr] = [s for s in assign_algorithms("encryption_alg") if s not in WEAK_ALGS] + elif "encryption_enc_values_supported" in attr: + _info[attr] = assign_algorithms("encryption_enc") + + if re.match(r".*(alg|enc).*_values_supported", attr): + for i in _info[attr]: + if i in WEAK_ALGS: + logger.warning( + f"Found {i} in {attr}. This is a weak algorithm " + "that MUST not be used in production!" + ) + return _info diff --git a/src/oidcop/cookie_handler.py b/src/oidcop/cookie_handler.py new file mode 100755 index 00000000..4524c94f --- /dev/null +++ b/src/oidcop/cookie_handler.py @@ -0,0 +1,261 @@ +import base64 +import hashlib +import logging +import os +import time +from typing import List +from typing import Optional +from typing import Union +from urllib.parse import urlparse + +from cryptography.exceptions import InvalidTag +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_jar import init_key_jar +from oidcmsg.time_util import epoch_in_a_while + +from oidcop.util import lv_pack +from oidcop.util import lv_unpack + +__author__ = "Roland Hedberg" + +LOGGER = logging.getLogger(__name__) + + +# The only thing I want to be able to do is to set names, values, expires and max-age on cookies. +# I don't care about the remaining attributes of a cookie. + + +class CookieHandler: + def __init__( + self, + sign_key: Optional[SYMKey] = None, + enc_key: Optional[SYMKey] = None, + keys: Optional[dict] = None, + sign_alg: [str] = "SHA256", + name: Optional[dict] = None, + ): + + if keys: + key_jar = init_key_jar(**keys) + _keys = key_jar.get_signing_key(key_type="oct", kid="sig") + if _keys: + self.sign_key = _keys[0] + _keys = key_jar.get_encrypt_key(key_type="oct", kid="enc") + if _keys: + self.enc_key = _keys[0] + else: + if sign_key: + if isinstance(sign_key, SYMKey): + self.sign_key = sign_key + else: + self.sign_key = SYMKey(k=sign_key) + else: + self.sign_key = None + + if enc_key: + if isinstance(enc_key, SYMKey): + self.enc_key = enc_key + else: + self.enc_key = SYMKey(k=enc_key) + else: + self.enc_key = None + + self.sign_alg = sign_alg + + self.time_format = "%a, %d-%b-%Y %H:%M:%S GMT" + + if name is None: + self.name = { + "session": "oidc_op", + "register": "oidc_op_reg", + "session_management": "oidc_op_sman", + } + else: + self.name = name + + def _sign_enc_payload(self, payload: str, timestamp: Optional[Union[int, str]] = 0): + """ + Creates signed and/or encrypted information. + + :param load: The basic information in the payload + :param timestamp: A timestamp (seconds since epoch) + :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 = payload.encode("utf-8") + bytes_timestamp = timestamp.encode("utf-8") + + if self.sign_key: + signer = HMACSigner(algorithm=self.sign_alg) + mac = signer.sign(bytes_load + bytes_timestamp, self.sign_key.key) + else: + mac = b"" + + if self.enc_key: + if len(self.enc_key.key) not in [16, 24, 32]: + raise ValueError("Wrong size of enc_key") + + encrypter = AES_GCMEncrypter(key=self.enc_key.key) + iv = os.urandom(12) + if mac: + msg = lv_pack(payload, timestamp, base64.b64encode(mac).decode("utf-8")) + else: + msg = lv_pack(payload, 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(self, parts): + """ + Verifies the value of a cookie + + :param parts: The parts of the payload + :return: A tuple with basic information and a timestamp + """ + + if parts is None: + return None + elif len(parts) == 3: + # verify the cookie signature + timestamp, payload, b64_mac = parts + mac = base64.b64decode(b64_mac) + verifier = HMACSigner(algorithm=self.sign_alg) + if verifier.verify( + payload.encode("utf-8") + timestamp.encode("utf-8"), mac, self.sign_key.key, + ): + return payload, timestamp + else: + raise VerificationError() + elif len(parts) == 4: + iv = base64.b64decode(parts[1]) + ciphertext = base64.b64decode(parts[2]) + tag = base64.b64decode(parts[3]) + + decrypter = AES_GCMEncrypter(key=self.enc_key.key) + try: + msg = decrypter.decrypt(ciphertext, iv, tag=tag) + except InvalidTag: + return None + + p = lv_unpack(msg.decode("utf-8")) + payload = p[0] + timestamp = p[1] + if len(p) == 3: + verifier = HMACSigner(algorithm=self.sign_alg) + if verifier.verify( + payload.encode("utf-8") + timestamp.encode("utf-8"), + base64.b64decode(p[2]), + self.sign_key.key, + ): + return payload, timestamp + else: + return payload, timestamp + return None + + def make_cookie_content( + self, + name: str, + value: str, + typ: Optional[str] = "", + timestamp: Optional[Union[int, str]] = "", + max_age: Optional[int] = 0, + **kwargs + ) -> dict: + """ + Create and return information to put in a cookie + + :param typ: The type of cookie + :param name: Cookie name + :param value: Cookie value + :param timestamp: A time stamp + :param max_age: The time in seconds for when a cookie will be deleted + :return: A dictionary + """ + + if not timestamp: + timestamp = str(int(time.time())) + + # create cookie payload + if not value and not typ: + _cookie_value = "" + else: + try: + cookie_payload = "::".join([value, typ]) + except TypeError: + cookie_payload = "::".join([value[0], typ]) + + _cookie_value = self._sign_enc_payload(cookie_payload, timestamp) + + content = {"name": name, "value": _cookie_value} + + if max_age == -1: + content["Expires"] = "Thu, 01 Jan 1970 00:00:00 GMT;" + elif max_age: + content["Max-Age"] = epoch_in_a_while(seconds=max_age) + + return content + + def parse_cookie(self, name: str, cookies: List[dict]) -> Optional[List[dict]]: + """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 kakor: A list of dictionaries with cookie information + :raises InvalidCookieSign: When verification fails. + :return: A list of dictionaries with information from the cookie or None if parsing fails + """ + if not cookies: + return None + + res = [] + for _cookie in cookies: + if _cookie["name"] == name: + payload, timestamp = self._ver_dec_content(_cookie["value"].split("|")) + value, typ = payload.split("::") + res.append({"value": value, "type": typ, "timestamp": timestamp}) + return res + + +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 diff --git a/src/oidcop/endpoint.py b/src/oidcop/endpoint.py new file mode 100755 index 00000000..3152cbb3 --- /dev/null +++ b/src/oidcop/endpoint.py @@ -0,0 +1,418 @@ +import logging +from typing import Callable +from typing import List +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 client_auth_setup +from oidcop.client_authn import verify_client +from oidcop.construct import construct_endpoint_info +from oidcop.endpoint_context import EndpointContext +from oidcop.exception import UnAuthorizedClient +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_cookies(self, cookies: List[dict], context: EndpointContext, name: str): + res = context.cookie_handler.parse_cookie(name, cookies) + return res + + def parse_request( + self, request: Union[Message, dict, str], http_info: Optional[dict] = None, **kwargs + ): + """ + + :param request: The request the server got + :param http_info: HTTP information in connection with the request. + This is a dictionary with keys: headers, url, cookies. + :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 http_info is None: + http_info = {} + + 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 = "" + auth_info = self.client_authentication(req, http_info, endpoint=self, **kwargs) + + 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(request=req, client_id=_client_id, http_info=http_info, + **kwargs) + + def get_client_id_from_token( + self, + endpoint_context: EndpointContext, + token: str, + request: Optional[Union[Message, dict]] = None, + ): + return "" + + def client_authentication(self, request: Message, http_info: Optional[dict] = None, **kwargs): + """ + Do client authentication + + :param request: Parsed request, a self.request_cls class instance + :param http_info: HTTP headers, URL used and cookies. + :return: client_id or raise an exception + """ + + if "endpoint" not in kwargs: + kwargs["endpoint"] = self + + authn_info = verify_client( + endpoint_context=self.server_get("endpoint_context"), + request=request, + http_info=http_info, + get_client_id_from_token=self.get_client_id_from_token, + **kwargs + ) + + 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: Message, client_id: Optional[str] = "", **kwargs + ) -> Message: + _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: dict, request: Optional[Union[Message, dict]] = None, **kwargs + ) -> dict: + _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: Union[Message, dict], + request: Optional[Union[Message, dict]] = None, + **kwargs + ) -> dict: + _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, + http_info: Optional[dict] = None, + **kwargs + ): + """ + + :param http_info: Information on the HTTP request + :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.response_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.response_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" + fragment_enc = kwargs.get("fragment_enc") + if not fragment_enc: + _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..e68cc99c --- /dev/null +++ b/src/oidcop/endpoint_context.py @@ -0,0 +1,324 @@ +import json +import logging +from typing import Any +from typing import Optional +from typing import Union + +import requests +from cryptojwt import KeyJar +from jinja2 import Environment +from jinja2 import FileSystemLoader +from oidcmsg.context import OidcContext + +from oidcop import rndstr +from oidcop.configure import OPConfiguration +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", {}) + + if server_get: + kwargs["server_get"] = server_get + + if isinstance(conf["class"], str): + try: + return importer(conf["class"])(**kwargs) + except TypeError as err: + logger.error("Could not init service class: {}".format(conf["class"]), err) + raise + + 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_handler": None, + "cwd": "", + "endpoint_to_authn_method": {}, + "httpc_params": {}, + # "idtoken": IDToken, + "issuer": "", + "jti_db": {}, + "jwks_uri": "", + "keyjar": KeyJar, + "login_hint_lookup": None, + "login_hint2acrs": {}, + "par_db": {}, + "provider_info": {}, + "registration_access_token": {}, + "scope2claims": {}, + # "session_db": {}, + "session_manager": SessionManager, + "sso_ttl": None, + "symkey": "", + "token_args_methods": [], + # "userinfo": UserInfo, + } + + def __init__( + self, + conf: Union[dict, OPConfiguration], + keyjar: Optional[KeyJar] = None, + cwd: Optional[str] = "", + cookie_handler: 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 + + # 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_handler = cookie_handler + 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() + + _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 = {} + _keys_conf = conf.get("keys") + if _keys_conf: + jwks_uri_path = _keys_conf["uri_path"] + + if self.issuer.endswith("/"): + self.jwks_uri = "{}{}".format(self.issuer, jwks_uri_path) + else: + self.jwks_uri = "{}/{}".format(self.issuer, jwks_uri_path) + + for item in [ + "cookie_handler", + "authentication", + "id_token", + "scope2claims", + ]: + _func = getattr(self, "do_{}".format(item), None) + if _func: + _func() + + for item in ["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", True)} + + self.set_scopes_handler() + self.dev_auth_db = None + self.claims_interface = None + + def new_cookie(self, name: str, max_age: Optional[int] = 0, **kwargs): + return self.cookie_handler.make_cookie_content( + name=name, value=json.dumps(kwargs), max_age=max_age + ) + + 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): + _add_on_conf = self.conf.get("add_on") + if _add_on_conf: + for spec in _add_on_conf.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_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_handler(self): + _conf = self.conf.get("cookie_handler") + if _conf: + if not self.cookie_handler: + self.cookie_handler = init_service(_conf) + + def do_sub_func(self) -> None: + """ + Loads functions that creates subject "sub" values + + :return: string + """ + ses_par = self.conf.get("session_params") or {} + sub_func = ses_par.get("sub_func") or {} + for key, args in sub_func.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 + + 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..48a25093 --- /dev/null +++ b/src/oidcop/exception.py @@ -0,0 +1,102 @@ +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." + + +class ProcessError(OidcEndpointError): + pass + + +class ServiceError(OidcEndpointError): + pass + + +class InvalidRequest(OidcEndpointError): + pass + + +class CapabilitiesMisMatch(OidcEndpointError): + pass + + +class MultipleCodeUsage(OidcEndpointError): + pass + + +class InvalidToken(Exception): + pass diff --git a/src/oidcop/logging.py b/src/oidcop/logging.py index f5134def..d04c577e 100755 --- a/src/oidcop/logging.py +++ b/src/oidcop/logging.py @@ -1,52 +1,41 @@ """Common logging functions""" - -import os import logging +import os from logging.config import dictConfig +from typing import Optional import yaml - -LOGGING_CONF = 'logging.yaml' +LOGGING_CONF = "logging.yaml" LOGGING_DEFAULT = { - 'version': 1, - 'formatters': { - 'default': { - 'format': '%(asctime)s %(name)s %(levelname)s %(message)s' - } - }, - 'handlers': { - 'default': { - 'class': 'logging.StreamHandler', - 'formatter': 'default' - } - }, - 'root': { - 'handlers': ['default'], - 'level': 'INFO' - } + "version": 1, + "formatters": {"default": {"format": "%(asctime)s %(name)s %(levelname)s %(message)s"}}, + "handlers": {"default": {"class": "logging.StreamHandler", "formatter": "default"}}, + "root": {"handlers": ["default"], "level": "INFO"}, } -def configure_logging(debug: bool = False, config: dict = None, - filename: str = LOGGING_CONF) -> logging.Logger: +def configure_logging( + debug: Optional[bool] = False, config: Optional[dict] = None, filename: Optional[str] = "", +) -> logging.Logger: """Configure logging""" if config is not None: config_dict = config - config_source = 'dictionary' - elif filename is not None and os.path.exists(filename): + config_source = "dictionary" + elif filename and os.path.exists(filename): with open(filename, "rt") as file: - config_dict = yaml.load(file) - config_source = 'file' + config_dict = yaml.safe_load(file) + config_source = "file" else: config_dict = LOGGING_DEFAULT - config_source = 'default' + config_source = "default" if debug: - config_dict['root']['level'] = 'DEBUG' + config_dict["root"]["level"] = "DEBUG" dictConfig(config_dict) - logging.debug("Configured logging using %s", config_source) - return logging.getLogger() + logger = logging.getLogger() + logger.debug("Configured logging using: {}".format(config_source)) + return logger diff --git a/src/oidcop/login_hint.py b/src/oidcop/login_hint.py new file mode 100644 index 00000000..cd64a3c9 --- /dev/null +++ b/src/oidcop/login_hint.py @@ -0,0 +1,36 @@ +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) + elif arg.startswith("mail:"): + _mail = arg[5:] + return self.userinfo.search(email=_mail) + + +class LoginHint2Acrs(object): + """ + OIDC Login hint support + """ + 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/dpop.py b/src/oidcop/oauth2/add_on/dpop.py new file mode 100644 index 00000000..472c5ea6 --- /dev/null +++ b/src/oidcop/oauth2/add_on/dpop.py @@ -0,0 +1,170 @@ +from typing import Optional + +from cryptojwt import JWS +from cryptojwt import as_unicode +from cryptojwt.jwk.jwk import key_from_jwk_dict +from cryptojwt.jws.jws import factory +from oidcmsg.message import SINGLE_REQUIRED_INT +from oidcmsg.message import SINGLE_REQUIRED_JSON +from oidcmsg.message import SINGLE_REQUIRED_STRING +from oidcmsg.message import Message + +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} + self.key.kid = "" + _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"]) + + # Need something I can add as a reference when minting tokens + request["dpop_jkt"] = as_unicode(_dpop.key.thumbprint("SHA-256")) + return request + + +def token_args(endpoint_context, client_id, token_args: Optional[dict] = None): + dpop_jkt = endpoint_context.cdb[client_id]["dpop_jkt"] + _jkt = list(dpop_jkt.keys())[0] + if "dpop_jkt" in endpoint_context.cdb[client_id]: + if token_args is None: + token_args = {"cnf": {"jkt": _jkt}} + else: + token_args.update({"cnf": {"jkt": endpoint_context.cdb[client_id]["dpop_jkt"]}}) + + return token_args + + +def add_support(endpoint, **kwargs): + # + _token_endp = endpoint["token"] + _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"] + + _token_endp.server_get("endpoint_context").provider_info[ + "dpop_signing_alg_values_supported" + ] = _algs_supported + + _endpoint_context = _token_endp.server_get("endpoint_context") + _endpoint_context.dpop_enabled = True + + +# 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/authorization.py b/src/oidcop/oauth2/authorization.py new file mode 100755 index 00000000..2a68230e --- /dev/null +++ b/src/oidcop/oauth2/authorization.py @@ -0,0 +1,944 @@ +import json +import logging +from typing import List +from typing import Optional +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.oauth2 import AuthorizationRequest +from oidcmsg.oidc import AuthorizationResponse +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_handler import compute_session_state +from oidcop.endpoint import Endpoint +from oidcop.endpoint_context import EndpointContext +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.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: EndpointContext, + request: Union[dict, Message], + uri_type: str, + client_id: Optional[str] = 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)) + + _uri = request.get(uri_type) + if _uri is None: + raise ValueError(f"Wrong uri_type: {uri_type}") + + _redirect_uri = unquote(_uri) + + part = urlparse(_redirect_uri) + if part.fragment: + raise URIError("Contains fragment") + + (_base, _query) = split_uri(_redirect_uri) + # if _query: + # _query = parse_qs(_query) + + # Get the clients registered redirect uris + client_info = endpoint_context.cdb.get(_cid) + if client_info is None: + raise KeyError("No such client") + + logger.debug("Client info: {}".format(client_info)) + redirect_uris = client_info.get("{}s".format(uri_type)) + if redirect_uris is None: + raise ValueError(f"No registered {uri_type} for {_cid}") + else: + match = False + 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 = f"{uri_type}s" + 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(f"Missing '{uri_type}' and none registered") + + if len(_specs) > 1: + raise ParameterError(f"Missing '{uri_type}' and more than one registered") + + uri = join_query(*_specs[0]) + else: + raise UnknownClient(client_id) + + return uri + + +def authn_args_gather( + request: Union[AuthorizationRequest, dict], authn_class_ref: str, cinfo: dict, **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 = {} + + if isinstance(request, Message): + authn_args["query"] = request.to_urlencoded() + elif isinstance(request, dict): + authn_args["query"] = urlencode(request) + else: + raise ValueError("Wrong request format") + + authn_args.update({"authn_class_ref": authn_class_ref, "return_uri": request["redirect_uri"]}) + + 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: Union[Message, dict], cinfo: dict) -> bool: + # 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_class, grant, session_id, based_on=None, **kwargs): + usage_rules = grant.usage_rules.get(token_class, {}) + token = grant.mint_token( + session_id=session_id, + endpoint_context=self.server_get("endpoint_context"), + token_class=token_class, + based_on=based_on, + usage_rules=usage_rules, + **kwargs, + ) + + _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 + + _mngr = self.server_get("endpoint_context").session_manager + _mngr.set(_mngr.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: + # One time usage + del endpoint_context.par_db[_request_uri] + return _req + else: + raise ValueError("Got a request_uri I can not resolve") + + # Do I support request_uri ? + if endpoint_context.provider_info.get("request_uri_parameter_supported", True) is False: + raise ServiceError("Someone is using request_uri which I'm not supporting") + + _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 [base for base, qp 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) 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] + + res = None + if acr: + res = _context.authn_broker.pick(acr) + else: + try: + res = pick_auth(_context, request) + except Exception as exc: + logger.exception( + f"An error occurred while picking the authN broker: {exc}" + ) + 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 create_session(self, request, user_id, acr, time_stamp, authn_method): + _context = self.server_get("endpoint_context") + _mngr = _context.session_manager + authn_event = create_authn_event(user_id, authn_info=acr, time_stamp=time_stamp,) + _exp_in = authn_method.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"]) + return _mngr.create_session( + authn_event=authn_event, + auth_req=request, + user_id=user_id, + client_id=request["client_id"], + token_usage_rules=_token_usage_rules, + ) + + def setup_auth( + self, + request: Optional[Union[Message, dict]], + redirect_uri: str, + cinfo: dict, + cookie: List[dict] = None, + acr: str = None, + **kwargs, + ): + """ + + :param request: The authorization/authentication request + :param redirect_uri: + :param cinfo: client info + :param cookie: List of cookies + :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"] + + client_id = request.get("client_id") + _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( + client_id, 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 not identity: # 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 got 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 = _mngr.decrypt_session_id(_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: + _session_id = self.create_session(request, identity["uid"], authn_class_ref, _ts, authn) + + return {"session_id": _session_id, "identity": identity, "user": user} + + def aresp_check(self, aresp, request): + return "" + + def response_mode( + self, + request: Union[dict, AuthorizationRequest], + response_args: Optional[AuthorizationResponse] = None, + return_uri: Optional[str] = "", + fragment_enc: Optional[bool] = None, + **kwargs, + ) -> dict: + resp_mode = request["response_mode"] + if resp_mode == "form_post": + if isinstance(response_args, AuthorizationRequest): + _args = response_args.to_dict() + else: + _args = response_args + msg = FORM_POST.format(inputs=inputs(_args), action=return_uri,) + kwargs.update( + {"response_msg": msg, "content_type": "text/html", "response_placement": "body",} + ) + elif resp_mode == "fragment": + if fragment_enc is False: + # Can't be done + raise InvalidRequest("wrong response_mode") + else: + kwargs["fragment_enc"] = True + elif resp_mode == "query": + if fragment_enc is True: + # Can't be done + raise InvalidRequest("wrong response_mode") + else: + raise InvalidRequest("Unknown response_mode") + + if resp_mode in ["fragment", "query"]: + kwargs.update({"response_args": response_args, "return_uri": return_uri}) + + 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_class="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: + _access_token = self.mint_token( + token_class="access_token", grant=grant, session_id=_sinfo["session_id"], + ) + 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 = self.mint_token( + token_class="id_token", + grant=grant, + session_id=_sinfo["session_id"], + **kwargs, + ) + # 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.value + 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 + + grant = _context.authz(session_id, request=request) + if grant.is_active() is False: + return self.error_response(response_info, "server_error", "Grant not usable") + + user_id, client_id, grant_id = _mngr.decrypt_session_id(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 + + # 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)) + + _cookie_info = _context.new_cookie( + name=_context.cookie_handler.name["session"], + sid=session_id, + state=request.get("state"), + ) + + response_info["cookie"] = [_cookie_info] + + return response_info + + def authz_part2(self, request, session_id, **kwargs): + """ + After the authentication this is where you should end up + + :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"]}))) + + _session_cookie_content = _context.new_cookie( + name=_context.cookie_handler.name["session_management"], state=as_unicode(_state), + ) + + opbs_value = _session_cookie_content["value"] + + 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 _session_cookie_content: + if "cookie" in resp_info: + resp_info["cookie"].append(_session_cookie_content) + else: + resp_info["cookie"] = [_session_cookie_content] + + 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: Optional[Union[Message, dict]] = None, + http_info: Optional[dict] = None, + **kwargs, + ): + """ The AuthorizationRequest endpoint + + :param http_info: Information on the HTTP request + :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) + + if http_info is None: + http_info = {} + + _cookies = http_info.get("cookie") + if _cookies: + _cookies = _context.cookie_handler.parse_cookie("oidcop", _cookies) + + kwargs = self.do_request_user(request_info=request, **kwargs) + + info = self.setup_auth(request, request["redirect_uri"], cinfo, _cookies, **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=_cookies, **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) -> bool: + """ + This is where you can demand reauthentication even though the authentication in use + is still valid. + + :param request: + :param authn: + :return: + """ + return False diff --git a/src/oidcop/oauth2/introspection.py b/src/oidcop/oauth2/introspection.py new file mode 100644 index 00000000..de32f720 --- /dev/null +++ b/src/oidcop/oauth2/introspection.py @@ -0,0 +1,127 @@ +"""Implements RFC7662""" +import logging +from typing import Optional + +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.token_class not in ["access_token", "refresh_token"]: + return None + + if not token.is_active(): + return None + + scope = token.scope + if not scope: + if token.based_on: + scope = grant.find_scope(token.based_on) + else: + 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_class": token.token_class, + "exp": token.expires_at, + "iat": token.issued_at, + "sub": grant.sub, + "iss": _context.issuer, + } + + try: + _token_type = token.token_type + except AttributeError: + _token_type = None + + if _token_type: + ret["token_type"] = _token_type + + 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, release: Optional[list] = None, **kwargs): + """ + + :param request: The authorization request as a dictionary + :param release: Information about what should be released + :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} + + grant = _session_info["grant"] + _token = grant.get_token(request_token) + + _info = self._introspect(_token, _session_info["client_id"], _session_info["grant"]) + if _info is None: + return {"response_args": _resp} + + if release: + if "username" in release: + try: + _info["username"] = _session_info["user_id"] + except KeyError: + pass + + _resp.update(_info) + _resp.weed() + + _claims_restriction = 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/oauth2/token.py b/src/oidcop/oauth2/token.py new file mode 100755 index 00000000..d35af5d3 --- /dev/null +++ b/src/oidcop/oauth2/token.py @@ -0,0 +1,422 @@ +import logging +from typing import Optional +from typing import Union + +from cryptojwt.jwe.exception import JWEException +from cryptojwt.jwt import utc_time_sans_frac + +from oidcmsg import oidc +from oidcmsg.message import Message +from oidcmsg.oauth2 import AccessTokenResponse +from oidcmsg.oauth2 import ResponseMessage +from oidcmsg.oidc import RefreshAccessTokenRequest +from oidcmsg.oidc import TokenErrorResponse +from oidcmsg.time_util import time_sans_frac + +from oidcop import sanitize +from oidcop.endpoint import Endpoint +from oidcop.exception import ProcessError +from oidcop.session.grant import AuthorizationCode +from oidcop.session.grant import Grant +from oidcop.session.grant import RefreshToken +from oidcop.session.token import MintingNotAllowed +from oidcop.session.token import SessionToken +from oidcop.token.exception import UnknownToken +from oidcop.util import importer + +logger = logging.getLogger(__name__) + + +class TokenEndpointHelper(object): + def __init__(self, endpoint, config=None): + self.endpoint = endpoint + self.config = config + self.error_cls = self.endpoint.error_cls + + def post_parse_request( + self, request: Union[Message, dict], client_id: Optional[str] = "", **kwargs + ): + """Context specific parsing of the request. + This is done after general request parsing and before processing + the request. + """ + raise NotImplementedError + + def process_request(self, req: Union[Message, dict], **kwargs): + """Acts on a process request.""" + raise NotImplementedError + + def _mint_token( + self, + token_class: str, + grant: Grant, + session_id: str, + client_id: str, + based_on: Optional[SessionToken] = None, + token_args: Optional[dict] = None, + token_type: Optional[str] = "" + ) -> SessionToken: + _context = self.endpoint.server_get("endpoint_context") + _mngr = _context.session_manager + usage_rules = grant.usage_rules.get(type) + if usage_rules: + _exp_in = usage_rules.get("expires_in") + else: + _exp_in = 0 + + token_args = token_args or {} + for meth in _context.token_args_methods: + token_args = meth(_context, client_id, token_args) + + if token_args: + _args = {"token_args": token_args} + else: + _args = {} + + token = grant.mint_token( + session_id, + endpoint_context=_context, + token_class=token_class, + token_handler=_mngr.token_handler[token_class], + based_on=based_on, + usage_rules=usage_rules, + token_type=token_type, + **_args, + ) + + if _exp_in: + if isinstance(_exp_in, str): + _exp_in = int(_exp_in) + + if _exp_in: + token.expires_at = time_sans_frac() + _exp_in + + _context.session_manager.set(_context.session_manager.unpack_session_key(session_id), grant) + + return token + + +class AccessTokenHelper(TokenEndpointHelper): + 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"] + + _based_on = grant.get_token(_access_code) + _supports_minting = _based_on.usage_rules.get("supports_minting", []) + + _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 = kwargs.get("issue_refresh", False) + + _response = { + "token_type": "Bearer", + "scope": grant.scope, + } + + if "access_token" in _supports_minting: + try: + token = self._mint_token( + token_class="access_token", + grant=grant, + session_id=_session_info["session_id"], + client_id=_session_info["client_id"], + based_on=_based_on, + ) + except MintingNotAllowed as err: + logger.warning(err) + else: + _response["access_token"] = token.value + if token.expires_at: + _response["expires_in"] = token.expires_at - utc_time_sans_frac() + + if issue_refresh and "refresh_token" in _supports_minting: + try: + refresh_token = self._mint_token( + token_class="refresh_token", + grant=grant, + session_id=_session_info["session_id"], + client_id=_session_info["client_id"], + based_on=_based_on, + ) + except MintingNotAllowed as err: + logger.warning(err) + else: + _response["refresh_token"] = refresh_token.value + + # since the grant content has changed. Make sure it's stored + _mngr[_session_info["session_id"]] = grant + + _based_on.register_usage() + + return _response + + def post_parse_request( + self, request: Union[Message, dict], client_id: Optional[str] = "", **kwargs + ): + """ + This is where clients come to get their access tokens + + :param request: The request + :param client_id: Client identifier + :returns: + """ + + _mngr = self.endpoint.server_get("endpoint_context").session_manager + try: + _session_info = _mngr.get_session_info_by_token(request["code"], grant=True) + except (KeyError, UnknownToken): + logger.error("Access Code invalid") + return self.error_cls(error="invalid_grant", error_description="Unknown code") + + grant = _session_info["grant"] + code = grant.get_token(request["code"]) + if not isinstance(code, AuthorizationCode): + return self.error_cls(error="invalid_request", error_description="Wrong token type") + + if code.is_active() is False: + return self.error_cls(error="invalid_request", error_description="Code inactive") + + _auth_req = grant.authorization_request + + if "client_id" not in request: # Optional for access token request + request["client_id"] = _auth_req["client_id"] + + logger.debug("%s: %s" % (request.__class__.__name__, sanitize(request))) + + return request + + +class RefreshTokenHelper(TokenEndpointHelper): + 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) + + _grant = _session_info["grant"] + token = _grant.get_token(token_value) + access_token = self._mint_token( + token_class="access_token", + grant=_grant, + session_id=_session_info["session_id"], + client_id=_session_info["client_id"], + based_on=token, + ) + + _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( + token_class="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() + _resp["refresh_token"] = refresh_token.value + + token.register_usage() + + return _resp + + def post_parse_request( + self, request: Union[Message, dict], client_id: Optional[str] = "", **kwargs + ): + """ + This is where clients come to refresh their access tokens + + :param request: The request + :param client_id: Client identifier + :returns: + """ + + request = RefreshAccessTokenRequest(**request.to_dict()) + _context = self.endpoint.server_get("endpoint_context") + try: + keyjar = _context.keyjar + except AttributeError: + keyjar = "" + + request.verify(keyjar=keyjar, opponent_id=client_id) + + _mngr = _context.session_manager + try: + _session_info = _mngr.get_session_info_by_token(request["refresh_token"], grant=True) + except KeyError: + logger.error("Access Code invalid") + return self.error_cls(error="invalid_grant") + + token = _session_info["grant"].get_token(request["refresh_token"]) + + if not isinstance(token, RefreshToken): + return self.error_cls(error="invalid_request", error_description="Wrong token type") + + if token.is_active() is False: + return self.error_cls( + error="invalid_request", error_description="Refresh token inactive" + ) + + return request + + +class Token(Endpoint): + request_cls = Message + response_cls = AccessTokenResponse + error_cls = TokenErrorResponse + request_format = "json" + request_placement = "body" + response_format = "json" + response_placement = "body" + endpoint_name = "token_endpoint" + name = "token" + default_capabilities = {"token_endpoint_auth_signing_alg_values_supported": None} + helper_by_grant_type = { + "authorization_code": AccessTokenHelper, + "refresh_token": RefreshTokenHelper, + } + + def __init__(self, server_get, new_refresh_token=False, **kwargs): + Endpoint.__init__(self, server_get, **kwargs) + self.post_parse_request.append(self._post_parse_request) + if "client_authn_method" in kwargs: + self.endpoint_info["token_endpoint_auth_methods_supported"] = kwargs[ + "client_authn_method" + ] + self.allow_refresh = False + self.new_refresh_token = new_refresh_token + self.configure_grant_types(kwargs.get("grant_types_supported")) + + def configure_grant_types(self, grant_types_supported): + if grant_types_supported is None: + self.helper = {k: v(self) for k, v in self.helper_by_grant_type.items()} + return + + self.helper = {} + # TODO: do we want to allow any grant_type? + for grant_type, grant_type_options in grant_types_supported.items(): + _conf = grant_type_options.get("kwargs", {}) + if _conf is False: + continue + + try: + grant_class = grant_type_options["class"] + except (KeyError, TypeError): + raise ProcessError( + "Token Endpoint's grant types must be True, None or a dict with a" + " 'class' key." + ) + + if isinstance(grant_class, str): + try: + grant_class = importer(grant_class) + except (ValueError, AttributeError): + raise ProcessError( + f"Token Endpoint's grant type class {grant_class} can't" " be imported." + ) + + try: + self.helper[grant_type] = grant_class(self, _conf) + except Exception as e: + raise ProcessError(f"Failed to initialize class {grant_class}: {e}") + + def _post_parse_request( + self, request: Union[Message, dict], client_id: Optional[str] = "", **kwargs + ): + _helper = self.helper.get(request["grant_type"]) + if _helper: + return _helper.post_parse_request(request, client_id, **kwargs) + else: + return self.error_cls( + error="invalid_request", + error_description=f"Unsupported grant_type: {request['grant_type']}", + ) + + def process_request(self, request: Optional[Union[Message, dict]] = None, **kwargs): + """ + + :param request: + :param kwargs: + :return: Dictionary with response information + """ + if isinstance(request, self.error_cls): + return request + + if request is None: + return self.error_cls(error="invalid_request") + + try: + _helper = self.helper.get(request["grant_type"]) + if _helper: + response_args = _helper.process_request(request, **kwargs) + else: + return self.error_cls( + error="invalid_request", + error_description=f"Unsupported grant_type: {request['grant_type']}", + ) + except JWEException as err: + return self.error_cls(error="invalid_request", error_description="%s" % err) + + if isinstance(response_args, ResponseMessage): + return response_args + + _access_token = response_args["access_token"] + _context = self.server_get("endpoint_context") + _session_info = _context.session_manager.get_session_info_by_token( + _access_token, grant=True + ) + + _cookie = _context.new_cookie( + name=_context.cookie_handler.name["session"], + sub=_session_info["grant"].sub, + sid=_context.session_manager.session_key( + _session_info["user_id"], _session_info["user_id"], _session_info["grant"].id, + ), + ) + + _headers = [("Content-type", "application/json")] + resp = {"response_args": response_args, "http_headers": _headers} + if _cookie: + resp["cookie"] = [_cookie] + return resp 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..e69de29b 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..75b541d6 --- /dev/null +++ b/src/oidcop/oidc/add_on/pkce.py @@ -0,0 +1,145 @@ +import hashlib +import logging +from typing import Dict + +from cryptojwt.utils import b64e +from oidcmsg.oauth2 import ( + AuthorizationErrorResponse, + RefreshAccessTokenRequest, + TokenExchangeRequest, +) +from oidcmsg.oidc import TokenErrorResponse + +from oidcop.endpoint import Endpoint + +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, RefreshAccessTokenRequest, TokenExchangeRequest), + ): + 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: Dict[str, 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..670b42a7 --- /dev/null +++ b/src/oidcop/oidc/authorization.py @@ -0,0 +1,105 @@ +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 + +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 do_request_user(self, request_info, **kwargs): + if proposed_user(request_info): + kwargs["req_user"] = proposed_user(request_info) + else: + _login_hint = request_info.get("login_hint") + if _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..b4809b4f --- /dev/null +++ b/src/oidcop/oidc/discovery.py @@ -0,0 +1,42 @@ +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..0a3ff443 --- /dev/null +++ b/src/oidcop/oidc/provider_config.py @@ -0,0 +1,37 @@ +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..a5dbb305 --- /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..71aa7374 --- /dev/null +++ b/src/oidcop/oidc/registration.py @@ -0,0 +1,476 @@ +import hashlib +import hmac +import json +import logging +import secrets +import time +from typing import List +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.endpoint import Endpoint +from oidcop.exception import CapabilitiesMisMatch +from oidcop.exception import InvalidRedirectURIError +from oidcop.exception import InvalidSectorIdentifier +from oidcop.util import importer +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: str, urlset: List[list]) -> bool: + 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: str, sid: str): + msg = "{}{}{}".format(time.time(), secrets.token_urlsafe(16), 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("{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 + + +def random_client_id(length: int = 16, reserved: list = [], **kwargs): + # create new id och secret + client_id = rndstr(16) + # cdb client_id MUST be unique! + while client_id in reserved: + client_id = rndstr(16) + return client_id + + +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 __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Those that use seed wants bytes but I can only store str. + # seed + _seed = kwargs.get("seed") or rndstr(32) + self.seed = as_bytes(_seed) + + 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], issuer_id=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(self.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: + if self.kwargs.get("client_id_generator"): + cid_generator = importer(self.kwargs["client_id_generator"]["class"]) + cid_gen_kwargs = self.kwargs["client_id_generator"].get("kwargs", {}) + else: + cid_generator = importer("oidcop.oidc.registration.random_client_id") + cid_gen_kwargs = {} + client_id = cid_generator(reserved=_context.cdb.keys(), **cid_gen_kwargs) + 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 = _context.new_cookie( + name=_context.cookie_handler.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..b3db0e42 --- /dev/null +++ b/src/oidcop/oidc/session.py @@ -0,0 +1,399 @@ +import json +import logging +from typing import Optional +from typing import Union +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.exception import VerificationError +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.endpoint import Endpoint +from oidcop.endpoint_context import add_path +from oidcop.oauth2.authorization import verify_uri + +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 = '