├── example └── flask_rp │ ├── __init__.py │ ├── run.sh │ ├── templates │ ├── session_status.html │ ├── opresult.html │ ├── opbyuid.html │ ├── rp_iframe.html │ └── repost_fragment.html │ ├── wsgi.py │ ├── application.py │ ├── certs │ ├── cert.pem │ └── key.pem │ └── dpop_conf.json ├── unsupported └── chrp │ ├── utils.py │ ├── html │ ├── opresult.html │ ├── opbyuid.html │ └── repost_fragment.html │ ├── README.txt │ ├── static │ └── jwks.json │ ├── jwks_dir │ └── jwks.json │ ├── make_opbyuid_html.py │ ├── certs │ ├── cert.pem │ └── key.pem │ ├── conf.py │ └── rp.py ├── src └── oidcrp │ ├── oauth2 │ ├── client_credentials │ │ ├── __init__.py │ │ ├── cc_access_token.py │ │ └── cc_refresh_access_token.py │ ├── add_on │ │ ├── __init__.py │ │ ├── pushed_authorization.py │ │ ├── pkce.py │ │ └── dpop.py │ ├── refresh_access_token.py │ ├── access_token.py │ ├── utils.py │ ├── authorization.py │ └── provider_info_discovery.py │ ├── provider │ ├── __init__.py │ ├── github.py │ └── linkedin.py │ ├── __init__.py │ ├── oidc │ ├── refresh_access_token.py │ ├── check_id.py │ ├── check_session.py │ ├── read_registration.py │ ├── utils.py │ ├── end_session.py │ ├── access_token.py │ ├── userinfo.py │ └── webfinger.py │ ├── service_factory.py │ ├── logging.py │ ├── exception.py │ ├── defaults.py │ ├── entity.py │ ├── http.py │ └── configure.py ├── doc ├── source │ ├── _images │ │ └── oid-l-certification-mark-l-rgb-150dpi-90mm-300x157.png │ ├── oidcrp.oidc.rst │ ├── oidcrp.oauth2.rst │ ├── add_on │ │ ├── index.rst │ │ ├── pushed_authorization.rst │ │ ├── dpop.rst │ │ └── pkce.rst │ ├── index.rst │ ├── oidcrp.rst │ └── conf.py ├── Makefile └── make.bat ├── .github ├── workflows │ ├── release-drafter.yml │ ├── pypi.yml │ └── python-app.yml └── release-drafter.yml ├── tests ├── pub_client.jwks ├── pub_github.jwks ├── pub_facebook.jwks ├── pub_linkedin.jwks ├── request123456.jwt ├── test_01_base.py ├── salesforce.key ├── data │ └── keys │ │ └── rsa.key ├── priv_client.jwks ├── priv_facebook.jwks ├── priv_github.jwks ├── priv_linkedin.jwks ├── test_22_config.py ├── test_15_oic_utils.py ├── test_04_http.py ├── test_21_pushed_auth.py ├── rp_conf.yaml ├── test_07_service.py ├── test_17_read_registration.py ├── conf.yaml ├── test_31_oauth2_persistent.py ├── test_14_pkce.py ├── test_02_cookie.py ├── test_32_oidc_persistent.py ├── test_40_dpop.py └── test_21_rph_defaults.py ├── README.md ├── CONTRIBUTING.md ├── .gitignore └── setup.py /example/flask_rp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /unsupported/chrp/utils.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/oidcrp/oauth2/client_credentials/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/oidcrp/provider/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['linkedin', 'github'] -------------------------------------------------------------------------------- /example/flask_rp/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ./wsgi.py conf.json -------------------------------------------------------------------------------- /unsupported/chrp/html/opresult.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | pyoidc RP 7 | 8 | 9 | 10 |

OP result

11 | {result} 12 | 13 | -------------------------------------------------------------------------------- /doc/source/_images/oid-l-certification-mark-l-rgb-150dpi-90mm-300x157.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityPython/JWTConnect-Python-OidcRP/HEAD/doc/source/_images/oid-l-certification-mark-l-rgb-150dpi-90mm-300x157.png -------------------------------------------------------------------------------- /src/oidcrp/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | __author__ = 'Roland Hedberg' 4 | __version__ = '2.1.4' 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | SUCCESSFUL = [200, 201, 202, 203, 204, 205, 206] 9 | 10 | -------------------------------------------------------------------------------- /doc/source/oidcrp.oidc.rst: -------------------------------------------------------------------------------- 1 | oidcrp\.oidc package 2 | ==================== 3 | 4 | Module contents 5 | --------------- 6 | 7 | .. automodule:: oidcrp.oidc 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /doc/source/oidcrp.oauth2.rst: -------------------------------------------------------------------------------- 1 | oidcrp\.oauth2 package 2 | ====================== 3 | 4 | Module contents 5 | --------------- 6 | 7 | .. automodule:: oidcrp.oauth2 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /unsupported/chrp/README.txt: -------------------------------------------------------------------------------- 1 | Copy example_conf.py to conf.py 2 | 3 | Edit conf.py to match your setup 4 | 5 | Make a needed HTML page: 6 | ./make_opbyuid_html.py conf > html/opbyuid.html 7 | 8 | The run the service 9 | ./rp.py -t -k conf 10 | -------------------------------------------------------------------------------- /src/oidcrp/oauth2/add_on/__init__.py: -------------------------------------------------------------------------------- 1 | from oidcrp.util import importer 2 | 3 | 4 | def do_add_ons(add_ons, services): 5 | for key, spec in add_ons.items(): 6 | _func = importer(spec['function']) 7 | _func(services, **spec['kwargs']) 8 | -------------------------------------------------------------------------------- /doc/source/add_on/index.rst: -------------------------------------------------------------------------------- 1 | OIDCRP add on documentation 2 | =========================== 3 | 4 | .. toctree:: 5 | 6 | dpop.rst 7 | pkce.rst 8 | pushed_authorization.rst 9 | 10 | 11 | Indices and tables 12 | ================== 13 | 14 | * :ref:`genindex` 15 | * :ref:`modindex` 16 | * :ref:`search` 17 | -------------------------------------------------------------------------------- /example/flask_rp/templates/session_status.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | pyoidc RP 7 | 8 | 9 | 10 |

Session verification

11 |

Checking that the session hasn't changed!

12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release drafter 2 | 3 | on: 4 | push: 5 | branches: [master, develop] 6 | pull_request: 7 | types: [opened, reopened, synchronize] 8 | 9 | jobs: 10 | update_release_draft: 11 | name: Update draft release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - 15 | uses: release-drafter/release-drafter@v5 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. oicrp documentation master file, created by 2 | sphinx-quickstart on Fri Feb 23 13:32:06 2018. 3 | 4 | Welcome to oidcrp's documentation! 5 | ================================== 6 | 7 | .. image:: _images/oid-l-certification-mark-l-rgb-150dpi-90mm-300x157.png 8 | :width: 300 9 | :alt: OIDC Certified 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | rp_handler.rst 15 | oidcrp.rst 16 | add_on/index.rst 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | -------------------------------------------------------------------------------- /src/oidcrp/oidc/refresh_access_token.py: -------------------------------------------------------------------------------- 1 | from oidcmsg import oidc 2 | 3 | from oidcrp.oauth2 import refresh_access_token 4 | 5 | 6 | class RefreshAccessToken(refresh_access_token.RefreshAccessToken): 7 | msg_type = oidc.RefreshAccessTokenRequest 8 | response_cls = oidc.AccessTokenResponse 9 | error_msg = oidc.ResponseMessage 10 | 11 | def get_authn_method(self): 12 | try: 13 | return self.client_get("service_context").behaviour['token_endpoint_auth_method'] 14 | except KeyError: 15 | return self.default_authn_method 16 | -------------------------------------------------------------------------------- /doc/source/add_on/pushed_authorization.rst: -------------------------------------------------------------------------------- 1 | .. _par: 2 | 3 | ******************** 4 | Pushed Authorization 5 | ******************** 6 | 7 | ------------ 8 | Introduction 9 | ------------ 10 | 11 | https://tools.ietf.org/id/draft-lodderstedt-oauth-par-00.html 12 | 13 | The Internet draft defines the pushed authorization request endpoint, 14 | which allows clients to push the payload of an OAuth 2.0 authorization 15 | request to the authorization server via a direct request and provides 16 | them with a request URI that is used as reference to the data in a 17 | subsequent authorization request. -------------------------------------------------------------------------------- /unsupported/chrp/static/jwks.json: -------------------------------------------------------------------------------- 1 | {"keys": [{"kty": "RSA", "use": "sig", "kid": "pu3GxC6YuA9jLm9KSE0SK74AajwW71hfHqaQGGGwm1w", "n": "3uZcI6SlS2Ln13wiuF38TEhP8TtbYmmp9GA-iOtUnr77neP6VTz-8UpOqepeyUrlkxG3fpQYl6RbXW1C5_bgWj8nGiELgkoXb3njeT9JxTHdWPy7PcF-l0jyyKz19yUILF34pTXCgdKJec-QbYCuhX63XfcI5U_Qfp4xdUYv2QTuNHGCFJKkl-oa2KuXsn29TKaFCpbkQGI7ZfW_ayHlyU9zfSDGSWeBl6K6_bEaXjpmjsY-2Ce60ZtToX2HIkOWuhZfaJS3tgHEDyjcewh5EIs2-oKfj7_rFBC7lE8StsGWMIemHnZrQpnDvXmBMC3yXDSvg7keoMA250MrRzMhEw", "e": "AQAB"}, {"kty": "EC", "use": "sig", "kid": "9lpBk6zQAqNkjvmeYib08kbxyKKVm6tucKEsXh3Nzqs", "crv": "P-256", "x": "76eFip9XlvDxOTtLIyca0Y3o3L9uep6QpXgee89mMIQ", "y": "jVXFTgp7-z7CUf_ZoLCvp7kuMHc54qbEQdq__cTwxv0"}]} -------------------------------------------------------------------------------- /tests/pub_client.jwks: -------------------------------------------------------------------------------- 1 | {"keys": [{"kty": "RSA", "use": "sig", "kid": "SUswNi1MRFlDT0Y2YjU1Z1RfQlo2S3dEa3FTTkV3LThFcnhDTHF5elk2VQ", "n": "0UkUx2ewKyc-XJ1o0ToyGjws_JybAMZj2oYjsPyyvQ_T5dhZ2VmRRRkhsaVJ2xE_GGc7mSG0IjmGFyXp5y0w4mJBcsAEE5-8eBTvQdYIryjW74r3jt6Fi4Hlm1yFMTie3apv8mw79BUj-jT0kh3_m-FiKKUvLsq45DcLtTJ4cx7Ize37dl1sFSpQcoYMk7eiUEM8fiNboiVwvBYNAWVMkUM-LnVUPm3UjvKp0LihYEkZFWOxmuQmj2x25SFUkjus38ERrRqJQBZduxdBHFrWtWg8yOA53BkMU0FFg_r0H3ctl-5GaKw-BWlogU4qXnsq85xy0EoenRk7FPV8g_ulJw", "e": "AQAB"}, {"kty": "EC", "use": "sig", "kid": "NC1pdGRQN002bWM3bk1xX2R0SktscElqbFdtN29ITDV2WVd2b0hOYzREVQ", "crv": "P-256", "x": "kK7Qp1woSerI7rUOAwW_4sU6ZmwV3wwXKX3VU-v2fMI", "y": "iPWd_Pjq6EjxYy08KNFZ3PxhEwgWHgAQTTknlKMKJA0"}]} -------------------------------------------------------------------------------- /tests/pub_github.jwks: -------------------------------------------------------------------------------- 1 | {"keys": [{"kty": "RSA", "use": "sig", "kid": "SFhIS1NvYm1kd1RPWUZBUnVib3hUbWlaZ1lFam1VUVJUNEZYb2FYRGhRUQ", "e": "AQAB", "n": "o_LnYy0yuILAHR7prGHX11GcVaCTNGWkzo_gO1BYltX7GqlHv8r-3RRGIfXcJ3_e4nU2vbLJADBuKLUiX2Vg_hnn3PUmtQuHP9ch2Iad7mPlreXPS8Zci6L2Cxsg8GCRwhCPE5lFZV1yQ0YCpQqy5XBtJC9s46oU9yWSx3WDR0NpRWjXVxCotUMcd7m9ESbK2NQ8_svbb0PVARwPrEcOPjdt6MA3IiowUs9BtyuDTvPP3fmIYe5-wRmO6IsFKJN8WaV5i5jkcKh8mtl-ExSdj5TgruaaN9XIzR8ndpNlsqab5KtrqBXToVbmAjszNZepYTTk9OLTIMcBnLPCreB1TQ"}, {"kty": "EC", "use": "sig", "kid": "YVNPMEpGWGxDeEJtU1Q4SDlUWERsaGFxZHpFcXNCZWZPQVI5Y0x5Ym5wYw", "crv": "P-256", "x": "7tVYJkmoaQDh3mBN6f8grFtNJUgAVquMKR6MLyp-ioA", "y": "sscQsIjuHE_ynMjlX5HniXHVl2Z_EQn6c3Dxan7ZKU8"}]} -------------------------------------------------------------------------------- /tests/pub_facebook.jwks: -------------------------------------------------------------------------------- 1 | {"keys": [{"kty": "RSA", "use": "sig", "kid": "emRsd1RoQnR5NlVoemJuWkd1SmNCQ2RNcTZybWc4OW9jUmRDUnA5RHhodw", "e": "AQAB", "n": "wbH1b0Rx6NFR-gtOxMEPWcl7lxjxofTU5yB6S2s4InlZNT37zk2a9MzfCu7s4aboUKTDKs_QFVC9TwPSfHV8Q1dvAXQjjZJlg5BJNozLD3YrU5T6CUumWFy1H33_EHslL6nzp41SiD3K3os2shfk94L_5MYb1RAJDOAOE97PqprUGLTBYortXU7Gn0lPxncfKCI-GUEl6zcDAErYPHy_l6-tQNtwHRpF-kPTKFtO1qakvbiOwV1iiVB7XBIpTjwxFmDYJPpO3is0RbNBGjcy-tHdL_HsGRQqZngjIav_aMuBkpczeCEEAhEiZoPS0LP-jaXHUy6bgyhY6FjA2kz3xw"}, {"kty": "EC", "use": "sig", "kid": "M1piSDUtNG5lU0pQZ1lHQnhBLS1CQ25acXdpMFcxUHNLS0JRd2Vhd2RQTQ", "crv": "P-256", "x": "9gm6LHMn4nIm2FRR-CHoT3Bjc_QIke42cbfw_G8ceLw", "y": "uLhBobGjYJVjv4aYCIEvjfpfKkXFAvZ-rN_5fTADhCE"}]} -------------------------------------------------------------------------------- /tests/pub_linkedin.jwks: -------------------------------------------------------------------------------- 1 | {"keys": [{"kty": "RSA", "use": "sig", "kid": "QUt5QjBKVXNXODBNWF9HWEpxUjh5MHRhV0hMcXhKeVFyVVhydUM4cW1yVQ", "e": "AQAB", "n": "qYsgo-DivIQAqMfcQkHxZ4V9LddmeMGXc1qhxiTL_LNMC1_XLBrUjfIW3DUeLCOS74yJo7yzmOBbiQH1UfeJCMHWRdhRprKcOdJs9Ob4AOPYKmAYV91cQmGBtZV5glU3TTLVE4qJQ3XHY3lEsSnzhhblnOg4QY6K2uc73CyyNrDxVjEj9LsImWtvfbGX79zjkixLC--76CKxFuzh-bU75BoZvUQl26B0grp8Vh1Y2bJipDKq3h4Tbyc4nD_kPmKqkoHYvBRNnzI53bbbNWHlwxkm7TEC0s0OTzXMIs0-dZXsplFbNTPatSjXComek7hFCOqfzAWvR_ZUpFugE6XLgw"}, {"kty": "EC", "use": "sig", "kid": "R1l3RGgxQVIxRnZBRU5KMHNDWm51TTV4TmVXSU10amZWemlHcWlZOXFoOA", "crv": "P-256", "x": "aQN-zVbnug7MR68exm1DdWtnE4Rhw-sXfmGKBfr5Y_U", "y": "upRb0k07yDA43ElWxZ8uNFL-vTaPuWkUSpf0DiTKWEs"}]} -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | #SPHINXBUILD = sphinx-build 7 | SPHINXBUILD = /Library/Frameworks/Python.framework/Versions/3.7/bin/sphinx-build 8 | SPHINXPROJ = oidcrp 9 | SOURCEDIR = source 10 | BUILDDIR = build 11 | 12 | # Put it first so that "make" without argument is like "make help". 13 | help: 14 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 15 | 16 | .PHONY: help Makefile 17 | 18 | # Catch-all target: route all unknown targets to Sphinx using the new 19 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 20 | %: Makefile 21 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /tests/request123456.jwt: -------------------------------------------------------------------------------- 1 | eyJhbGciOiJSUzI1NiIsImtpZCI6IlNVc3dOaTFNUkZsRFQwWTJZalUxWjFSZlFsbzJTM2RFYTNGVFRrVjNMVGhGY25oRFRIRjVlbGsyVlEifQ.eyJyZXNwb25zZV90eXBlIjogImNvZGUiLCAic3RhdGUiOiAic3RhdGUiLCAicmVkaXJlY3RfdXJpIjogImh0dHBzOi8vZXhhbXBsZS5jb20vY2xpL2F1dGh6X2NiIiwgInNjb3BlIjogIm9wZW5pZCIsICJub25jZSI6ICJvWkpBNTRnZTVaUndNalkwOVVLVnpwYkx5MEdNUEwwaCIsICJjbGllbnRfaWQiOiAiY2xpZW50X2lkIiwgImlzcyI6ICJjbGllbnRfaWQiLCAiaWF0IjogMTYzMzU5NTc4OSwgImF1ZCI6IFsiaHR0cHM6Ly9leGFtcGxlLmNvbSJdfQ.KVMPK6leJ5pEXnJ0jXiXu21U176IU9iwkT4FkQV_33jGYTsgdqCqXw5XHR1ciixdcH2cWf0SzTPOgIzGsI4NJiPNdR9xOusYRyYKZciXHq85nrM7fr7dEPaVntWCU6uadH0MNHWCcq2FyBdz2YYDuiFPUXoxkFbfWZoo_jVMAWLxGQtGEitniI49qo0zbeSFck4hBmEtQTUOrGQvg_CjkSZb5oNb5rt_X5T-ZSK9y3AeKru4HLSQRkWj-oD-Fgd60Sm3XqfLQXrx26lk4a8ORah01BMmMsi5jeIUbOTthhhglZhMwoI9xCZ57I4SF7870-PrinIByW8d2keA1-LipQ -------------------------------------------------------------------------------- /doc/source/oidcrp.rst: -------------------------------------------------------------------------------- 1 | oidcrp package 2 | =================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | 9 | oidcrp.oauth2 10 | oidcrp.oidc 11 | 12 | Submodules 13 | ---------- 14 | 15 | oidcrp\.cookie module 16 | --------------------- 17 | 18 | .. automodule:: oidcrp.cookie 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | oidcrp\.http module 24 | ------------------- 25 | 26 | .. automodule:: oidcrp.http 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | oidcrp\.util module 32 | ------------------- 33 | 34 | .. automodule:: oidcrp.util 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | Module contents 40 | --------------- 41 | 42 | .. automodule:: oidcrp 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | -------------------------------------------------------------------------------- /tests/test_01_base.py: -------------------------------------------------------------------------------- 1 | from oidcrp.util import add_path 2 | from oidcrp.util import load_registration_response 3 | from oidcrp.oidc import RP 4 | 5 | 6 | 7 | def test_add_path(): 8 | assert add_path('https://example.com/', '/usr') == 'https://example.com/usr' 9 | assert add_path('https://example.com/', 'usr') == 'https://example.com/usr' 10 | assert add_path('https://example.com', '/usr') == 'https://example.com/usr' 11 | assert add_path('https://example.com', 'usr') == 'https://example.com/usr' 12 | 13 | 14 | def test_load_registration_response(): 15 | conf = { 16 | 'redirect_uris': ['https://example.com/cli/authz_cb'], 17 | 'client_id': 'client_1', 18 | 'client_secret': 'abcdefghijklmnop', 19 | 'registration_response': {'issuer': 'https://example.com'} 20 | } 21 | client = RP(config=conf) 22 | 23 | # test static 24 | load_registration_response(client) 25 | assert True -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=oicrp 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /tests/salesforce.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXAIBAAKBgQD6vqn19W/VB215DBADRakfPmCtFBf8/+YyhGqixWIwDiEl/L6L 3 | w5HKZCUPVgrC0ADhJfvAbn4fte5MWBCTkqgepKL3BySMA0LMaBF12pbHlPSUbmQG 4 | BJmTX4NNXuUel6TbPYJAU2Nh5Nan0Mb7Bmb8QpFvS0Hw7qZRW8y2eIttfwIDAQAB 5 | AoGBAJVf9FxkRKUB8cOE3h006JWGUY2KROghgn9hxy0ErYO3RyQcN1+HuFh75GAI 6 | gAyiYYO/XwS6TkSR2057wBRJ8ABzcL3+v5g+16Vbh0BjXVE+cv1WGdNGujyzl6ji 7 | jlyF4cb6tXDyqWTLkMAtV20NfO/CGsfii6YEkZb2P90usthRAkEA/oG7a9EvQ7eR 8 | gSEqppzW7KCwidPjnZTr/ROIZQU33nwkIJ0ElTjMNYKP8DerSuixR9skw2ZY8Q8I 9 | 1PTBnocHwwJBAPw3SAQYwxZwQMu1trVPMNOGIbSY4rQlMZGXrCZSu/TnozczFLA8 10 | qNM84g5veyJOzHKmYkIsMG1gwg5VNniG45UCQF6SlLOW0upl70K9sVyiUVcyywcc 11 | Xqty6FJtjLSFQOKC3OXlkwtkRLXpo1UPSq6WUzIxY7LceFZzUMPZg41F/gMCQHNr 12 | POqbBlPzZMOUUZthNP/nhu8lc8Fqr+dnmGElRVxK0JdHKfWInN2mI/DlNV064Dar 13 | S5XqsPKs78EtX7MCT40CQFQZiry8m7ROubOU4+HDG9o1w9zcKXCkmbD9hBCGvTAj 14 | BQNuGE0DtC6FEWTs8bXybLM5yBRq1XiKLdmi5N+3n4g= 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /tests/data/keys/rsa.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXAIBAAKBgQD6vqn19W/VB215DBADRakfPmCtFBf8/+YyhGqixWIwDiEl/L6L 3 | w5HKZCUPVgrC0ADhJfvAbn4fte5MWBCTkqgepKL3BySMA0LMaBF12pbHlPSUbmQG 4 | BJmTX4NNXuUel6TbPYJAU2Nh5Nan0Mb7Bmb8QpFvS0Hw7qZRW8y2eIttfwIDAQAB 5 | AoGBAJVf9FxkRKUB8cOE3h006JWGUY2KROghgn9hxy0ErYO3RyQcN1+HuFh75GAI 6 | gAyiYYO/XwS6TkSR2057wBRJ8ABzcL3+v5g+16Vbh0BjXVE+cv1WGdNGujyzl6ji 7 | jlyF4cb6tXDyqWTLkMAtV20NfO/CGsfii6YEkZb2P90usthRAkEA/oG7a9EvQ7eR 8 | gSEqppzW7KCwidPjnZTr/ROIZQU33nwkIJ0ElTjMNYKP8DerSuixR9skw2ZY8Q8I 9 | 1PTBnocHwwJBAPw3SAQYwxZwQMu1trVPMNOGIbSY4rQlMZGXrCZSu/TnozczFLA8 10 | qNM84g5veyJOzHKmYkIsMG1gwg5VNniG45UCQF6SlLOW0upl70K9sVyiUVcyywcc 11 | Xqty6FJtjLSFQOKC3OXlkwtkRLXpo1UPSq6WUzIxY7LceFZzUMPZg41F/gMCQHNr 12 | POqbBlPzZMOUUZthNP/nhu8lc8Fqr+dnmGElRVxK0JdHKfWInN2mI/DlNV064Dar 13 | S5XqsPKs78EtX7MCT40CQFQZiry8m7ROubOU4+HDG9o1w9zcKXCkmbD9hBCGvTAj 14 | BQNuGE0DtC6FEWTs8bXybLM5yBRq1XiKLdmi5N+3n4g= 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python distribution to PyPI 2 | on: 3 | release: 4 | types: 5 | - published 6 | 7 | jobs: 8 | build-n-publish: 9 | name: Publish Python distribution to PyPI 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@master 13 | - name: Setup Python 3.8 14 | uses: actions/setup-python@v1 15 | with: 16 | python-version: 3.8 17 | - name: Install pypa/build 18 | run: >- 19 | python -m 20 | pip install 21 | build 22 | --user 23 | - name: Build a binary wheel and a source tarball 24 | run: >- 25 | python -m 26 | build 27 | --sdist 28 | --wheel 29 | --outdir dist/ 30 | . 31 | - name: Publish distribution to PyPI 32 | uses: pypa/gh-action-pypi-publish@master 33 | with: 34 | user: __token__ 35 | password: ${{ secrets.PYPI_API_TOKEN }} 36 | -------------------------------------------------------------------------------- /src/oidcrp/provider/github.py: -------------------------------------------------------------------------------- 1 | from oidcmsg import oauth2 2 | from oidcmsg.message import Message 3 | from oidcmsg.message import SINGLE_OPTIONAL_STRING 4 | from oidcmsg.message import SINGLE_REQUIRED_STRING 5 | from oidcmsg.oauth2 import ResponseMessage 6 | from oidcrp.oauth2 import access_token 7 | from oidcrp.oidc import userinfo 8 | 9 | 10 | class AccessTokenResponse(Message): 11 | """ 12 | Access token response 13 | """ 14 | c_param = { 15 | "access_token": SINGLE_REQUIRED_STRING, 16 | "token_type": SINGLE_REQUIRED_STRING, 17 | "scope": SINGLE_OPTIONAL_STRING 18 | } 19 | 20 | 21 | class AccessToken(access_token.AccessToken): 22 | msg_type = oauth2.AccessTokenRequest 23 | response_cls = AccessTokenResponse 24 | error_msg = oauth2.TokenErrorResponse 25 | response_body_type = 'urlencoded' 26 | 27 | 28 | class UserInfo(userinfo.UserInfo): 29 | response_cls = Message 30 | error_msg = ResponseMessage 31 | default_authn_method = '' 32 | http_method = 'GET' 33 | -------------------------------------------------------------------------------- /unsupported/chrp/html/opbyuid.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | pyoidc RP 8 | 9 | 10 |

OP by UID

11 |

12 | You can perform a login to an OP's by using your unique identifier at the OP. 13 | A unique identifier is defined as your username@opserver, this may be equal to an e-mail address. 14 | A unique identifier is only equal to an e-mail address if the op server is published at the same 15 | server address as your e-mail provider. 16 |

17 |
18 |

Start sign in flow

19 |

By entering your unique identifier:

20 | 21 |

Or you can chose one of the preconfigured OpenID Connect Providers

22 | 26 | 27 |
28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # oidcrp 2 | 3 | ![CI build](https://github.com/IdentityPython/JWTConnect-Python-OidcRP/workflows/oidcrp/badge.svg) 4 | ![pypi](https://img.shields.io/pypi/v/oidcrp.svg) 5 | [![Downloads total](https://pepy.tech/badge/oidcrp)](https://pepy.tech/project/oidcrp) 6 | [![Downloads week](https://pepy.tech/badge/oidcrp/week)](https://pepy.tech/project/oidcrp) 7 | ![License](https://img.shields.io/badge/license-Apache%202-blue.svg) 8 | ![Documentation Status](https://readthedocs.org/projects/oidcrp/badge/?version=latest) 9 | ![Python version](https://img.shields.io/badge/python-3.7%20%7C%203.8%20%7C%203.9-blue.svg) 10 | 11 | 12 | High level interface to the OIDC RP library 13 | 14 | oidcrp represents the 3rd layer in the JWTConnect stack (cryptojwt, oidcmsg, oidcrp) 15 | 16 | See the documentation at http://oidcrp.readthedocs.io/en/latest/ . 17 | 18 | See sample RPs in [example/](https://github.com/IdentityPython/JWTConnect-Python-OidcRP/tree/master/example) folder. 19 | 20 | ![OIDC Certification mark](doc/source/_images/oid-l-certification-mark-l-rgb-150dpi-90mm-300x157.png) 21 | -------------------------------------------------------------------------------- /example/flask_rp/templates/opresult.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | pyoidc RP 7 | 8 | 9 | 10 |

OP result

11 |

You have successfully logged in!

12 |
13 |
Accesstoken
14 |
{{ access_token }}
15 |

Endpoints

16 | {% for end_point, url in endpoints.items() %} 17 |
{{ end_point }}
18 |
{{ url }}
19 | {% endfor %} 20 |
21 |
22 |
ID Token
23 |
{{ id_token }}
24 |
25 |

User information

26 |
27 | {% for key, value in userinfo.items() %} 28 |
{{ key }}
29 |
{{ value }}
30 | {% endfor %} 31 |
32 | 33 | {% if check_session_iframe is defined %} 34 | 35 | 36 | {% endif %} 37 | 38 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/oidcrp/oidc/check_id.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from oidcmsg.oauth2 import Message, ResponseMessage 4 | from oidcmsg.oidc import session 5 | 6 | from oidcrp.service import Service 7 | 8 | __author__ = 'Roland Hedberg' 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class CheckID(Service): 14 | msg_type = session.CheckIDRequest 15 | response_cls = Message 16 | error_msg = ResponseMessage 17 | endpoint_name = '' 18 | synchronous = True 19 | service_name = 'check_id' 20 | 21 | def __init__(self, client_get, client_authn_factory=None, conf=None): 22 | Service.__init__(self, client_get, 23 | client_authn_factory=client_authn_factory, 24 | conf=conf) 25 | self.pre_construct = [self.oidc_pre_construct] 26 | 27 | def oidc_pre_construct(self, request_args=None, **kwargs): 28 | request_args = self.client_get("service_context").state.multiple_extend_request_args( 29 | request_args, kwargs['state'], ['id_token'], 30 | ['auth_response', 'token_response', 'refresh_token_response']) 31 | return request_args, {} 32 | -------------------------------------------------------------------------------- /src/oidcrp/oauth2/client_credentials/cc_access_token.py: -------------------------------------------------------------------------------- 1 | from oidcmsg import oauth2 2 | from oidcmsg.oauth2 import ResponseMessage 3 | from oidcmsg.time_util import time_sans_frac 4 | 5 | from oidcrp.service import Service 6 | 7 | 8 | class CCAccessToken(Service): 9 | msg_type = oauth2.CCAccessTokenRequest 10 | response_cls = oauth2.AccessTokenResponse 11 | error_msg = ResponseMessage 12 | endpoint_name = 'token_endpoint' 13 | synchronous = True 14 | service_name = 'accesstoken' 15 | default_authn_method = 'client_secret_basic' 16 | http_method = 'POST' 17 | request_body_type = 'urlencoded' 18 | response_body_type = 'json' 19 | 20 | def __init__(self, client_get, client_authn_factory=None, conf=None): 21 | Service.__init__(self, client_get, 22 | client_authn_factory=client_authn_factory, conf=conf) 23 | 24 | def update_service_context(self, resp, key='cc', **kwargs): 25 | if 'expires_in' in resp: 26 | resp['__expires_at'] = time_sans_frac() + int(resp['expires_in']) 27 | self.client_get('service_context').state.store_item(resp, 'token_response', key) 28 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - 5 | title: 'Features' 6 | labels: 7 | - 'enhancement' 8 | - 'feat' 9 | - 'feature' 10 | - 11 | title: 'Bug Fixes' 12 | labels: 13 | - 'bug' 14 | - 'bugfix' 15 | - 'fix' 16 | - 17 | title: 'Maintenance' 18 | labels: 19 | - 'chore' 20 | - 'style' 21 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 22 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 23 | version-resolver: 24 | major: 25 | labels: ['major'] 26 | minor: 27 | labels: ['minor'] 28 | patch: 29 | labels: ['patch'] 30 | default: patch 31 | exclude-labels: ['skip'] 32 | autolabeler: 33 | - 34 | label: 'bug' 35 | branch: 36 | - '/bug\/.+/' 37 | - '/bugfix\/.+/' 38 | - '/fix\/.+/' 39 | - 40 | label: 'enhancement' 41 | branch: 42 | - '/dependabot\/.+/' 43 | - '/enhancement\/.+/' 44 | - '/feat\/.+/' 45 | - '/feature\/.+/' 46 | - 47 | label: 'chore' 48 | branch: 49 | - '/chore\/.+/' 50 | - '/style\/.+/' 51 | template: | 52 | ## Release notes 53 | 54 | $CHANGES 55 | -------------------------------------------------------------------------------- /src/oidcrp/oidc/check_session.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from oidcmsg.oauth2 import Message, ResponseMessage 4 | from oidcmsg.oidc import session 5 | 6 | from oidcrp.service import Service 7 | 8 | __author__ = 'Roland Hedberg' 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class CheckSession(Service): 14 | msg_type = session.CheckSessionRequest 15 | response_cls = Message 16 | error_msg = ResponseMessage 17 | endpoint_name = '' 18 | synchronous = True 19 | service_name = 'check_session' 20 | 21 | def __init__(self, client_get, client_authn_factory=None, conf=None): 22 | Service.__init__(self, client_get, 23 | client_authn_factory=client_authn_factory, 24 | conf=conf) 25 | self.pre_construct = [self.oidc_pre_construct] 26 | 27 | def oidc_pre_construct(self, request_args=None, **kwargs): 28 | request_args = self.client_get("service_context").state.multiple_extend_request_args( 29 | request_args, kwargs['state'], ['id_token'], 30 | ['auth_response', 'token_response', 'refresh_token_response']) 31 | return request_args, {} 32 | -------------------------------------------------------------------------------- /example/flask_rp/wsgi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | 6 | from oidcmsg.configure import create_from_config_file 7 | 8 | from oidcrp.configure import Configuration 9 | from oidcrp.configure import RPConfiguration 10 | from oidcrp.util import create_context 11 | 12 | try: 13 | from . import application 14 | except ImportError: 15 | import application 16 | 17 | dir_path = os.path.dirname(os.path.realpath(__file__)) 18 | 19 | if __name__ == "__main__": 20 | conf = sys.argv[1] 21 | name = 'oidc_rp' 22 | template_dir = os.path.join(dir_path, 'templates') 23 | 24 | _config = create_from_config_file(Configuration, 25 | entity_conf=[{"class": RPConfiguration, "attr": "rp"}], 26 | filename=conf) 27 | 28 | app = application.oidc_provider_init_app(_config.rp, name, template_folder=template_dir) 29 | _web_conf = _config.web_conf 30 | context = create_context(dir_path, _web_conf) 31 | 32 | debug = _web_conf.get('debug', True) 33 | app.run(host=_web_conf["domain"], port=_web_conf["port"], 34 | debug=_web_conf.get("debug", False), ssl_context=context) 35 | -------------------------------------------------------------------------------- /src/oidcrp/provider/linkedin.py: -------------------------------------------------------------------------------- 1 | from oidcmsg import oauth2 2 | from oidcmsg.message import Message 3 | from oidcmsg.message import SINGLE_OPTIONAL_JSON 4 | from oidcmsg.message import SINGLE_OPTIONAL_STRING 5 | from oidcmsg.message import SINGLE_REQUIRED_INT 6 | from oidcmsg.message import SINGLE_REQUIRED_STRING 7 | 8 | from oidcrp.oauth2 import access_token 9 | from oidcrp.oidc import userinfo 10 | 11 | 12 | class AccessTokenResponse(Message): 13 | """ 14 | Access token response 15 | """ 16 | c_param = { 17 | "access_token": SINGLE_REQUIRED_STRING, 18 | "expires_in": SINGLE_REQUIRED_INT 19 | } 20 | 21 | 22 | class UserSchema(Message): 23 | c_param = { 24 | "firstName": SINGLE_OPTIONAL_STRING, 25 | "headline": SINGLE_OPTIONAL_STRING, 26 | "id": SINGLE_REQUIRED_STRING, 27 | "lastName": SINGLE_OPTIONAL_STRING, 28 | "siteStandardProfileRequest": SINGLE_OPTIONAL_JSON 29 | } 30 | 31 | 32 | class AccessToken(access_token.AccessToken): 33 | msg_type = oauth2.AccessTokenRequest 34 | response_cls = AccessTokenResponse 35 | error_msg = oauth2.TokenErrorResponse 36 | 37 | 38 | class UserInfo(userinfo.UserInfo): 39 | response_cls = UserSchema 40 | -------------------------------------------------------------------------------- /src/oidcrp/service_factory.py: -------------------------------------------------------------------------------- 1 | from glob import glob 2 | import inspect 3 | from os.path import basename 4 | from os.path import dirname 5 | from os.path import join 6 | import sys 7 | 8 | from oidcrp.service import Service 9 | 10 | 11 | def service_factory(req_name, module_dirs, **kwargs): 12 | pwd = dirname(__file__) 13 | if pwd not in sys.path: 14 | sys.path.insert(0, pwd) 15 | 16 | for dir in module_dirs: 17 | for x in glob(join(pwd, dir, '*.py')): 18 | _mod = basename(x)[:-3] 19 | if not _mod.startswith('__'): 20 | if '/' in dir: 21 | dir = dir.replace('/', '.') 22 | _dir_mod = '{}.{}'.format(dir, basename(x)[:-3]) 23 | if _dir_mod not in sys.modules: 24 | __import__(_dir_mod, globals(), locals()) 25 | 26 | for name, obj in inspect.getmembers(sys.modules[_dir_mod]): 27 | if inspect.isclass(obj) and issubclass(obj, Service): 28 | try: 29 | if obj.__name__ == req_name: 30 | return obj(**kwargs) 31 | except AttributeError: 32 | pass 33 | -------------------------------------------------------------------------------- /example/flask_rp/templates/opbyuid.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | pyoidc RP 7 | 8 | 9 |

OP by UID

10 |

11 | You can perform a login to an OP's by using your unique identifier at the OP. 12 | A unique identifier is defined as your username@opserver, this may be equal to an e-mail address. 13 | A unique identifier is only equal to an e-mail address if the op server is published at the same 14 | server address as your e-mail provider. 15 |

16 |
17 |

Start sign in flow

18 |

By entering your unique identifier:

19 | 20 |

an issuer ID

21 | 22 |

Or you can chose one of the preconfigured OpenID Connect Providers

23 | 29 | 30 |
31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /example/flask_rp/templates/rp_iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /unsupported/chrp/jwks_dir/jwks.json: -------------------------------------------------------------------------------- 1 | {"keys": [{"kty": "RSA", "use": "sig", "kid": "pu3GxC6YuA9jLm9KSE0SK74AajwW71hfHqaQGGGwm1w", "n": "3uZcI6SlS2Ln13wiuF38TEhP8TtbYmmp9GA-iOtUnr77neP6VTz-8UpOqepeyUrlkxG3fpQYl6RbXW1C5_bgWj8nGiELgkoXb3njeT9JxTHdWPy7PcF-l0jyyKz19yUILF34pTXCgdKJec-QbYCuhX63XfcI5U_Qfp4xdUYv2QTuNHGCFJKkl-oa2KuXsn29TKaFCpbkQGI7ZfW_ayHlyU9zfSDGSWeBl6K6_bEaXjpmjsY-2Ce60ZtToX2HIkOWuhZfaJS3tgHEDyjcewh5EIs2-oKfj7_rFBC7lE8StsGWMIemHnZrQpnDvXmBMC3yXDSvg7keoMA250MrRzMhEw", "e": "AQAB", "d": "H8Nd3-pnb52xggB7hiBGgqxPUAXwWM7L3DoWzzYRwelfO7mwA5OElOfM2-O9DBwzKMj-h_gcpQdTybV3Mkz43YNgVBXfaPqb6lPJOY7uOT6I11R0bjFzk6Vei4AyMLzDNGdTtl85z3wsAQK2BxSuSfGruaUpTFwaTuDGFXsh-F-6UsMxNYQUlL_5LrnrMkfg157RW8QCJkwQRdiEG5q9aAgehc9DhuE7LEmwAQMmoCJk599t6QMMF6uJXaaS9lIv5vz-sFyp-P86PgwazZszbpX6w10-oP-YhvBOPfmmZqGMaqRrVsLlpJ49jJGSb10jSGISgpZYI8q3pC0uwiRKmQ", "p": "4wMaKP1ieyGbXc7MG--NA_beZJV_s81Si3KvjFIWkt3Yi9_7aFOV9J8KTxhjCjB7ipNQy6hezRMtZpnXiMol9v8lpLWsenUdPYTX-ZOml6-tDFfQM0FqVpheNSwxtSnEy1IPem3MZKO77uZZEvzFH8h-Yw6YcXQ7VfN2XyeXCQ8", "q": "-1zUbe6AOsODA8wHZbeB6-yAePtfHte2SAi-WOxI5At3mi1Wq_-AaPAE7_J8XzMQG279qm_gCLmWcJMYM8XedfMcNqVUH5pjG3Y2qEAPUIojkGESlZTp8kWsTC0ShwRsMXd1u0TRtQ5TnapVD6-t8A7WNYXoYT03gqiBFl-df70"}, {"kty": "EC", "use": "sig", "kid": "9lpBk6zQAqNkjvmeYib08kbxyKKVm6tucKEsXh3Nzqs", "crv": "P-256", "x": "76eFip9XlvDxOTtLIyca0Y3o3L9uep6QpXgee89mMIQ", "y": "jVXFTgp7-z7CUf_ZoLCvp7kuMHc54qbEQdq__cTwxv0", "d": "sk0JprznDQxQyh8lCZtzk-pL7t9FJ_ZLN6Awiti55MU"}]} -------------------------------------------------------------------------------- /tests/priv_client.jwks: -------------------------------------------------------------------------------- 1 | {"keys": [{"kty": "RSA", "use": "sig", "kid": "SUswNi1MRFlDT0Y2YjU1Z1RfQlo2S3dEa3FTTkV3LThFcnhDTHF5elk2VQ", "n": "0UkUx2ewKyc-XJ1o0ToyGjws_JybAMZj2oYjsPyyvQ_T5dhZ2VmRRRkhsaVJ2xE_GGc7mSG0IjmGFyXp5y0w4mJBcsAEE5-8eBTvQdYIryjW74r3jt6Fi4Hlm1yFMTie3apv8mw79BUj-jT0kh3_m-FiKKUvLsq45DcLtTJ4cx7Ize37dl1sFSpQcoYMk7eiUEM8fiNboiVwvBYNAWVMkUM-LnVUPm3UjvKp0LihYEkZFWOxmuQmj2x25SFUkjus38ERrRqJQBZduxdBHFrWtWg8yOA53BkMU0FFg_r0H3ctl-5GaKw-BWlogU4qXnsq85xy0EoenRk7FPV8g_ulJw", "e": "AQAB", "d": "LSHadkoZBqVW4Hgdo4kuRtQVS4CmOJuP6w8kYUWNchIYuydV8PZMyp7p0jp32-MzPRr-Ej7fbsYC8bj-YRkwwpE31fwspOK8rRSup_71LnjbKRtJ2aiJGhWwIznniz7Pm7PmolvO7gslEA8dcuk_Nyl6lpNQwoF7L9PjFXdF3GUx7kSdxfEm_DGhzFIakDdpMcKD1sBFRyS4TFCtAy6kq2aU4iGjHI-CcQ8eWDfokEgB9VP7si8X16VmIiWhYH4s2dhjyPIoh-_Ih0CzYdDHXqqusGGazMzokrsDKxaPYGo-aPw1730_5lohllAH3pAGfhs6ohUH9jR04NogC3dnQQ", "p": "-9rpFyRgyvZy21pGoWclnd7aXF6ZZRTeoaKnbFT_TPGhsbN6se7xIO0Br2q77KOj5EmuQLB1UxnuZl8bbm-U2-bD9Nnharj6o9gNBVGtftKQlYhbztcn4ZegAsFPfPoizJ8Qpey04ejBVy6SrGAHfxINT0vDK5t4x7-mCkpWcoc", "q": "1LrSGbothPcSpD7QPUAPpWXN2SBjZg1JIzyOESTAbKq2SKHE3kydKUnYNZ-18KKfsgJb0HrygvwbzVSF03nge9_XtvKrXxVCJ6MBDnrrfOJaNcA2i1OiCjBzx37Ni_68f8d-rTk7dcSfqf61zRqDAz2iwrHV3psrIIMZ_qZDwGE"}, {"kty": "EC", "use": "sig", "kid": "NC1pdGRQN002bWM3bk1xX2R0SktscElqbFdtN29ITDV2WVd2b0hOYzREVQ", "crv": "P-256", "x": "kK7Qp1woSerI7rUOAwW_4sU6ZmwV3wwXKX3VU-v2fMI", "y": "iPWd_Pjq6EjxYy08KNFZ3PxhEwgWHgAQTTknlKMKJA0", "d": "MH1yAgNusNvP4l_EfvjjhJAPOxOSAoNiMynfDx6TSUM"}]} -------------------------------------------------------------------------------- /tests/priv_facebook.jwks: -------------------------------------------------------------------------------- 1 | {"keys": [{"kty": "RSA", "use": "sig", "kid": "emRsd1RoQnR5NlVoemJuWkd1SmNCQ2RNcTZybWc4OW9jUmRDUnA5RHhodw", "n": "wbH1b0Rx6NFR-gtOxMEPWcl7lxjxofTU5yB6S2s4InlZNT37zk2a9MzfCu7s4aboUKTDKs_QFVC9TwPSfHV8Q1dvAXQjjZJlg5BJNozLD3YrU5T6CUumWFy1H33_EHslL6nzp41SiD3K3os2shfk94L_5MYb1RAJDOAOE97PqprUGLTBYortXU7Gn0lPxncfKCI-GUEl6zcDAErYPHy_l6-tQNtwHRpF-kPTKFtO1qakvbiOwV1iiVB7XBIpTjwxFmDYJPpO3is0RbNBGjcy-tHdL_HsGRQqZngjIav_aMuBkpczeCEEAhEiZoPS0LP-jaXHUy6bgyhY6FjA2kz3xw", "e": "AQAB", "d": "tPQ2YO7GpET5mun5eqMKXk13It_wzedXB142IkNWvA42IyF3H3Ms8sp7DVHNB7rQipaCpnpi4ab-VNUzTYbMwnTqhackl9xO7ixb2ZSLGDTDoWAqf4inLSHfLD6fjZweT3ss4DmNAy8HWgUg4hykkf9WZToXQmOqsNaZEEM2KyC8qlOywtrbDRlYmupLm3wxHb4hyhNVnY2AStHhN4-4LeLSc53opK3TpzRFPZYvrJWDwqrTmmO-EYUmFaKuWw74VdjJkll36hL12KvqLzsdeMrUf46D1hXxcvP_4UJBwaPULg_grL9nW5Zjn2l2PEh_i7Iw_KPoVaRsg7AT_343oQ", "p": "8sG35s_JXAlAqsaDyChAm9xQ29Q_aVzrLd3n649sf783zGiNfCjvJyQf5VEkc8449xTxWjLX74ZZNO0G2dekgUhiNruwcD4pI7nSiaN7QQDJD01Db9x_T5TLySlSE1r8jSiMZc4zdKw8QsYTD7dc5aoDlRix1iZSoIY_sxF18X8", "q": "zEMO9lwCOgQd341loHsEJKzZxfuQHfmMaHrCm43b9cOiGVTexd_Jgy6D4MmYH1nbauA8S5tgizczfItBhBKUj8xRRVGxlo8rt-fmOVP5bnPKYaYGQHnaqH1ww_NgAXZXRZ9ejwa-Gj1uUdNHmoI4GSoSWcVkeVMdT4R5NOwyDbk"}, {"kty": "EC", "use": "sig", "kid": "M1piSDUtNG5lU0pQZ1lHQnhBLS1CQ25acXdpMFcxUHNLS0JRd2Vhd2RQTQ", "crv": "P-256", "x": "9gm6LHMn4nIm2FRR-CHoT3Bjc_QIke42cbfw_G8ceLw", "y": "uLhBobGjYJVjv4aYCIEvjfpfKkXFAvZ-rN_5fTADhCE", "d": "M-C9OyZZiuvgc8H52HJluvuPPDyIzY7UoU-bTTrfDY4"}]} -------------------------------------------------------------------------------- /tests/priv_github.jwks: -------------------------------------------------------------------------------- 1 | {"keys": [{"kty": "RSA", "use": "sig", "kid": "SFhIS1NvYm1kd1RPWUZBUnVib3hUbWlaZ1lFam1VUVJUNEZYb2FYRGhRUQ", "n": "o_LnYy0yuILAHR7prGHX11GcVaCTNGWkzo_gO1BYltX7GqlHv8r-3RRGIfXcJ3_e4nU2vbLJADBuKLUiX2Vg_hnn3PUmtQuHP9ch2Iad7mPlreXPS8Zci6L2Cxsg8GCRwhCPE5lFZV1yQ0YCpQqy5XBtJC9s46oU9yWSx3WDR0NpRWjXVxCotUMcd7m9ESbK2NQ8_svbb0PVARwPrEcOPjdt6MA3IiowUs9BtyuDTvPP3fmIYe5-wRmO6IsFKJN8WaV5i5jkcKh8mtl-ExSdj5TgruaaN9XIzR8ndpNlsqab5KtrqBXToVbmAjszNZepYTTk9OLTIMcBnLPCreB1TQ", "e": "AQAB", "d": "RXXGHripgo6ywiO0sLoLbkg_Se_sLgjaWEGQqNCTl-Q0rMkHgvSIIjZSuFSn33xHe_5ZIOm8Sv45zblgRLZ4728eUvjyW0X1GVEWH9x40OU2DAUPXHVABFEYmP_3ZqAjYOsPEyiexCFhJatlt3Le9GI7e1c0dQg8NbGPjD33TptNRnZ3WYTP4oLPmmncqpGcz_ZrPbttuZg3EjOoHjpmirWuPmTjsx_1B24UXzcR5YbOPiKL0Vpp7sah5uPaY1lzn1JPzXbLstpnQxTz6FSxZlH1BYHkR5Dh3nzOcYUbB8hxmMYFeo0MQ6826U3TqdcqhFoRt12rsAZaj6lOPl1KaQ", "p": "zqkq8Leg6Rfw-KvWS_E8NB6RpCH9v6i3HxxP0BWOIRfNvb4YCJl1Ilu0FDYxNa5xCp7QDbNNjMhwlHXBuiKj5w3rH-60Aw_wRCKgKJdBfzJji98ECOb-Qwuv-8tHpCXpTgWnyV1FQPXnwu_0OfemXpXqyKzH9ThDx-SHz30mF-M", "q": "yxc83rZWWAtYR3wRQUA9IZCclz0JTimERwLiiggHv968N0r9bZ3S8a8phpfUb5_gnORIiEXxls_1S2KZMLLPhpxPf9c2tKCq_4g_Bc_UubEU1FQNoGeRRaCyhczJaxh_7HqvySh5lbSagIBNA1AsAFHzOK8m_ZJ9jx00ZXJN5Q8"}, {"kty": "EC", "use": "sig", "kid": "YVNPMEpGWGxDeEJtU1Q4SDlUWERsaGFxZHpFcXNCZWZPQVI5Y0x5Ym5wYw", "crv": "P-256", "x": "7tVYJkmoaQDh3mBN6f8grFtNJUgAVquMKR6MLyp-ioA", "y": "sscQsIjuHE_ynMjlX5HniXHVl2Z_EQn6c3Dxan7ZKU8", "d": "cUbUgewGKmlqm0EfQn_nwZ_knRpM7A9xSZpw6uY5AlU"}]} -------------------------------------------------------------------------------- /tests/priv_linkedin.jwks: -------------------------------------------------------------------------------- 1 | {"keys": [{"kty": "RSA", "use": "sig", "kid": "QUt5QjBKVXNXODBNWF9HWEpxUjh5MHRhV0hMcXhKeVFyVVhydUM4cW1yVQ", "n": "qYsgo-DivIQAqMfcQkHxZ4V9LddmeMGXc1qhxiTL_LNMC1_XLBrUjfIW3DUeLCOS74yJo7yzmOBbiQH1UfeJCMHWRdhRprKcOdJs9Ob4AOPYKmAYV91cQmGBtZV5glU3TTLVE4qJQ3XHY3lEsSnzhhblnOg4QY6K2uc73CyyNrDxVjEj9LsImWtvfbGX79zjkixLC--76CKxFuzh-bU75BoZvUQl26B0grp8Vh1Y2bJipDKq3h4Tbyc4nD_kPmKqkoHYvBRNnzI53bbbNWHlwxkm7TEC0s0OTzXMIs0-dZXsplFbNTPatSjXComek7hFCOqfzAWvR_ZUpFugE6XLgw", "e": "AQAB", "d": "MGO-KCfabvW5_nI0olCR5vsJlFjjLVoLsOfQPm5oLjK6ZJCxKCxsOCs9bRJYDz8EBAz3h90m4V4_oeYGL6PX38D7OCoae9qEjlhJsshtRkvBea69o-5RE70iuUH1UUajJwx4FkC3FqL6CqElAu-3SrEkTdlVnRhi90GCK0AyiTsWQn43fi5KPyApT2IDfa4YIgZT9pnMCaOdFxHf-tnf-25LDNzGKK5_aUpZ2E-zYuXPT6yMW7rMC30cSegA5TtlyHrT5Ja4t_O9bfwMkTXrHfwQ7A39lvZlJmeLPaQJmw5MQrpONRwUgJg6rTqFfsNbAomtu2VTLqyeU-v0IHiFwQ", "p": "2zrSeIf6rpS6kCUDtH2lXakUJEhfEvQhH-Ttrc6eb1SGiDDPes4ICDOrH6Yo4uiGwPqrHrZRSN2HOfwuNVDrlutseTJrEMTGaiRgTyjMu-gWBO1VpPQ9StwWqv1ZVkJhcfGQ3uKDa7LZIESrcHe1-Eh8XdAVCawUzaedm20LMuE", "q": "xfrmUET392dZV-aINk516EH-U2VBw5ucoqLFgI38xS4IAQU9Ghw3iY8g1mOzpahX9Uq2qyae7pZDMRKCSEAqriha01Eu-ZAOzwCb8S-8XrL9yGT1OYiAygyWw-ZbDK-kHPgUhQkROkRWUhdTSGkM7-R0_NctnGXrSRdKIvk7buM"}, {"kty": "EC", "use": "sig", "kid": "R1l3RGgxQVIxRnZBRU5KMHNDWm51TTV4TmVXSU10amZWemlHcWlZOXFoOA", "crv": "P-256", "x": "aQN-zVbnug7MR68exm1DdWtnE4Rhw-sXfmGKBfr5Y_U", "y": "upRb0k07yDA43ElWxZ8uNFL-vTaPuWkUSpf0DiTKWEs", "d": "pk3RPHK0zYHpoaD9WKQDUt3KQIcFhfDJlN8DBHV3HNo"}]} -------------------------------------------------------------------------------- /unsupported/chrp/make_opbyuid_html.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import importlib 3 | import sys 4 | 5 | pre = """ 6 | 7 | 8 | 9 | 10 | 11 | pyoidc RP 12 | 13 | 14 |

OP by UID

15 |

16 | You can perform a login to an OP's by using your unique identifier at the OP. 17 | A unique identifier is defined as your username@opserver, this may be equal to an e-mail address. 18 | A unique identifier is only equal to an e-mail address if the op server is published at the same 19 | server address as your e-mail provider. 20 |

21 |
22 |

Start sign in flow

23 |

By entering your unique identifier:

24 | 25 |

Or you can chose one of the preconfigured OpenID Connect Providers

26 | 29 | 30 |
31 | 32 | 33 | """ 34 | 35 | config = importlib.import_module(sys.argv[1]) 36 | 37 | option = [] 38 | for key in config.CLIENTS.keys(): 39 | if key == '': 40 | option.append('') 41 | else: 42 | option.append(''.format(key, key)) 43 | 44 | _html = pre.format(select='\n'.join(option)) 45 | print(_html) -------------------------------------------------------------------------------- /src/oidcrp/logging.py: -------------------------------------------------------------------------------- 1 | """Common logging functions""" 2 | 3 | import os 4 | import logging 5 | from logging.config import dictConfig 6 | from typing import Optional 7 | 8 | import yaml 9 | 10 | 11 | LOGGING_CONF = 'logging.yaml' 12 | 13 | LOGGING_DEFAULT = { 14 | 'version': 1, 15 | 'formatters': { 16 | 'default': { 17 | 'format': '%(asctime)s %(name)s %(levelname)s %(message)s' 18 | } 19 | }, 20 | 'handlers': { 21 | 'default': { 22 | 'class': 'logging.StreamHandler', 23 | 'formatter': 'default' 24 | } 25 | }, 26 | 'root': { 27 | 'handlers': ['default'], 28 | 'level': 'INFO' 29 | } 30 | } 31 | 32 | 33 | def configure_logging(debug: Optional[bool] = False, 34 | config: Optional[dict] = None, 35 | filename: Optional[str] = LOGGING_CONF) -> logging.Logger: 36 | """Configure logging""" 37 | 38 | if config is not None: 39 | config_dict = config 40 | config_source = 'dictionary' 41 | elif filename is not None and os.path.exists(filename): 42 | with open(filename, "rt") as file: 43 | config_dict = yaml.load(file) 44 | config_source = 'file' 45 | else: 46 | config_dict = LOGGING_DEFAULT 47 | config_source = 'default' 48 | 49 | if debug: 50 | config_dict['root']['level'] = 'DEBUG' 51 | 52 | dictConfig(config_dict) 53 | logging.debug("Configured logging using %s", config_source) 54 | return logging.getLogger() 55 | -------------------------------------------------------------------------------- /src/oidcrp/oidc/read_registration.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from oidcmsg import oidc 4 | from oidcmsg.message import Message 5 | from oidcmsg.oauth2 import ResponseMessage 6 | 7 | from oidcrp.service import Service 8 | 9 | LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | class RegistrationRead(Service): 13 | msg_type = Message 14 | response_cls = oidc.RegistrationResponse 15 | error_msg = ResponseMessage 16 | synchronous = True 17 | service_name = 'registration_read' 18 | http_method = 'GET' 19 | default_authn_method = 'client_secret_basic' 20 | 21 | def get_endpoint(self): 22 | try: 23 | return self.client_get("service_context").registration_response["registration_client_uri"] 24 | except KeyError: 25 | return '' 26 | 27 | def get_authn_header(self, request, authn_method, **kwargs): 28 | """ 29 | Construct an authorization specification to be sent in the 30 | HTTP header. 31 | 32 | :param request: The service request 33 | :param authn_method: Which authentication/authorization method to use 34 | :param kwargs: Extra keyword arguments 35 | :return: A set of keyword arguments to be sent in the HTTP header. 36 | """ 37 | headers = {} 38 | 39 | if authn_method == "client_secret_basic": 40 | LOGGER.debug("Client authn method: %s", authn_method) 41 | headers["Authorization"] = "Bearer {}".format( 42 | self.client_get("service_context").registration_response[ 43 | "registration_access_token"] 44 | ) 45 | 46 | return headers -------------------------------------------------------------------------------- /unsupported/chrp/html/repost_fragment.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pyoidc RP 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 |
14 | 15 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example/flask_rp/templates/repost_fragment.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pyoidc RP 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 |
14 | 15 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example/flask_rp/application.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from cryptojwt import KeyJar 5 | from cryptojwt.key_jar import init_key_jar 6 | from flask.app import Flask 7 | 8 | from oidcrp.rp_handler import RPHandler 9 | 10 | dir_path = os.path.dirname(os.path.realpath(__file__)) 11 | 12 | 13 | def init_oidc_rp_handler(app): 14 | _rp_conf = app.rp_config 15 | 16 | if _rp_conf.key_conf: 17 | _kj = init_key_jar(**_rp_conf.key_conf) 18 | _path = _rp_conf.key_conf['public_path'] 19 | # removes ./ and / from the begin of the string 20 | _path = re.sub('^(.)/', '', _path) 21 | else: 22 | _kj = KeyJar() 23 | _path = '' 24 | _kj.httpc_params = _rp_conf.httpc_params 25 | 26 | rph = RPHandler(_rp_conf.base_url, _rp_conf.clients, services=_rp_conf.services, 27 | hash_seed=_rp_conf.hash_seed, keyjar=_kj, jwks_path=_path, 28 | httpc_params=_rp_conf.httpc_params) 29 | 30 | return rph 31 | 32 | 33 | def oidc_provider_init_app(config, name=None, **kwargs): 34 | name = name or __name__ 35 | app = Flask(name, static_url_path='', **kwargs) 36 | 37 | app.rp_config = config 38 | 39 | # Session key for the application session 40 | app.config['SECRET_KEY'] = os.urandom(12).hex() 41 | 42 | app.users = {'test_user': {'name': 'Testing Name'}} 43 | 44 | try: 45 | from .views import oidc_rp_views 46 | except ImportError: 47 | from views import oidc_rp_views 48 | 49 | app.register_blueprint(oidc_rp_views) 50 | 51 | # Initialize the oidc_provider after views to be able to set correct urls 52 | app.rph = init_oidc_rp_handler(app) 53 | 54 | return app 55 | -------------------------------------------------------------------------------- /doc/source/add_on/dpop.rst: -------------------------------------------------------------------------------- 1 | .. _dpop: 2 | 3 | ************************************ 4 | Demonstration of Proof-of-possession 5 | ************************************ 6 | 7 | ------------ 8 | Introduction 9 | ------------ 10 | 11 | In the traditional mechanism, API access is allowed only if the access 12 | token presented by the client application is valid. However, if a 13 | mechanism of PoP (Proof of Possession) such as DPoP is employed, 14 | the API implementation additionally checks whether the client 15 | application presenting the access token is the valid owner of the 16 | access token (= whether the client application is the same one that 17 | the access token has been issued to). If the client is not the valid 18 | owner of the access token, the API access is rejected. 19 | 20 | The `DPOP Internet draft`_ describes a mechanism for sender-constraining 21 | OAuth 2.0 tokens via a proof-of-possession mechanism on the application 22 | level. This mechanism allows for the detection of replay attacks with 23 | access and refresh tokens. 24 | 25 | ------------- 26 | Configuration 27 | ------------- 28 | 29 | The only thing you can chose is the signing algorithms. 30 | There are no default algorithms. 31 | 32 | ------- 33 | Example 34 | ------- 35 | 36 | What you have to do is to add a *dpop* section to an *add_ons* section 37 | in a client configuration. 38 | 39 | .. code:: python 40 | 41 | 'add_ons': { 42 | "dpop": { 43 | "function": "oidcrp.oauth2.add_on.dpop.add_support", 44 | "kwargs": { 45 | "signing_algorithms": ["ES256", "ES512"] 46 | } 47 | } 48 | } 49 | 50 | 51 | .. _DPOP Internet draft: https://datatracker.ietf.org/doc/draft-ietf-oauth-dpop/ -------------------------------------------------------------------------------- /tests/test_22_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from oidcmsg.configure import create_from_config_file 4 | 5 | from oidcrp.configure import Configuration 6 | from oidcrp.configure import RPConfiguration 7 | 8 | _dirname = os.path.dirname(os.path.abspath(__file__)) 9 | 10 | 11 | def test_yaml_config(): 12 | c = create_from_config_file(Configuration, 13 | entity_conf=[{"class": RPConfiguration, "attr": "rp"}], 14 | filename=os.path.join(_dirname, 'conf.yaml'), 15 | base_path=_dirname) 16 | assert c 17 | assert set(c.web_conf.keys()) == {'port', 'domain', 'server_cert', 'server_key', 'debug'} 18 | 19 | rp_config = c.rp 20 | assert rp_config.base_url == "https://127.0.0.1:8090" 21 | assert rp_config.httpc_params == {"verify": False} 22 | assert set(rp_config.services.keys()) == {'discovery', 'registration', 'authorization', 23 | 'accesstoken', 'userinfo', 'end_session'} 24 | assert set(rp_config.clients.keys()) == {'', 'bobcat', 'flop'} 25 | 26 | 27 | def test_dict(): 28 | configuration = create_from_config_file(RPConfiguration, 29 | filename=os.path.join(_dirname, 'rp_conf.yaml'), 30 | base_path=_dirname) 31 | assert configuration 32 | 33 | assert configuration.base_url == "https://127.0.0.1:8090" 34 | assert configuration.httpc_params == {"verify": False} 35 | assert set(configuration.services.keys()) == {'discovery', 'registration', 'authorization', 36 | 'accesstoken', 'userinfo', 'end_session'} 37 | assert set(configuration.clients.keys()) == {'', 'bobcat', 'flop'} 38 | -------------------------------------------------------------------------------- /tests/test_15_oic_utils.py: -------------------------------------------------------------------------------- 1 | from cryptojwt.jwe.jwe import factory 2 | from cryptojwt.key_jar import build_keyjar 3 | from oidcmsg.oidc import AuthorizationRequest 4 | 5 | from oidcrp.oidc.utils import construct_request_uri 6 | from oidcrp.oidc.utils import request_object_encryption 7 | from oidcrp.service_context import ServiceContext 8 | 9 | KEYSPEC = [ 10 | {"type": "RSA", "use": ["enc"]}, 11 | {"type": "EC", "crv": "P-256", "use": ["enc"]}, 12 | ] 13 | 14 | RECEIVER = 'https://example.org/op' 15 | 16 | KEYJAR = build_keyjar(KEYSPEC, issuer_id=RECEIVER) 17 | 18 | 19 | def test_request_object_encryption(): 20 | msg = AuthorizationRequest(state='ABCDE', 21 | redirect_uri='https://example.com/cb', 22 | response_type='code') 23 | 24 | conf = { 25 | 'redirect_uris': ['https://example.com/cli/authz_cb'], 26 | 'client_id': 'client_1', 27 | 'client_secret': 'abcdefghijklmnop', 28 | } 29 | service_context = ServiceContext(keyjar=KEYJAR, config=conf) 30 | _behav = service_context.behaviour 31 | _behav["request_object_encryption_alg"] = 'RSA1_5' 32 | _behav["request_object_encryption_enc"] = "A128CBC-HS256" 33 | service_context.behaviour = _behav 34 | 35 | _jwe = request_object_encryption(msg.to_json(), service_context, target=RECEIVER) 36 | assert _jwe 37 | 38 | _decryptor = factory(_jwe) 39 | 40 | assert _decryptor.jwt.verify_headers(alg='RSA1_5', enc='A128CBC-HS256') 41 | 42 | 43 | def test_construct_request_uri(): 44 | local_dir = 'home' 45 | base_path = 'https://example.com/' 46 | a, b = construct_request_uri(local_dir, base_path) 47 | assert a.startswith('home') and a.endswith('.jwt') 48 | d, f = a.split('/') 49 | assert b == '{}{}'.format(base_path, f) 50 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: oidcrp 5 | 6 | on: 7 | push: 8 | branches: [ master, develop ] 9 | pull_request: 10 | branches: [ master, develop ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: 21 | - '3.7' 22 | - '3.8' 23 | - '3.9' 24 | - '3.10' 25 | 26 | steps: 27 | - uses: actions/checkout@v2 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v2 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | - name: Install rustc and cargo 33 | run: | 34 | sudo apt-get install rustc 35 | sudo apt install cargo 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install -U wheel --user 40 | pip install setuptools-rust 41 | python setup.py install 42 | python setup.py test 43 | pip install flake8 44 | pip install pytest 45 | pip install pytest-httpserver 46 | - name: Lint with flake8 47 | run: | 48 | # stop the build if there are Python syntax errors or undefined names 49 | flake8 ./src/oidcrp --count --select=E9,F63,F7,F82 --show-source --statistics 50 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 51 | flake8 ./src/oidcrp --count --exit-zero --statistics 52 | 53 | - name: Unit tests 54 | run: | 55 | py.test tests/ 56 | -------------------------------------------------------------------------------- /example/flask_rp/certs/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFUDCCAzigAwIBAgIJAJWgBcizyJrFMA0GCSqGSIb3DQEBCwUAMD0xCzAJBgNV 3 | BAYTAlNFMQ0wCwYDVQQKDARPSURGMR8wHQYDVQQDDBZGZWQgYXdhcmUgUlAgdGVz 4 | dCB0b29sMB4XDTE3MDIxMzE5MDg1MloXDTE4MDIxMzE5MDg1MlowPTELMAkGA1UE 5 | BhMCU0UxDTALBgNVBAoMBE9JREYxHzAdBgNVBAMMFkZlZCBhd2FyZSBSUCB0ZXN0 6 | IHRvb2wwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC3NrEL+VKs00NT 7 | R+ZpGRxvDoeLhD7EM+uf7IqHl6IN3H6pflAOE8YqnTepdglhGH4a7nyftINTZjDU 8 | 86anR+OKPoY2Padf4E+YceJOcaT6lB5XOWxBu4j3wDRHb6jMUwMDUXHsmh389Bvx 9 | X44KSYe/mhjkrIV8bolhT9NpNjPVUdUvpwpSxDOhSjq7BCmfdvXJrNNYElEQaDSc 10 | yJ4h6BAOp/FfdnWKAeiVDpIF5QqZgr0gzKiV5LEvwsNfHynsLgrlgK2+Fd8qIqbC 11 | /fHtB1BEL3h01dlBR1Y4ocMM5we23Phe4lwQs8QojPTnnr14fWynrjNi0Km0TcMT 12 | TDHVnw5qO5dSr4LpBcfIo82YWpj6lTEKQwKin+SPz0k0kD4E83rtsGp8n3FWHVAo 13 | BsIJ4O58REi3YTh1NCe/bjsQWiFOPW0N9GOl0UTOUj90cGVbO9i91aDFHHQWOIiA 14 | VsmZ35yOjQ031It9Kzv4YcmWXQcdKYnzUQ5eSXZPmJFoebKgQF6neFlg1hp6uDKi 15 | NRxkaPWGVCVZXPmgRwVcFdbxI8OpNqPEFQGskUPGJS5CF1o8o6wuVwPSSwxDVoYM 16 | 12TTdATH1he4cK69ej/1F2oHCVQ0KE46fNABaxNKxGls0bPPPJBPrQBjoAR2qxgg 17 | iFz2DjumVC3EySwXLsH4tXTjyuVbSQIDAQABo1MwUTAdBgNVHQ4EFgQUiD0bTabj 18 | Q0Pf0vVJneGr5TQRO+4wHwYDVR0jBBgwFoAUiD0bTabjQ0Pf0vVJneGr5TQRO+4w 19 | DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAnYCE5MdqVXHBxMGZ 20 | 1bIZxwLg9pe5poaX7l7XGdXxnBKWxfqwCx2UHQZIBdV3eIt8lgtuOL1en9ZCHAIY 21 | X0OZCafQ1Jzx3nXV4qOoolfmri0DQs60LPozoXKW61mah8fFhf/XdjuZxYH+XVV6 22 | 39E08MY4ZWDzzNoDe5zhGWw+IOfowx5wNTtZ8CipWUv4FiO9cUZJ/1hnJgE0CQNH 23 | v4v0g0lIuWs7eArbzvxTu3jHWx/+eYvl2TSYxEHpVulbesnI27M34nS0OePqbywO 24 | eGBtM65UuCCBh27FO+O7qJWA3sRPuw/cll0vi69WVYHO5rk7yji1hiTT2MKTEizP 25 | GmdT/FXG4nEsM6WaEe4FMJN6cZf49BUzRcEdW6k8i2YIysHf8fi3Xv1JF74OB5bF 26 | TogV/Fu/LzXsfA/XTj9ki0hUNmueyNT/xBD5tOH4FqHQvMWpjpzfwI90ENVeY+Ad 27 | BCU2Ck1HBEuUhUNaC1d6QkU6pn3voPvaWK49+T9NyrFVMNHVWHeLUHJ/i9kgWXLl 28 | TgAbTCmnJOHTxxCVCf40EjOpPR3hlCadYr8vOGyuHPk1M2Lppgh2kQtFX5ubhhfW 29 | IKP5TPKuZlu3z9RjfUvIxqWC6cbwjlOGIx2K0uCnIbpTzTuaLHJSWWRUpDzNL6lg 30 | V620B7/n1jo2JDudjhjD2uLekJg= 31 | -----END CERTIFICATE----- 32 | -------------------------------------------------------------------------------- /unsupported/chrp/certs/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFUDCCAzigAwIBAgIJAJWgBcizyJrFMA0GCSqGSIb3DQEBCwUAMD0xCzAJBgNV 3 | BAYTAlNFMQ0wCwYDVQQKDARPSURGMR8wHQYDVQQDDBZGZWQgYXdhcmUgUlAgdGVz 4 | dCB0b29sMB4XDTE3MDIxMzE5MDg1MloXDTE4MDIxMzE5MDg1MlowPTELMAkGA1UE 5 | BhMCU0UxDTALBgNVBAoMBE9JREYxHzAdBgNVBAMMFkZlZCBhd2FyZSBSUCB0ZXN0 6 | IHRvb2wwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC3NrEL+VKs00NT 7 | R+ZpGRxvDoeLhD7EM+uf7IqHl6IN3H6pflAOE8YqnTepdglhGH4a7nyftINTZjDU 8 | 86anR+OKPoY2Padf4E+YceJOcaT6lB5XOWxBu4j3wDRHb6jMUwMDUXHsmh389Bvx 9 | X44KSYe/mhjkrIV8bolhT9NpNjPVUdUvpwpSxDOhSjq7BCmfdvXJrNNYElEQaDSc 10 | yJ4h6BAOp/FfdnWKAeiVDpIF5QqZgr0gzKiV5LEvwsNfHynsLgrlgK2+Fd8qIqbC 11 | /fHtB1BEL3h01dlBR1Y4ocMM5we23Phe4lwQs8QojPTnnr14fWynrjNi0Km0TcMT 12 | TDHVnw5qO5dSr4LpBcfIo82YWpj6lTEKQwKin+SPz0k0kD4E83rtsGp8n3FWHVAo 13 | BsIJ4O58REi3YTh1NCe/bjsQWiFOPW0N9GOl0UTOUj90cGVbO9i91aDFHHQWOIiA 14 | VsmZ35yOjQ031It9Kzv4YcmWXQcdKYnzUQ5eSXZPmJFoebKgQF6neFlg1hp6uDKi 15 | NRxkaPWGVCVZXPmgRwVcFdbxI8OpNqPEFQGskUPGJS5CF1o8o6wuVwPSSwxDVoYM 16 | 12TTdATH1he4cK69ej/1F2oHCVQ0KE46fNABaxNKxGls0bPPPJBPrQBjoAR2qxgg 17 | iFz2DjumVC3EySwXLsH4tXTjyuVbSQIDAQABo1MwUTAdBgNVHQ4EFgQUiD0bTabj 18 | Q0Pf0vVJneGr5TQRO+4wHwYDVR0jBBgwFoAUiD0bTabjQ0Pf0vVJneGr5TQRO+4w 19 | DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAnYCE5MdqVXHBxMGZ 20 | 1bIZxwLg9pe5poaX7l7XGdXxnBKWxfqwCx2UHQZIBdV3eIt8lgtuOL1en9ZCHAIY 21 | X0OZCafQ1Jzx3nXV4qOoolfmri0DQs60LPozoXKW61mah8fFhf/XdjuZxYH+XVV6 22 | 39E08MY4ZWDzzNoDe5zhGWw+IOfowx5wNTtZ8CipWUv4FiO9cUZJ/1hnJgE0CQNH 23 | v4v0g0lIuWs7eArbzvxTu3jHWx/+eYvl2TSYxEHpVulbesnI27M34nS0OePqbywO 24 | eGBtM65UuCCBh27FO+O7qJWA3sRPuw/cll0vi69WVYHO5rk7yji1hiTT2MKTEizP 25 | GmdT/FXG4nEsM6WaEe4FMJN6cZf49BUzRcEdW6k8i2YIysHf8fi3Xv1JF74OB5bF 26 | TogV/Fu/LzXsfA/XTj9ki0hUNmueyNT/xBD5tOH4FqHQvMWpjpzfwI90ENVeY+Ad 27 | BCU2Ck1HBEuUhUNaC1d6QkU6pn3voPvaWK49+T9NyrFVMNHVWHeLUHJ/i9kgWXLl 28 | TgAbTCmnJOHTxxCVCf40EjOpPR3hlCadYr8vOGyuHPk1M2Lppgh2kQtFX5ubhhfW 29 | IKP5TPKuZlu3z9RjfUvIxqWC6cbwjlOGIx2K0uCnIbpTzTuaLHJSWWRUpDzNL6lg 30 | V620B7/n1jo2JDudjhjD2uLekJg= 31 | -----END CERTIFICATE----- 32 | -------------------------------------------------------------------------------- /doc/source/add_on/pkce.rst: -------------------------------------------------------------------------------- 1 | .. _pkce: 2 | 3 | *************************** 4 | Proof Key for Code Exchange 5 | *************************** 6 | 7 | ------------ 8 | Introduction 9 | ------------ 10 | 11 | OAuth 2.0 public clients utilizing the Authorization Code Grant are 12 | susceptible to the authorization code interception attack. `RFC7636`_ 13 | describes the attack as well as a technique to mitigate 14 | against the threat through the use of Proof Key for Code Exchange 15 | (PKCE, pronounced "pixy"). 16 | 17 | ------------- 18 | Configuration 19 | ------------- 20 | 21 | You can set *code_challenge_length* and *code_challenge_method*. 22 | Both have defaults: 23 | 24 | - code_challenge_length: 64 and 25 | - code_challenge_method: S256 26 | 27 | *S256* is mandatory to implement so there should be good reasons for 28 | not choosing it. To other defined method is *plain*. *plain* should only 29 | be used when you rely on the operating system and transport 30 | security not to disclose the request to an attacker. 31 | 32 | The security model relies on the fact that the code verifier is not 33 | learned or guessed by the attacker. It is vitally important to 34 | adhere to this principle. As such, the code verifier has to be 35 | created in such a manner that it is cryptographically random and has 36 | high entropy that it is not practical for the attacker to guess. 37 | 38 | The client SHOULD create a "code_verifier" with a minimum of 256 bits 39 | of entropy. This can be done by having a suitable random number 40 | generator create a 32-octet sequence. 41 | 42 | code_challenge_length is the length of that sequence. 43 | 44 | ------- 45 | Example 46 | ------- 47 | 48 | .. code:: python 49 | 50 | "add_ons": { 51 | "pkce": { 52 | "function": "oidcrp.oauth2.add_on.pkce.add_support", 53 | "kwargs": { 54 | "code_challenge_length": 64, 55 | "code_challenge_method": "S256" 56 | } 57 | } 58 | } 59 | 60 | .. _RFC7636: https://datatracker.ietf.org/doc/html/rfc7636 -------------------------------------------------------------------------------- /tests/test_04_http.py: -------------------------------------------------------------------------------- 1 | import os 2 | from http.cookies import SimpleCookie 3 | 4 | import pytest 5 | 6 | from oidcrp.cookie import CookieDealer 7 | from oidcrp.http import HTTPLib 8 | from oidcrp.util import set_cookie 9 | 10 | _dirname = os.path.dirname(os.path.abspath(__file__)) 11 | _keydir = os.path.join(_dirname, "data", "keys") 12 | 13 | # CLIENT_CERT = open(os.path.join(_keydir,'cert.key')).read() 14 | # CA_CERT = open(os.path.join(_keydir, 'cacert.pem')).read() 15 | 16 | 17 | @pytest.fixture 18 | def cookie_dealer(): 19 | class DummyServer(): 20 | def __init__(self): 21 | self.symkey = b"0123456789012345" 22 | 23 | return CookieDealer(DummyServer()) 24 | 25 | 26 | # def test_ca_cert(): 27 | # with pytest.raises(ValueError): 28 | # HTTPLib(CA_CERT, False, CLIENT_CERT) 29 | # 30 | # _h = HTTPLib(CA_CERT, True, CLIENT_CERT) 31 | # assert _h.request_args["verify"] == CA_CERT 32 | 33 | 34 | def test_cookie(cookie_dealer): 35 | cookie_value = "Something to pass along" 36 | cookie_typ = "sso" 37 | cookie_name = "Foobar" 38 | 39 | kaka = cookie_dealer.create_cookie(cookie_value, cookie_typ, 40 | cookie_name) 41 | _h = HTTPLib() 42 | set_cookie(_h.cookiejar, SimpleCookie(kaka[1])) 43 | 44 | res = _h._cookies() 45 | assert set(res.keys()) == {'Foobar'} 46 | 47 | kwargs = _h.add_cookies({}) 48 | assert 'cookies' in kwargs 49 | assert set(kwargs['cookies'].keys()) == {'Foobar'} 50 | 51 | 52 | class DummyResponse(object): 53 | def __init__(self, status_code, data, headers): 54 | self.status_code = status_code 55 | self.data = data 56 | self.headers = headers 57 | 58 | 59 | def test_set_cookie(cookie_dealer): 60 | cookie_value = "Something to pass along" 61 | cookie_typ = "sso" 62 | cookie_name = "Foobar" 63 | 64 | kaka = cookie_dealer.create_cookie(cookie_value, cookie_typ, 65 | cookie_name) 66 | 67 | _h = HTTPLib() 68 | response = DummyResponse(200, 'OK', {"set-cookie": kaka[1]}) 69 | _h.set_cookie(response) 70 | 71 | res = _h._cookies() 72 | assert set(res.keys()) == {'Foobar'} 73 | -------------------------------------------------------------------------------- /src/oidcrp/oauth2/refresh_access_token.py: -------------------------------------------------------------------------------- 1 | """The service that talks to the OAuth2 refresh access token endpoint.""" 2 | import logging 3 | 4 | from oidcmsg import oauth2 5 | from oidcmsg.oauth2 import ResponseMessage 6 | from oidcmsg.time_util import time_sans_frac 7 | 8 | from oidcrp.oauth2.utils import get_state_parameter 9 | from oidcrp.service import Service 10 | 11 | LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | class RefreshAccessToken(Service): 15 | """The service that talks to the OAuth2 refresh access token endpoint.""" 16 | msg_type = oauth2.RefreshAccessTokenRequest 17 | response_cls = oauth2.AccessTokenResponse 18 | error_msg = ResponseMessage 19 | endpoint_name = 'token_endpoint' 20 | synchronous = True 21 | service_name = 'refresh_token' 22 | default_authn_method = 'bearer_header' 23 | http_method = 'POST' 24 | 25 | def __init__(self, client_get, client_authn_factory=None, conf=None): 26 | Service.__init__(self, client_get, 27 | client_authn_factory=client_authn_factory, conf=conf) 28 | self.pre_construct.append(self.oauth_pre_construct) 29 | 30 | def update_service_context(self, resp, key='', **kwargs): 31 | if 'expires_in' in resp: 32 | resp['__expires_at'] = time_sans_frac() + int(resp['expires_in']) 33 | self.client_get("service_context").state.store_item(resp, 'token_response', key) 34 | 35 | def oauth_pre_construct(self, request_args=None, **kwargs): 36 | """Preconstructor of request arguments""" 37 | _state = get_state_parameter(request_args, kwargs) 38 | parameters = list(self.msg_type.c_param.keys()) 39 | 40 | _si = self.client_get("service_context").state 41 | _args = _si.extend_request_args({}, oauth2.AccessTokenResponse, 42 | 'token_response', _state, parameters) 43 | 44 | _args = _si.extend_request_args(_args, oauth2.AccessTokenResponse, 45 | 'refresh_token_response', _state, 46 | parameters) 47 | 48 | if request_args is None: 49 | request_args = _args 50 | else: 51 | _args.update(request_args) 52 | request_args = _args 53 | 54 | return request_args, {} 55 | -------------------------------------------------------------------------------- /src/oidcrp/oauth2/client_credentials/cc_refresh_access_token.py: -------------------------------------------------------------------------------- 1 | from oidcmsg import oauth2 2 | from oidcmsg.oauth2 import ResponseMessage 3 | from oidcmsg.time_util import time_sans_frac 4 | 5 | from oidcrp.service import Service 6 | 7 | 8 | class CCRefreshAccessToken(Service): 9 | msg_type = oauth2.RefreshAccessTokenRequest 10 | response_cls = oauth2.AccessTokenResponse 11 | error_msg = ResponseMessage 12 | endpoint_name = 'token_endpoint' 13 | synchronous = True 14 | service_name = 'refresh_token' 15 | default_authn_method = 'bearer_header' 16 | http_method = 'POST' 17 | 18 | def __init__(self, client_get, client_authn_factory=None, conf=None): 19 | Service.__init__(self, client_get, 20 | client_authn_factory=client_authn_factory, conf=conf) 21 | self.pre_construct.append(self.cc_pre_construct) 22 | self.post_construct.append(self.cc_post_construct) 23 | 24 | def cc_pre_construct(self, request_args=None, **kwargs): 25 | _state_id = kwargs.get("state", "cc") 26 | parameters = ['refresh_token'] 27 | _state_interface = self.client_get("service_context").state 28 | _args = _state_interface.extend_request_args({}, oauth2.AccessTokenResponse, 29 | 'token_response', _state_id, parameters) 30 | 31 | _args = _state_interface.extend_request_args(_args, oauth2.AccessTokenResponse, 32 | 'refresh_token_response', _state_id, 33 | parameters) 34 | 35 | if request_args is None: 36 | request_args = _args 37 | else: 38 | _args.update(request_args) 39 | request_args = _args 40 | 41 | return request_args, {} 42 | 43 | def cc_post_construct(self, request_args, **kwargs): 44 | for attr in ['client_id', 'client_secret']: 45 | try: 46 | del request_args[attr] 47 | except KeyError: 48 | pass 49 | 50 | return request_args 51 | 52 | def update_service_context(self, resp, key='cc', **kwargs): 53 | if 'expires_in' in resp: 54 | resp['__expires_at'] = time_sans_frac() + int(resp['expires_in']) 55 | self.client_get("service_context").state.store_item(resp, 'token_response', key) 56 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to JwtConnect-Python 2 | 3 | All contributions to the Python JwtConnect packages are welcome! 4 | 5 | Note that as this library is planned to be used in high-profile production code, 6 | we insist on a very high standards for the code and design, but don't feel shy: 7 | discuss your plans over 8 | [GitHub Issues](https://github.com/openid/JWTConnect-Python-OidcMsg/issues) and the 9 | [mailing list](http://lists.openid.net/mailman/listinfo/openid-specs-ab), and 10 | send in those pull requests! 11 | 12 | # Signing the Agreements 13 | 14 | In order to contribute to this project, you need to execute two legal agreements 15 | that cover your contributions. Pull requests from users who have not signed 16 | these agreements will not be merged. 17 | 18 | ## Execute the Contributor License Agreement (CLA) 19 | 20 | 1. Visit http://openid.net/contribution-license-agreement/ 21 | 2. Tap *Execute OpenID Foundation Contribution License Agreement* for the 22 | version relevant to you (Individual or Corporate). 23 | 3. Follow the instructions to sign the agreement. 24 | 25 | ## Execute the Working Group Contribution Agreement 26 | 27 | In addition to the Code License Agreement, the OpenID Foundation also requires 28 | a working group contribution agreement to cover any contributions you may make 29 | towards the OpenID Connect spec itself (e.g. in comments, bug reports, feature 30 | requests). 31 | 32 | 1. Visit http://openid.net/intellectual-property/ 33 | 2. Tap *Execute Contributor Agreement By Electronic Signature* in the box 34 | marked *Resources*. 35 | 3. Follow the instructions to sign the document, state `OpenID AB/Connect` as 36 | the Initial Working Group. 37 | 38 | # Making a Pull Request 39 | 40 | ## Before you Start 41 | 42 | Before you work on a big new feature, get in touch to make sure that your work 43 | is inline with the direction of the project and get input on your architecture. 44 | You can file an [Issue](https://github.com/openid/JWTConnect-Python-OidcMsg/issues) 45 | discussing your proposal, or email the 46 | [list](http://lists.openid.net/mailman/listinfo/openid-specs-ab). 47 | 48 | ## Coding Standards 49 | 50 | The JWTConnect-Python packages follows the 51 | [PEP8](https://www.python.org/dev/peps/pep-0008/) 52 | coding style for Python implementations. Please review your own code 53 | for adherence to the standard. 54 | 55 | ## Pull Request Reviews 56 | 57 | All pull requests, even by members who have repository write access need to be 58 | reviewed and marked as "LGTM" before they will be merged. 59 | -------------------------------------------------------------------------------- /src/oidcrp/exception.py: -------------------------------------------------------------------------------- 1 | 2 | __author__ = 'roland' 3 | 4 | 5 | # The base exception class for oidc service specific exceptions 6 | class OidcServiceError(Exception): 7 | def __init__(self, errmsg, content_type="", *args): 8 | Exception.__init__(self, errmsg, *args) 9 | self.content_type = content_type 10 | 11 | 12 | class MissingRequiredAttribute(OidcServiceError): 13 | pass 14 | 15 | 16 | class VerificationError(OidcServiceError): 17 | pass 18 | 19 | 20 | class ResponseError(OidcServiceError): 21 | pass 22 | 23 | 24 | class TimeFormatError(OidcServiceError): 25 | pass 26 | 27 | 28 | class CapabilitiesMisMatch(OidcServiceError): 29 | pass 30 | 31 | 32 | class MissingEndpoint(OidcServiceError): 33 | pass 34 | 35 | 36 | class TokenError(OidcServiceError): 37 | pass 38 | 39 | 40 | class GrantError(OidcServiceError): 41 | pass 42 | 43 | 44 | class ParseError(OidcServiceError): 45 | pass 46 | 47 | 48 | class OtherError(OidcServiceError): 49 | pass 50 | 51 | 52 | class NoClientInfoReceivedError(OidcServiceError): 53 | pass 54 | 55 | 56 | class InvalidRequest(OidcServiceError): 57 | pass 58 | 59 | 60 | class NonFatalException(OidcServiceError): 61 | """ 62 | :param resp: A response that the function/method would return on non-error 63 | :param msg: A message describing what error has occurred. 64 | """ 65 | 66 | def __init__(self, resp, msg): 67 | self.resp = resp 68 | self.msg = msg 69 | 70 | 71 | class Unsupported(OidcServiceError): 72 | pass 73 | 74 | 75 | class UnsupportedResponseType(Unsupported): 76 | pass 77 | 78 | 79 | class AccessDenied(OidcServiceError): 80 | pass 81 | 82 | 83 | class ImproperlyConfigured(OidcServiceError): 84 | pass 85 | 86 | 87 | class UnsupportedMethod(OidcServiceError): 88 | pass 89 | 90 | 91 | class AuthzError(OidcServiceError): 92 | pass 93 | 94 | 95 | class AuthnToOld(OidcServiceError): 96 | pass 97 | 98 | 99 | class ParameterError(OidcServiceError): 100 | pass 101 | 102 | 103 | class SubMismatch(OidcServiceError): 104 | pass 105 | 106 | 107 | class ConfigurationError(OidcServiceError): 108 | pass 109 | 110 | 111 | class WrongContentType(OidcServiceError): 112 | pass 113 | 114 | 115 | class WebFingerError(OidcServiceError): 116 | pass 117 | 118 | 119 | class HandlerError(Exception): 120 | pass 121 | 122 | 123 | class HttpError(OidcServiceError): 124 | pass 125 | -------------------------------------------------------------------------------- /src/oidcrp/oauth2/access_token.py: -------------------------------------------------------------------------------- 1 | """Implements the service that talks to the Access Token endpoint.""" 2 | import logging 3 | 4 | from oidcmsg import oauth2 5 | from oidcmsg.oauth2 import ResponseMessage 6 | from oidcmsg.time_util import time_sans_frac 7 | 8 | from oidcrp.oauth2.utils import get_state_parameter 9 | from oidcrp.service import Service 10 | 11 | LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | class AccessToken(Service): 15 | """The access token service.""" 16 | msg_type = oauth2.AccessTokenRequest 17 | response_cls = oauth2.AccessTokenResponse 18 | error_msg = ResponseMessage 19 | endpoint_name = 'token_endpoint' 20 | synchronous = True 21 | service_name = 'accesstoken' 22 | default_authn_method = 'client_secret_basic' 23 | http_method = 'POST' 24 | request_body_type = 'urlencoded' 25 | response_body_type = 'json' 26 | 27 | def __init__(self, client_get, client_authn_factory=None, conf=None): 28 | Service.__init__(self, client_get, 29 | client_authn_factory=client_authn_factory, conf=conf) 30 | self.pre_construct.append(self.oauth_pre_construct) 31 | 32 | def update_service_context(self, resp, key='', **kwargs): 33 | if 'expires_in' in resp: 34 | resp['__expires_at'] = time_sans_frac() + int(resp['expires_in']) 35 | self.client_get("service_context").state.store_item(resp, 'token_response', key) 36 | 37 | def oauth_pre_construct(self, request_args=None, post_args=None, **kwargs): 38 | """ 39 | 40 | :param request_args: Initial set of request arguments 41 | :param kwargs: Extra keyword arguments 42 | :return: Request arguments 43 | """ 44 | _state = get_state_parameter(request_args, kwargs) 45 | parameters = list(self.msg_type.c_param.keys()) 46 | 47 | _context = self.client_get("service_context") 48 | _args = _context.state.extend_request_args({}, oauth2.AuthorizationRequest, 49 | 'auth_request', _state, parameters) 50 | 51 | _args = _context.state.extend_request_args(_args, oauth2.AuthorizationResponse, 52 | 'auth_response', _state, parameters) 53 | 54 | if "grant_type" not in _args: 55 | _args["grant_type"] = "authorization_code" 56 | 57 | if request_args is None: 58 | request_args = _args 59 | else: 60 | _args.update(request_args) 61 | request_args = _args 62 | 63 | return request_args, post_args 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | .pytest_cache/ 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # IPython Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # dotenv 81 | .env 82 | 83 | # virtualenv 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | /.project 93 | /.pydevproject 94 | .isort.cfg 95 | .isort.cfg~ 96 | draft/eval.py 97 | draft/idfed.xml 98 | draft/modifications 99 | draft/out 100 | draft/parse_ms.py 101 | draft/pyoidc 102 | draft/pyoidc.pub 103 | draft/seq.out 104 | draft/sequence.py 105 | draft/sequence_0.1.py 106 | fed_op/client_db.db 107 | fed_op/cp_error 108 | fed_op/jwks/ 109 | fed_op/keys/ 110 | fed_op/modules/ 111 | fed_op/static/jwks.json 112 | fed_operator/certs/ 113 | fed_operator/jwks/ 114 | fed_operator/keys/ 115 | fed_operator/statement.json 116 | fed_rp/certs/ 117 | fed_rp/keys/ 118 | fed_rp/static/ 119 | tests/data/ 120 | tests/test_weed.py 121 | .idea/ 122 | doc/_build/ 123 | doc/_static/ 124 | doc/conf.py~ 125 | fed_conf/ 126 | fo_jwks/ 127 | mds/ 128 | ms_dir/ 129 | scripts/iss.jwks 130 | scripts/request.json 131 | test_dir/ 132 | tests/mds_10/ 133 | tests/mds_6/ 134 | tests/mds_dir_6/ 135 | tests/ms_dir_10/ 136 | tests/ms_dir_6/ 137 | tests/ms_path/ 138 | tests/ms_path_6/ 139 | tests/pyoidc 140 | tests/pyoidc.pub 141 | tests/xtest_usage.py 142 | -------------------------------------------------------------------------------- /src/oidcrp/defaults.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import string 3 | 4 | SERVICE_NAME = "OIC" 5 | CLIENT_CONFIG = {} 6 | 7 | DEFAULT_OIDC_SERVICES = { 8 | 'web_finger': {'class': 'oidcrp.oidc.webfinger.WebFinger'}, 9 | 'discovery': {'class': 'oidcrp.oidc.provider_info_discovery.ProviderInfoDiscovery'}, 10 | 'registration': {'class': 'oidcrp.oidc.registration.Registration'}, 11 | 'authorization': {'class': 'oidcrp.oidc.authorization.Authorization'}, 12 | 'access_token': {'class': 'oidcrp.oidc.access_token.AccessToken'}, 13 | 'refresh_access_token': {'class': 'oidcrp.oidc.refresh_access_token.RefreshAccessToken'}, 14 | 'userinfo': {'class': 'oidcrp.oidc.userinfo.UserInfo'} 15 | } 16 | 17 | DEFAULT_CLIENT_PREFS = { 18 | 'application_type': 'web', 19 | 'application_name': 'rphandler', 20 | 'response_types': ['code', 'id_token', 'id_token token', 'code id_token', 'code id_token token', 21 | 'code token'], 22 | 'scope': ['openid'], 23 | 'token_endpoint_auth_method': 'client_secret_basic' 24 | } 25 | 26 | # Using PKCE is default 27 | DEFAULT_CLIENT_CONFIGS = { 28 | "": { 29 | "client_preferences": DEFAULT_CLIENT_PREFS, 30 | "add_ons": { 31 | "pkce": { 32 | "function": "oidcrp.oauth2.add_on.pkce.add_support", 33 | "kwargs": { 34 | "code_challenge_length": 64, 35 | "code_challenge_method": "S256" 36 | } 37 | } 38 | } 39 | } 40 | } 41 | 42 | DEFAULT_KEY_DEFS = [ 43 | {"type": "RSA", "use": ["sig"]}, 44 | {"type": "EC", "crv": "P-256", "use": ["sig"]}, 45 | ] 46 | 47 | DEFAULT_RP_KEY_DEFS = { 48 | 'private_path': 'private/jwks.json', 49 | 'key_defs': DEFAULT_KEY_DEFS, 50 | 'public_path': 'static/jwks.json', 51 | 'read_only': False 52 | } 53 | 54 | OIDCONF_PATTERN = "{}/.well-known/openid-configuration" 55 | CC_METHOD = { 56 | 'S256': hashlib.sha256, 57 | 'S384': hashlib.sha384, 58 | 'S512': hashlib.sha512, 59 | } 60 | 61 | # Map the signing context to a signing algorithm 62 | DEF_SIGN_ALG = {"id_token": "RS256", 63 | "userinfo": "RS256", 64 | "request_object": "RS256", 65 | "client_secret_jwt": "HS256", 66 | "private_key_jwt": "RS256"} 67 | 68 | HTTP_ARGS = ["headers", "redirections", "connection_type"] 69 | 70 | JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 71 | SAML2_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:saml2-bearer" 72 | 73 | BASECHR = string.ascii_letters + string.digits 74 | -------------------------------------------------------------------------------- /tests/test_21_pushed_auth.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from cryptojwt.key_jar import init_key_jar 5 | import pytest 6 | import responses 7 | 8 | from oidcrp.oauth2 import Client 9 | from oidcrp.oauth2 import DEFAULT_OAUTH2_SERVICES 10 | 11 | _dirname = os.path.dirname(os.path.abspath(__file__)) 12 | 13 | ISS = 'https://example.com' 14 | 15 | KEYSPEC = [ 16 | {"type": "RSA", "use": ["sig"]}, 17 | {"type": "EC", "crv": "P-256", "use": ["sig"]}, 18 | ] 19 | 20 | CLI_KEY = init_key_jar(public_path='{}/pub_client.jwks'.format(_dirname), 21 | private_path='{}/priv_client.jwks'.format(_dirname), 22 | key_defs=KEYSPEC, issuer_id='') 23 | 24 | 25 | class TestPushedAuth: 26 | @pytest.fixture(autouse=True) 27 | def create_client(self): 28 | config = { 29 | 'client_id': 'client_id', 'client_secret': 'a longesh password', 30 | 'redirect_uris': ['https://example.com/cli/authz_cb'], 31 | 'behaviour': {'response_types': ['code']}, 32 | 'add_ons': { 33 | "pushed_authorization": { 34 | "function": 35 | "oidcrp.oauth2.add_on.pushed_authorization.add_support", 36 | "kwargs": { 37 | "body_format": "jws", 38 | "signing_algorithm": "RS256", 39 | "http_client": None, 40 | "merge_rule": "lax" 41 | } 42 | } 43 | } 44 | } 45 | self.entity = Client(keyjar=CLI_KEY, config=config, services=DEFAULT_OAUTH2_SERVICES) 46 | 47 | self.entity.client_get("service_context").provider_info = { 48 | "pushed_authorization_request_endpoint": "https://as.example.com/push" 49 | } 50 | 51 | def test_authorization(self): 52 | auth_service = self.entity.client_get("service","authorization") 53 | req_args = {'foo': 'bar', "response_type": "code"} 54 | with responses.RequestsMock() as rsps: 55 | _resp = { 56 | "request_uri": "urn:example:bwc4JK-ESC0w8acc191e-Y1LTC2", 57 | "expires_in": 3600 58 | } 59 | rsps.add("GET", 60 | auth_service.client_get("service_context").provider_info[ 61 | "pushed_authorization_request_endpoint"], 62 | body=json.dumps(_resp), status=200) 63 | 64 | _req = auth_service.construct(request_args=req_args, state='state') 65 | 66 | assert set(_req.keys()) == {"request_uri", "response_type", "client_id"} 67 | -------------------------------------------------------------------------------- /src/oidcrp/oauth2/add_on/pushed_authorization.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from cryptojwt import JWT 4 | from oidcmsg.message import Message 5 | from oidcmsg.oauth2 import JWTSecuredAuthorizationRequest 6 | import requests 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def push_authorization(request_args, service, **kwargs): 12 | """ 13 | :param request_args: All the request arguments as a AuthorizationRequest instance 14 | :param service: The service to which this post construct method is applied. 15 | :param kwargs: Extra keyword arguments. 16 | """ 17 | 18 | _context = service.client_get("service_context") 19 | method_args = _context.add_on["pushed_authorization"] 20 | 21 | # construct the message body 22 | if method_args["body_format"] == "urlencoded": 23 | _body = request_args.to_urlencoded() 24 | else: 25 | _jwt = JWT(key_jar=_context.keyjar, iss=_context.base_url) 26 | _jws = _jwt.pack(request_args.to_dict()) 27 | 28 | _msg = Message(request=_jws) 29 | if method_args["merge_rule"] == "lax": 30 | for param in request_args.required_parameters(): 31 | _msg[param] = request_args.get(param) 32 | 33 | _body = _msg.to_urlencoded() 34 | 35 | # Send it to the Pushed Authorization Request Endpoint 36 | resp = method_args["http_client"].get( 37 | _context.provider_info["pushed_authorization_request_endpoint"], data=_body 38 | ) 39 | 40 | if resp.status_code == 200: 41 | _resp = Message().from_json(resp.text) 42 | _req = JWTSecuredAuthorizationRequest(request_uri=_resp["request_uri"]) 43 | if method_args["merge_rule"] == "lax": 44 | for param in request_args.required_parameters(): 45 | _req[param] = request_args.get(param) 46 | request_args = _req 47 | 48 | return request_args 49 | 50 | 51 | def add_support(services, body_format="jws", signing_algorithm="RS256", 52 | http_client=None, merge_rule="strict"): 53 | """ 54 | Add the necessary pieces to make Demonstration of proof of possession (DPOP). 55 | 56 | :param merge_rule: 57 | :param http_client: 58 | :param signing_algorithm: 59 | :param services: A dictionary with all the services the client has access to. 60 | :param body_format: jws or urlencoded 61 | """ 62 | 63 | if http_client is None: 64 | http_client = requests 65 | 66 | _service = services["authorization"] 67 | _service.client_get("service_context").add_on['pushed_authorization'] = { 68 | "body_format": body_format, 69 | "signing_algorithm": signing_algorithm, 70 | "http_client": http_client, 71 | "merge_rule": merge_rule 72 | } 73 | 74 | _service.post_construct.append(push_authorization) 75 | -------------------------------------------------------------------------------- /src/oidcrp/entity.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from typing import Optional 3 | from typing import Union 4 | 5 | from cryptojwt import KeyJar 6 | 7 | from oidcrp.client_auth import factory 8 | from oidcrp.configure import Configuration 9 | from oidcrp.service import init_services 10 | from oidcrp.service_context import ServiceContext 11 | 12 | DEFAULT_SERVICES = { 13 | "discovery": { 14 | 'class': 'oidcrp.oauth2.provider_info_discovery.ProviderInfoDiscovery' 15 | }, 16 | 'authorization': { 17 | 'class': 'oidcrp.oauth2.authorization.Authorization' 18 | }, 19 | 'access_token': { 20 | 'class': 'oidcrp.oauth2.access_token.AccessToken' 21 | }, 22 | 'refresh_access_token': { 23 | 'class': 'oidcrp.oauth2.refresh_access_token.RefreshAccessToken' 24 | } 25 | } 26 | 27 | 28 | class Entity(): 29 | def __init__(self, 30 | client_authn_factory: Optional[Callable] = None, 31 | keyjar: Optional[KeyJar] = None, 32 | config: Optional[Union[dict, Configuration]] = None, 33 | services: Optional[dict] = None, 34 | jwks_uri: Optional[str] = '', 35 | httpc_params: Optional[dict] = None): 36 | 37 | if httpc_params: 38 | self.httpc_params = httpc_params 39 | else: 40 | self.httpc_params = {"verify": True} 41 | 42 | self._service_context = ServiceContext(keyjar=keyjar, config=config, 43 | jwks_uri=jwks_uri, httpc_params=self.httpc_params) 44 | 45 | _cam = client_authn_factory or factory 46 | 47 | _srvs = services or DEFAULT_SERVICES 48 | 49 | self._service = init_services(service_definitions=_srvs, 50 | client_get=self.client_get, 51 | client_authn_factory=_cam) 52 | 53 | def client_get(self, what, *arg): 54 | _func = getattr(self, "get_{}".format(what), None) 55 | if _func: 56 | return _func(*arg) 57 | return None 58 | 59 | def get_services(self, *arg): 60 | return self._service 61 | 62 | def get_service_context(self, *arg): 63 | return self._service_context 64 | 65 | def get_service(self, service_name, *arg): 66 | try: 67 | return self._service[service_name] 68 | except KeyError: 69 | return None 70 | 71 | def get_service_by_endpoint_name(self, endpoint_name, *arg): 72 | for service in self._service.values(): 73 | if service.endpoint_name == endpoint_name: 74 | return service 75 | 76 | return None 77 | 78 | def get_entity(self): 79 | return self 80 | 81 | def get_client_id(self): 82 | return self._service_context.client_id 83 | -------------------------------------------------------------------------------- /src/oidcrp/oauth2/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | from typing import Union 4 | 5 | from oidcmsg.exception import MissingParameter 6 | from oidcmsg.exception import MissingRequiredAttribute 7 | from oidcmsg.message import Message 8 | 9 | from oidcrp.service import Service 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def get_state_parameter(request_args, kwargs): 15 | """Find a state value from a set of possible places.""" 16 | try: 17 | _state = kwargs['state'] 18 | except KeyError: 19 | try: 20 | _state = request_args['state'] 21 | except KeyError: 22 | raise MissingParameter('state') 23 | 24 | return _state 25 | 26 | 27 | def pick_redirect_uri(context, 28 | request_args: Optional[Union[Message, dict]] = None, 29 | response_type: Optional[str] = ''): 30 | if request_args is None: 31 | request_args = {} 32 | 33 | if 'redirect_uri' in request_args: 34 | return request_args["redirect_uri"] 35 | 36 | if context.redirect_uris: 37 | redirect_uri = context.redirect_uris[0] 38 | elif context.callback: 39 | if not response_type: 40 | _conf_resp_types = context.behaviour.get('response_types', []) 41 | response_type = request_args.get('response_type') 42 | if not response_type and _conf_resp_types: 43 | response_type = _conf_resp_types[0] 44 | 45 | _response_mode = request_args.get('response_mode') 46 | 47 | if _response_mode == 'form_post' or response_type == ["form_post"]: 48 | redirect_uri = context.callback['form_post'] 49 | elif response_type == 'code' or response_type == ["code"]: 50 | redirect_uri = context.callback['code'] 51 | else: 52 | redirect_uri = context.callback['implicit'] 53 | 54 | logger.debug( 55 | f"pick_redirect_uris: response_type={response_type}, response_mode={_response_mode}, " 56 | f"redirect_uri={redirect_uri}") 57 | else: 58 | logger.error("No redirect_uri") 59 | raise MissingRequiredAttribute('redirect_uri') 60 | 61 | return redirect_uri 62 | 63 | 64 | def pre_construct_pick_redirect_uri(request_args: Optional[Union[Message, dict]] = None, 65 | service: Optional[Service] = None, **kwargs): 66 | _context = service.client_get("service_context") 67 | request_args["redirect_uri"] = pick_redirect_uri(_context, 68 | request_args=request_args) 69 | return request_args, {} 70 | 71 | 72 | def set_state_parameter(request_args=None, **kwargs): 73 | """Assigned a state value.""" 74 | request_args['state'] = get_state_parameter(request_args, kwargs) 75 | return request_args, {'state': request_args['state']} 76 | -------------------------------------------------------------------------------- /src/oidcrp/oidc/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from cryptojwt.jwe.jwe import JWE 4 | from cryptojwt.jwe.utils import alg2keytype 5 | from oidcmsg.exception import MissingRequiredAttribute 6 | 7 | from oidcrp.util import rndstr 8 | 9 | 10 | def request_object_encryption(msg, service_context, **kwargs): 11 | """ 12 | Created an encrypted JSON Web token with *msg* as body. 13 | 14 | :param msg: The mesaqg 15 | :param service_context: 16 | :param kwargs: 17 | :return: 18 | """ 19 | try: 20 | encalg = kwargs["request_object_encryption_alg"] 21 | except KeyError: 22 | try: 23 | encalg = service_context.behaviour[ 24 | "request_object_encryption_alg"] 25 | except KeyError: 26 | return msg 27 | 28 | if not encalg: 29 | return msg 30 | 31 | try: 32 | encenc = kwargs["request_object_encryption_enc"] 33 | except KeyError: 34 | try: 35 | encenc = service_context.behaviour["request_object_encryption_enc"] 36 | except KeyError: 37 | raise MissingRequiredAttribute( 38 | "No request_object_encryption_enc specified") 39 | 40 | if not encenc: 41 | raise MissingRequiredAttribute( 42 | "No request_object_encryption_enc specified") 43 | 44 | _jwe = JWE(msg, alg=encalg, enc=encenc) 45 | _kty = alg2keytype(encalg) 46 | 47 | try: 48 | _kid = kwargs["enc_kid"] 49 | except KeyError: 50 | _kid = "" 51 | 52 | if "target" not in kwargs: 53 | raise MissingRequiredAttribute("No target specified") 54 | 55 | if _kid: 56 | _keys = service_context.keyjar.get_encrypt_key(_kty, 57 | issuer_id=kwargs["target"], 58 | kid=_kid) 59 | _jwe["kid"] = _kid 60 | else: 61 | _keys = service_context.keyjar.get_encrypt_key(_kty, 62 | issuer_id=kwargs["target"]) 63 | 64 | return _jwe.encrypt(_keys) 65 | 66 | 67 | def construct_request_uri(local_dir, base_path, **kwargs): 68 | """ 69 | Constructs a special redirect_uri to be used when communicating with 70 | one OP. Each OP should get their own redirect_uris. 71 | 72 | :param local_dir: Local directory in which to place the file 73 | :param base_path: Base URL to start with 74 | :param kwargs: 75 | :return: 2-tuple with (filename, url) 76 | """ 77 | _filedir = local_dir 78 | if not os.path.isdir(_filedir): 79 | os.makedirs(_filedir) 80 | _webpath = base_path 81 | _name = rndstr(10) + ".jwt" 82 | filename = os.path.join(_filedir, _name) 83 | while os.path.exists(filename): 84 | _name = rndstr(10) 85 | filename = os.path.join(_filedir, _name) 86 | if _webpath.endswith("/"): 87 | _webname = f"{_webpath}{_name}" 88 | else: 89 | _webname = f"{_webpath}/{_name}" 90 | return filename, _webname 91 | -------------------------------------------------------------------------------- /src/oidcrp/oidc/end_session.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from oidcmsg.oauth2 import Message 4 | from oidcmsg.oauth2 import ResponseMessage 5 | from oidcmsg.oidc import session 6 | 7 | from oidcrp.service import Service 8 | from oidcrp.util import rndstr 9 | 10 | __author__ = 'Roland Hedberg' 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class EndSession(Service): 16 | msg_type = session.EndSessionRequest 17 | response_cls = Message 18 | error_msg = ResponseMessage 19 | endpoint_name = 'end_session_endpoint' 20 | synchronous = True 21 | service_name = 'end_session' 22 | response_body_type = 'html' 23 | 24 | def __init__(self, client_get, client_authn_factory=None, conf=None): 25 | Service.__init__(self, client_get, 26 | client_authn_factory=client_authn_factory, 27 | conf=conf) 28 | self.pre_construct = [self.get_id_token_hint, 29 | self.add_post_logout_redirect_uri, 30 | self.add_state] 31 | 32 | def get_id_token_hint(self, request_args=None, **kwargs): 33 | """ 34 | Add id_token_hint to request 35 | 36 | :param request_args: 37 | :param kwargs: 38 | :return: 39 | """ 40 | request_args = self.client_get("service_context").state.multiple_extend_request_args( 41 | request_args, kwargs['state'], ['id_token'], 42 | ['auth_response', 'token_response', 'refresh_token_response'], 43 | orig=True 44 | ) 45 | 46 | try: 47 | request_args['id_token_hint'] = request_args['id_token'] 48 | except KeyError: 49 | pass 50 | else: 51 | del request_args['id_token'] 52 | 53 | return request_args, {} 54 | 55 | def add_post_logout_redirect_uri(self, request_args=None, **kwargs): 56 | if 'post_logout_redirect_uri' not in request_args: 57 | _context = self.client_get("service_context") 58 | _uri = _context.register_args.get('post_logout_redirect_uris') 59 | if _uri: 60 | if isinstance(_uri, str): 61 | request_args['post_logout_redirect_uri'] = _uri 62 | else: # assume list 63 | request_args['post_logout_redirect_uri'] = _uri[0] 64 | else: 65 | _uri = _context.callback.get("post_logout_redirect_uris") 66 | if _uri: 67 | request_args['post_logout_redirect_uri'] = _uri[0] 68 | 69 | return request_args, {} 70 | 71 | def add_state(self, request_args=None, **kwargs): 72 | if 'state' not in request_args: 73 | request_args['state'] = rndstr(32) 74 | 75 | # As a side effect bind logout state to session state 76 | self.client_get("service_context").state.store_logout_state2state(request_args['state'], 77 | kwargs['state']) 78 | 79 | return request_args, {} 80 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright (C) 2017 Roland Hedberg, Sweden 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | import os 18 | import re 19 | import sys 20 | 21 | from setuptools import setup 22 | from setuptools.command.test import test as test_command 23 | 24 | __author__ = 'Roland Hedberg' 25 | 26 | 27 | class PyTest(test_command): 28 | def finalize_options(self): 29 | test_command.finalize_options(self) 30 | self.test_args = [] 31 | self.test_suite = True 32 | 33 | def run_tests(self): 34 | # import here, cause outside the eggs aren't loaded 35 | import pytest 36 | 37 | errno = pytest.main(self.test_args) 38 | sys.exit(errno) 39 | 40 | 41 | # Python 2.7 and later ship with importlib and argparse 42 | if sys.version_info[0] == 2 and sys.version_info[1] == 6: 43 | extra_install_requires = ["importlib", "argparse"] 44 | else: 45 | extra_install_requires = [] 46 | 47 | with open('src/oidcrp/__init__.py', 'r') as fd: 48 | version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', 49 | fd.read(), re.MULTILINE).group(1) 50 | 51 | with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme: 52 | README = readme.read() 53 | 54 | 55 | setup( 56 | name="oidcrp", 57 | version=version, 58 | description="Python implementation of OAuth2 Client and OpenID Connect RP", 59 | long_description=README, 60 | long_description_content_type='text/markdown', 61 | author="Roland Hedberg", 62 | author_email="roland@catalogix.se", 63 | license="Apache 2.0", 64 | url='https://github.com/IdentityPython/oicrp/', 65 | packages=["oidcrp", "oidcrp/provider", "oidcrp/oidc", "oidcrp/oauth2", 66 | "oidcrp/oauth2/add_on", "oidcrp/oauth2/client_credentials"], 67 | package_dir={"": "src"}, 68 | classifiers=[ 69 | "Development Status :: 5 - Production/Stable", 70 | "License :: OSI Approved :: Apache Software License", 71 | "Programming Language :: Python :: 3.7", 72 | "Programming Language :: Python :: 3.8", 73 | "Programming Language :: Python :: 3.9", 74 | "Programming Language :: Python :: 3.10", 75 | "Topic :: Software Development :: Libraries :: Python Modules"], 76 | install_requires=[ 77 | 'oidcmsg==1.6.0', 78 | 'pyyaml>=5.1.2', 79 | 'responses' 80 | ], 81 | tests_require=[ 82 | 'pytest', 83 | 'pytest-localserver' 84 | ], 85 | zip_safe=False, 86 | cmdclass={'test': PyTest}, 87 | ) 88 | -------------------------------------------------------------------------------- /unsupported/chrp/conf.py: -------------------------------------------------------------------------------- 1 | # BASE = "https://lingon.ladok.umu.se" 2 | 3 | PORT = 8089 4 | 5 | # If PORT and not default port 6 | BASEURL = "https://localhost:{}".format(PORT) 7 | # else 8 | # BASEURL = "https://localhost" 9 | 10 | # If BASE is https these has to be specified 11 | SERVER_CERT = "certs/cert.pem" 12 | SERVER_KEY = "certs/key.pem" 13 | CA_BUNDLE = None 14 | 15 | VERIFY_SSL = False 16 | 17 | KEYDEFS = [ 18 | {"type": "RSA", "key": '', "use": ["sig"]}, 19 | {"type": "EC", "crv": "P-256", "use": ["sig"]} 20 | ] 21 | 22 | PRIVATE_JWKS_PATH = "jwks_dir/jwks.json" 23 | PUBLIC_JWKS_PATH = 'static/jwks.json' 24 | # information used when registering the client, this may be the same for all OPs 25 | 26 | SERVICES = ['ProviderInfoDiscovery', 'Registration', 'Authorization', 27 | 'AccessToken', 'RefreshAccessToken', 'UserInfo'] 28 | 29 | SERVICES_DICT = {'accesstoken': {'class': 'oidcrp.oidc.access_token.AccessToken', 30 | 'kwargs': {}}, 31 | 'authorization': {'class': 'oidcrp.oidc.authorization.Authorization', 32 | 'kwargs': {}}, 33 | 'discovery': {'class': 'oidcrp.oidc.provider_info_discovery.ProviderInfoDiscovery', 34 | 'kwargs': {}}, 35 | 'end_session': {'class': 'oidcrp.oidc.end_session.EndSession', 36 | 'kwargs': {}}, 37 | 'refresh_accesstoken': {'class': 'oidcrp.oidc.refresh_access_token.RefreshAccessToken', 38 | 'kwargs': {}}, 39 | 'registration': {'class': 'oidcrp.oidc.registration.Registration', 40 | 'kwargs': {}}, 41 | 'userinfo': {'class': 'oidcrp.oidc.userinfo.UserInfo', 'kwargs': {}}} 42 | 43 | CLIENT_PREFS = { 44 | "application_type": "web", 45 | "application_name": "rphandler", 46 | "contacts": ["ops@example.com"], 47 | "response_types": ["code", "id_token", "id_token token", "code id_token", 48 | "code id_token token", "code token"], 49 | "scope": ["openid", "profile", "email", "address", "phone"], 50 | "token_endpoint_auth_method": "client_secret_basic", 51 | 'services': SERVICES_DICT 52 | } 53 | 54 | # The keys in this dictionary are the OPs short user friendly name 55 | # not the issuer (iss) name. 56 | 57 | CLIENTS = { 58 | # The ones that support webfinger, OP discovery and client registration 59 | # This is the default, any client that is not listed here is expected to 60 | # support dynamic discovery and registration. 61 | "": { 62 | "client_preferences": CLIENT_PREFS, 63 | "redirect_uris": None, 64 | "services": { 65 | 'WebFinger': {}, 66 | 'ProviderInfoDiscovery': {}, 67 | 'Registration': {}, 68 | 'Authorization': {}, 69 | 'AccessToken': {}, 70 | 'RefreshAccessToken': {}, 71 | 'UserInfo': {} 72 | } 73 | }, 74 | 'flop': 75 | { 76 | 'client_preferences': CLIENT_PREFS, 77 | 'issuer': 'https://127.0.0.1:5000/', 78 | 'redirect_uris': ['https://127.0.0.1:8090/authz_cb/flop'], 79 | 'services': SERVICES_DICT 80 | } 81 | } 82 | 83 | # Whether an attempt to fetch the userinfo should be made 84 | USERINFO = True 85 | -------------------------------------------------------------------------------- /example/flask_rp/certs/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQC3NrEL+VKs00NT 3 | R+ZpGRxvDoeLhD7EM+uf7IqHl6IN3H6pflAOE8YqnTepdglhGH4a7nyftINTZjDU 4 | 86anR+OKPoY2Padf4E+YceJOcaT6lB5XOWxBu4j3wDRHb6jMUwMDUXHsmh389Bvx 5 | X44KSYe/mhjkrIV8bolhT9NpNjPVUdUvpwpSxDOhSjq7BCmfdvXJrNNYElEQaDSc 6 | yJ4h6BAOp/FfdnWKAeiVDpIF5QqZgr0gzKiV5LEvwsNfHynsLgrlgK2+Fd8qIqbC 7 | /fHtB1BEL3h01dlBR1Y4ocMM5we23Phe4lwQs8QojPTnnr14fWynrjNi0Km0TcMT 8 | TDHVnw5qO5dSr4LpBcfIo82YWpj6lTEKQwKin+SPz0k0kD4E83rtsGp8n3FWHVAo 9 | BsIJ4O58REi3YTh1NCe/bjsQWiFOPW0N9GOl0UTOUj90cGVbO9i91aDFHHQWOIiA 10 | VsmZ35yOjQ031It9Kzv4YcmWXQcdKYnzUQ5eSXZPmJFoebKgQF6neFlg1hp6uDKi 11 | NRxkaPWGVCVZXPmgRwVcFdbxI8OpNqPEFQGskUPGJS5CF1o8o6wuVwPSSwxDVoYM 12 | 12TTdATH1he4cK69ej/1F2oHCVQ0KE46fNABaxNKxGls0bPPPJBPrQBjoAR2qxgg 13 | iFz2DjumVC3EySwXLsH4tXTjyuVbSQIDAQABAoICAQCoZ801hGdKFKa91kkkMcDB 14 | FEnjJBvNnSvoRDTRjb+XniWPBlvvlJ2CbiDL04OrjCfd+Xj0E6ji7/vSwmNdP+cX 15 | G4GiOemvZy/CoGu0TyGmcp+w7Udk5Exx7moff7NYnLUYR7TAFqmZ6YgFxh95tTzi 16 | EXLwPuQ0DCabHBTnkLr0SdP7iT8j9NTAXMq/PIRF38LtLb7WJX/95Mr3kjBIWlbo 17 | IdbsOKaxxC9VU59Fa9LiaBoQHA6aOSvlCtEqjiqqvWemrTEGmHQY9uDyOxo1FZPi 18 | GQBP5IFeT4Qhag8vvOyKWXKzRL37XEHiRC6Y+ICQUDmfp6/0FHjpEtFM26yy/xDv 19 | ZtL7/b7TEQMmp2CWD8WV8a9oalTRqyrGTBeeSg6CV5tnx3wnM0krkCvJ+Eadki23 20 | Wp34s7v8NPmVMTqG/UIW21tmzb40KjXNI8MgNXASBIKm9W2z2xXQ07xELsSfWm9O 21 | p0umh1xHLqX7rNmigg/odW3K9aocF8NOhuc4aYgVZH18sMhkhja3dgwCe8YSImyW 22 | 0uHZC6wKIXnD44lS2BmdYsIY/k+uZKNum6lE7x/F1V2vbzkzShuJ7VCD3IhQW6nK 23 | XNQBXju/CnMiMW6mpZsSZG8mIjx8hNKLYv492ZNgnbeP2HHM5WAsKTOKLO0FldFS 24 | sbRSXTTM40j7AcurS7DKQQKCAQEA2WdkRhGXOuOlHSq/W4YZ6Mq2kydp46ARQS8b 25 | zKbUXX6+7GyU6TSB71eblP4003NGx8rdasyZTpexRH4sTKv0/GLM2eSDEi7/GV1w 26 | HISwdIa8NlHiOT9qPONqdhH0KDy5lDrCTMa9B5QpbYo4l/F/4O52zJc1CuRacpyi 27 | 58hY3Me2UND2yHqb2TKxOwwHumE8FEMs9CqixLE4oAaoiNdJi08pyg7o/6oxPaUE 28 | CKmGX6r9eW5piFCLGAkmfAgBjYejrFDAp8eY6Yx5dRWMdLddQnm/5tl0rzFho+71 29 | UwtOIZtowKeWms1N/+duOmcfYyDsRJ/Ec3pzxphzcHrWEllP9wKCAQEA171qkSxv 30 | +53viIJbaJ636emDg4kZ3asGLODefEcbe0XS0xHmsb+WZpRIBkNMJFj3k2IYcUSO 31 | 7DObemF4ln9CJY+DxHZJzr/mo8T3X0yt0aK8O75+fXHQ/991kUMcx21BmXMjybYj 32 | TA5vv956AYV9Kt37ye87dYMtEINtchdukYqyrLZ9+0lBV1XrGKALMC68EyyTtDFs 33 | AtJzKVTYnKNkYFWkA6cq+GZvlEbx74dZopH/yVo+P/wGiU5AH1bq5847uq5LIwIU 34 | j2ZkKBJr8Y3YvFjAaRNRGNXOhHUo3BPkgkYZGnC2WP9UJT3w7PgjwyUpbFZurwIr 35 | Sz1QdbNZ+spevwKCAQBgyN6jMwGYfe/r5DP8kt7F/Dj7mfhSFdiYpFhD66FvXhWx 36 | O0Wv7GhMHTxuQB1UZWWFXJLmEN/PVUjdrS4blBIkqfd4qXqQhcubhzV5/Lhxp+ny 37 | ZNHJmqm5IaUrmyKPJzmW+/G0LGXLEfK/iWFYg3LiuEa7HjXG+5IopAMCHPcyktZf 38 | dCfpaGwpbZ/pIZnvJ4qPmrhQmwqLdjo3Q7+T7AQZuMxp3+lqqGHzh5scIBxqSr09 39 | aiIhRXom4Sv427eVQmVjOTALgZhZoOgRb95vt5IVHg6IvxZrSBin2qHsroPCAmXI 40 | HtO1ZuDqpCU2auJWRznn8xiKMGGKcCQ0VvsmgAxRAoIBAQDPsB7OQRxQ+3skTHIZ 41 | Jmrg+ZdM4oiPGFyqiZRFyeKP6ukJnvsadNkiSW+I7/J2L1uve8kSCbEZfJkZ2InR 42 | QBN6u01brZBiQ+WSFUUbbmMLJIHXdgypUQ+ltAanYBdteSWkxu5V+kzCpEc6S7/i 43 | hRK5WNhTT0ZLW4vfkNak9h/QZtiZYlmntp77p8/adgAvU15liw1qdAWKNfT9fhvF 44 | t5ojD28EwUKhvWN/OEkikYdd9PVsbr7ss//K4RTj1rXvkF952N6mhhMq9aRH22wl 45 | L6vNrhcVUK5KnVHhvDQoodHjA/6YsJcq2Cq2a4nrZvpum/DjxdVqD0mEdjNmC9H8 46 | mCNbAoIBAHbkApjatORw6Bb+zAbfLs2vKLMs0sVABmA2AzTukm8+k3Clji4npGxh 47 | IGj4c2kBa93yOd25qONoNvFfcig+LbCnq5aT8qSLTl7iecRNvvAlxA1r7MHRqjYO 48 | bFGAM5cCZC+hpOmXF80IOmQMfaV33tCHJ0uf1fOvkreAQxPOJqEskYGFHqN8zfeW 49 | zsSMnea+oHvfAhHmQcikJV/YiomYb0Urz838o5o+JLTkBs+miwPNTZW5iVEnYLUh 50 | NtABZU3c1ohXAw8i4Z/Jdmxzsro75D3ekRfa/coPCcnUK0MqYd8C/uEVe5rgXOWZ 51 | Svp9rK9sO9LqfKBeV9NKW9/wb/X6lU4= 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /unsupported/chrp/certs/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQC3NrEL+VKs00NT 3 | R+ZpGRxvDoeLhD7EM+uf7IqHl6IN3H6pflAOE8YqnTepdglhGH4a7nyftINTZjDU 4 | 86anR+OKPoY2Padf4E+YceJOcaT6lB5XOWxBu4j3wDRHb6jMUwMDUXHsmh389Bvx 5 | X44KSYe/mhjkrIV8bolhT9NpNjPVUdUvpwpSxDOhSjq7BCmfdvXJrNNYElEQaDSc 6 | yJ4h6BAOp/FfdnWKAeiVDpIF5QqZgr0gzKiV5LEvwsNfHynsLgrlgK2+Fd8qIqbC 7 | /fHtB1BEL3h01dlBR1Y4ocMM5we23Phe4lwQs8QojPTnnr14fWynrjNi0Km0TcMT 8 | TDHVnw5qO5dSr4LpBcfIo82YWpj6lTEKQwKin+SPz0k0kD4E83rtsGp8n3FWHVAo 9 | BsIJ4O58REi3YTh1NCe/bjsQWiFOPW0N9GOl0UTOUj90cGVbO9i91aDFHHQWOIiA 10 | VsmZ35yOjQ031It9Kzv4YcmWXQcdKYnzUQ5eSXZPmJFoebKgQF6neFlg1hp6uDKi 11 | NRxkaPWGVCVZXPmgRwVcFdbxI8OpNqPEFQGskUPGJS5CF1o8o6wuVwPSSwxDVoYM 12 | 12TTdATH1he4cK69ej/1F2oHCVQ0KE46fNABaxNKxGls0bPPPJBPrQBjoAR2qxgg 13 | iFz2DjumVC3EySwXLsH4tXTjyuVbSQIDAQABAoICAQCoZ801hGdKFKa91kkkMcDB 14 | FEnjJBvNnSvoRDTRjb+XniWPBlvvlJ2CbiDL04OrjCfd+Xj0E6ji7/vSwmNdP+cX 15 | G4GiOemvZy/CoGu0TyGmcp+w7Udk5Exx7moff7NYnLUYR7TAFqmZ6YgFxh95tTzi 16 | EXLwPuQ0DCabHBTnkLr0SdP7iT8j9NTAXMq/PIRF38LtLb7WJX/95Mr3kjBIWlbo 17 | IdbsOKaxxC9VU59Fa9LiaBoQHA6aOSvlCtEqjiqqvWemrTEGmHQY9uDyOxo1FZPi 18 | GQBP5IFeT4Qhag8vvOyKWXKzRL37XEHiRC6Y+ICQUDmfp6/0FHjpEtFM26yy/xDv 19 | ZtL7/b7TEQMmp2CWD8WV8a9oalTRqyrGTBeeSg6CV5tnx3wnM0krkCvJ+Eadki23 20 | Wp34s7v8NPmVMTqG/UIW21tmzb40KjXNI8MgNXASBIKm9W2z2xXQ07xELsSfWm9O 21 | p0umh1xHLqX7rNmigg/odW3K9aocF8NOhuc4aYgVZH18sMhkhja3dgwCe8YSImyW 22 | 0uHZC6wKIXnD44lS2BmdYsIY/k+uZKNum6lE7x/F1V2vbzkzShuJ7VCD3IhQW6nK 23 | XNQBXju/CnMiMW6mpZsSZG8mIjx8hNKLYv492ZNgnbeP2HHM5WAsKTOKLO0FldFS 24 | sbRSXTTM40j7AcurS7DKQQKCAQEA2WdkRhGXOuOlHSq/W4YZ6Mq2kydp46ARQS8b 25 | zKbUXX6+7GyU6TSB71eblP4003NGx8rdasyZTpexRH4sTKv0/GLM2eSDEi7/GV1w 26 | HISwdIa8NlHiOT9qPONqdhH0KDy5lDrCTMa9B5QpbYo4l/F/4O52zJc1CuRacpyi 27 | 58hY3Me2UND2yHqb2TKxOwwHumE8FEMs9CqixLE4oAaoiNdJi08pyg7o/6oxPaUE 28 | CKmGX6r9eW5piFCLGAkmfAgBjYejrFDAp8eY6Yx5dRWMdLddQnm/5tl0rzFho+71 29 | UwtOIZtowKeWms1N/+duOmcfYyDsRJ/Ec3pzxphzcHrWEllP9wKCAQEA171qkSxv 30 | +53viIJbaJ636emDg4kZ3asGLODefEcbe0XS0xHmsb+WZpRIBkNMJFj3k2IYcUSO 31 | 7DObemF4ln9CJY+DxHZJzr/mo8T3X0yt0aK8O75+fXHQ/991kUMcx21BmXMjybYj 32 | TA5vv956AYV9Kt37ye87dYMtEINtchdukYqyrLZ9+0lBV1XrGKALMC68EyyTtDFs 33 | AtJzKVTYnKNkYFWkA6cq+GZvlEbx74dZopH/yVo+P/wGiU5AH1bq5847uq5LIwIU 34 | j2ZkKBJr8Y3YvFjAaRNRGNXOhHUo3BPkgkYZGnC2WP9UJT3w7PgjwyUpbFZurwIr 35 | Sz1QdbNZ+spevwKCAQBgyN6jMwGYfe/r5DP8kt7F/Dj7mfhSFdiYpFhD66FvXhWx 36 | O0Wv7GhMHTxuQB1UZWWFXJLmEN/PVUjdrS4blBIkqfd4qXqQhcubhzV5/Lhxp+ny 37 | ZNHJmqm5IaUrmyKPJzmW+/G0LGXLEfK/iWFYg3LiuEa7HjXG+5IopAMCHPcyktZf 38 | dCfpaGwpbZ/pIZnvJ4qPmrhQmwqLdjo3Q7+T7AQZuMxp3+lqqGHzh5scIBxqSr09 39 | aiIhRXom4Sv427eVQmVjOTALgZhZoOgRb95vt5IVHg6IvxZrSBin2qHsroPCAmXI 40 | HtO1ZuDqpCU2auJWRznn8xiKMGGKcCQ0VvsmgAxRAoIBAQDPsB7OQRxQ+3skTHIZ 41 | Jmrg+ZdM4oiPGFyqiZRFyeKP6ukJnvsadNkiSW+I7/J2L1uve8kSCbEZfJkZ2InR 42 | QBN6u01brZBiQ+WSFUUbbmMLJIHXdgypUQ+ltAanYBdteSWkxu5V+kzCpEc6S7/i 43 | hRK5WNhTT0ZLW4vfkNak9h/QZtiZYlmntp77p8/adgAvU15liw1qdAWKNfT9fhvF 44 | t5ojD28EwUKhvWN/OEkikYdd9PVsbr7ss//K4RTj1rXvkF952N6mhhMq9aRH22wl 45 | L6vNrhcVUK5KnVHhvDQoodHjA/6YsJcq2Cq2a4nrZvpum/DjxdVqD0mEdjNmC9H8 46 | mCNbAoIBAHbkApjatORw6Bb+zAbfLs2vKLMs0sVABmA2AzTukm8+k3Clji4npGxh 47 | IGj4c2kBa93yOd25qONoNvFfcig+LbCnq5aT8qSLTl7iecRNvvAlxA1r7MHRqjYO 48 | bFGAM5cCZC+hpOmXF80IOmQMfaV33tCHJ0uf1fOvkreAQxPOJqEskYGFHqN8zfeW 49 | zsSMnea+oHvfAhHmQcikJV/YiomYb0Urz838o5o+JLTkBs+miwPNTZW5iVEnYLUh 50 | NtABZU3c1ohXAw8i4Z/Jdmxzsro75D3ekRfa/coPCcnUK0MqYd8C/uEVe5rgXOWZ 51 | Svp9rK9sO9LqfKBeV9NKW9/wb/X6lU4= 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /src/oidcrp/oauth2/authorization.py: -------------------------------------------------------------------------------- 1 | """The service that talks to the OAuth2 Authorization endpoint.""" 2 | import logging 3 | 4 | from oidcmsg import oauth2 5 | from oidcmsg.exception import MissingParameter 6 | from oidcmsg.oauth2 import ResponseMessage 7 | from oidcmsg.time_util import time_sans_frac 8 | 9 | from oidcrp.oauth2.utils import get_state_parameter 10 | from oidcrp.oauth2.utils import pre_construct_pick_redirect_uri 11 | from oidcrp.oauth2.utils import set_state_parameter 12 | from oidcrp.service import Service 13 | 14 | LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | class Authorization(Service): 18 | """The service that talks to the OAuth2 Authorization endpoint.""" 19 | msg_type = oauth2.AuthorizationRequest 20 | response_cls = oauth2.AuthorizationResponse 21 | error_msg = ResponseMessage 22 | endpoint_name = 'authorization_endpoint' 23 | synchronous = False 24 | service_name = 'authorization' 25 | response_body_type = 'urlencoded' 26 | 27 | # parameter = Service.parameter.copy() 28 | # parameter.update({ 29 | # "endpoint": "" 30 | # }) 31 | 32 | def __init__(self, client_get, client_authn_factory=None, conf=None): 33 | Service.__init__(self, client_get, 34 | client_authn_factory=client_authn_factory, conf=conf) 35 | self.pre_construct.extend([pre_construct_pick_redirect_uri, set_state_parameter]) 36 | self.post_construct.append(self.store_auth_request) 37 | 38 | def update_service_context(self, resp, key='', **kwargs): 39 | if 'expires_in' in resp: 40 | resp['__expires_at'] = time_sans_frac() + int(resp['expires_in']) 41 | self.client_get("service_context").state.store_item(resp, 'auth_response', key) 42 | 43 | def store_auth_request(self, request_args=None, **kwargs): 44 | """Store the authorization request in the state DB.""" 45 | _key = get_state_parameter(request_args, kwargs) 46 | self.client_get("service_context").state.store_item(request_args, 'auth_request', _key) 47 | return request_args 48 | 49 | def gather_request_args(self, **kwargs): 50 | ar_args = Service.gather_request_args(self, **kwargs) 51 | 52 | if 'redirect_uri' not in ar_args: 53 | try: 54 | ar_args['redirect_uri'] = self.client_get("service_context").redirect_uris[0] 55 | except (KeyError, AttributeError): 56 | raise MissingParameter('redirect_uri') 57 | 58 | return ar_args 59 | 60 | def post_parse_response(self, response, **kwargs): 61 | """ 62 | Add scope claim to response, from the request, if not present in the 63 | response 64 | 65 | :param response: The response 66 | :param kwargs: Extra Keyword arguments 67 | :return: A possibly augmented response 68 | """ 69 | 70 | if "scope" not in response: 71 | try: 72 | _key = kwargs['state'] 73 | except KeyError: 74 | pass 75 | else: 76 | if _key: 77 | item = self.client_get("service_context").state.get_item(oauth2.AuthorizationRequest, 78 | 'auth_request', _key) 79 | try: 80 | response["scope"] = item["scope"] 81 | except KeyError: 82 | pass 83 | return response 84 | -------------------------------------------------------------------------------- /src/oidcrp/oidc/access_token.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | from typing import Union 4 | 5 | from oidcmsg import oidc 6 | from oidcmsg.message import Message 7 | from oidcmsg.oidc import verified_claim_name 8 | from oidcmsg.time_util import time_sans_frac 9 | 10 | from oidcrp.exception import ParameterError 11 | from oidcrp.oauth2 import access_token 12 | from oidcrp.oidc import IDT2REG 13 | 14 | __author__ = 'Roland Hedberg' 15 | 16 | LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class AccessToken(access_token.AccessToken): 20 | msg_type = oidc.AccessTokenRequest 21 | response_cls = oidc.AccessTokenResponse 22 | error_msg = oidc.ResponseMessage 23 | 24 | def __init__(self, 25 | client_get, 26 | client_authn_factory=None, 27 | conf: Optional[dict]=None): 28 | access_token.AccessToken.__init__(self, client_get, 29 | client_authn_factory=client_authn_factory, conf=conf) 30 | 31 | def gather_verify_arguments(self, 32 | response: Optional[Union[dict, Message]] = None, 33 | behaviour_args: Optional[dict] = None): 34 | """ 35 | Need to add some information before running verify() 36 | 37 | :return: dictionary with arguments to the verify call 38 | """ 39 | _context = self.client_get("service_context") 40 | _entity = self.client_get("entity") 41 | 42 | kwargs = { 43 | 'client_id': _entity.get_client_id(), 44 | 'iss': _context.issuer, 45 | 'keyjar': _context.keyjar, 46 | 'verify': True, 47 | 'skew': _context.clock_skew, 48 | } 49 | 50 | _reg_resp = _context.registration_response 51 | if _reg_resp: 52 | for attr, param in IDT2REG.items(): 53 | try: 54 | kwargs[attr] = _reg_resp[param] 55 | except KeyError: 56 | pass 57 | 58 | try: 59 | kwargs['allow_missing_kid'] = _context.allow['missing_kid'] 60 | except KeyError: 61 | pass 62 | 63 | _verify_args = _context.behaviour.get("verify_args") 64 | if _verify_args: 65 | if _verify_args: 66 | kwargs.update(_verify_args) 67 | 68 | return kwargs 69 | 70 | def update_service_context(self, resp, key='', **kwargs): 71 | _state_interface = self.client_get("service_context").state 72 | try: 73 | _idt = resp[verified_claim_name('id_token')] 74 | except KeyError: 75 | pass 76 | else: 77 | try: 78 | if _state_interface.get_state_by_nonce(_idt['nonce']) != key: 79 | raise ParameterError('Someone has messed with "nonce"') 80 | except KeyError: 81 | raise ValueError('Invalid nonce value') 82 | 83 | _state_interface.store_sub2state(_idt['sub'], key) 84 | 85 | if 'expires_in' in resp: 86 | resp['__expires_at'] = time_sans_frac() + int( 87 | resp['expires_in']) 88 | 89 | _state_interface.store_item(resp, 'token_response', key) 90 | 91 | def get_authn_method(self): 92 | try: 93 | return self.client_get("service_context").behaviour['token_endpoint_auth_method'] 94 | except KeyError: 95 | return self.default_authn_method 96 | -------------------------------------------------------------------------------- /unsupported/chrp/rp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import cherrypy 3 | import importlib 4 | import logging 5 | import os 6 | import sys 7 | 8 | from cryptojwt.key_jar import init_key_jar 9 | 10 | from oidcrp import RPHandler 11 | 12 | logger = logging.getLogger("") 13 | LOGFILE_NAME = 'farp.log' 14 | hdlr = logging.FileHandler(LOGFILE_NAME) 15 | base_formatter = logging.Formatter( 16 | "%(asctime)s %(name)s:%(levelname)s %(message)s") 17 | 18 | hdlr.setFormatter(base_formatter) 19 | logger.addHandler(hdlr) 20 | logger.setLevel(logging.DEBUG) 21 | 22 | 23 | SIGKEY_NAME = 'sigkey.jwks' 24 | 25 | 26 | if __name__ == '__main__': 27 | import argparse 28 | 29 | parser = argparse.ArgumentParser() 30 | parser.add_argument('-t', dest='tls', action='store_true') 31 | parser.add_argument('-k', dest='insecure', action='store_true') 32 | parser.add_argument(dest="config") 33 | args = parser.parse_args() 34 | 35 | folder = os.path.abspath(os.curdir) 36 | sys.path.insert(0, ".") 37 | config = importlib.import_module(args.config) 38 | try: 39 | _port = config.PORT 40 | except AttributeError: 41 | if args.tls: 42 | _port = 443 43 | else: 44 | _port = 80 45 | 46 | cherrypy.config.update( 47 | {'environment': 'production', 48 | 'log.error_file': 'error.log', 49 | 'log.access_file': 'access.log', 50 | 'tools.trailing_slash.on': False, 51 | 'server.socket_host': '0.0.0.0', 52 | 'log.screen': True, 53 | 'tools.sessions.on': True, 54 | 'tools.encode.on': True, 55 | 'tools.encode.encoding': 'utf-8', 56 | 'server.socket_port': _port 57 | }) 58 | 59 | provider_config = { 60 | '/': { 61 | 'root_path': 'localhost', 62 | 'log.screen': True 63 | }, 64 | '/static': { 65 | 'tools.staticdir.dir': os.path.join(folder, 'static'), 66 | 'tools.staticdir.debug': True, 67 | 'tools.staticdir.on': True, 68 | 'tools.staticdir.content_types': { 69 | 'json': 'application/json', 70 | 'jwks': 'application/json', 71 | 'jose': 'application/jose' 72 | }, 73 | 'log.screen': True, 74 | 'cors.expose_public.on': True 75 | }} 76 | 77 | cprp = importlib.import_module('cprp') 78 | 79 | _base_url = config.BASEURL 80 | 81 | _kj = init_key_jar(public_path=config.PUBLIC_JWKS_PATH, 82 | private_path=config.PRIVATE_JWKS_PATH, 83 | key_defs=config.KEYDEFS) 84 | 85 | if args.insecure: 86 | _kj.verify_ssl = False 87 | _verify_ssl = False 88 | else: 89 | _verify_ssl = True 90 | 91 | rph = RPHandler(_base_url, config.CLIENTS, services=config.SERVICES, 92 | hash_seed="BabyHoldOn", keyjar=_kj, jwks_path=config.PUBLIC_JWKS_PATH, 93 | verify_ssl=_verify_ssl) 94 | 95 | cherrypy.tree.mount(cprp.Consumer(rph, 'html'), '/', provider_config) 96 | 97 | # If HTTPS 98 | if args.tls: 99 | cherrypy.server.ssl_certificate = config.SERVER_CERT 100 | cherrypy.server.ssl_private_key = config.SERVER_KEY 101 | if config.CA_BUNDLE: 102 | cherrypy.server.ssl_certificate_chain = config.CA_BUNDLE 103 | 104 | cherrypy.engine.start() 105 | cherrypy.engine.block() 106 | -------------------------------------------------------------------------------- /tests/rp_conf.yaml: -------------------------------------------------------------------------------- 1 | port: &port 8090 2 | domain: &domain 127.0.0.1 3 | base_url: "https://{domain}:{port}" 4 | 5 | httpc_params: 6 | # This is just for testing a local usage. In all other cases it MUST be True 7 | verify: false 8 | # Client side 9 | #client_cert: "certs/client.crt" 10 | #client_key: "certs/client.key" 11 | 12 | keydefs: &keydef 13 | - "type": "RSA" 14 | "key": '' 15 | "use": ["sig"] 16 | - "type": "EC" 17 | "crv": "P-256" 18 | "use": ["sig"] 19 | 20 | rp_keys: 21 | 'private_path': 'private/jwks.json' 22 | 'key_defs': *keydef 23 | 'public_path': 'static/jwks.json' 24 | # this will create the jwks files if they are absent 25 | 'read_only': False 26 | 27 | client_preferences: &id001 28 | application_name: rphandler 29 | application_type: web 30 | contacts: 31 | - ops@example.com 32 | response_types: 33 | - code 34 | scope: 35 | - openid 36 | - profile 37 | - email 38 | - address 39 | - phone 40 | token_endpoint_auth_method: 41 | - client_secret_basic 42 | - client_secret_post 43 | 44 | services: &id002 45 | discovery: &disc 46 | class: oidcservice.oidc.provider_info_discovery.ProviderInfoDiscovery 47 | kwargs: {} 48 | registration: ®ist 49 | class: oidcservice.oidc.registration.Registration 50 | kwargs: {} 51 | authorization: &authz 52 | class: oidcservice.oidc.authorization.Authorization 53 | kwargs: {} 54 | accesstoken: &acctok 55 | class: oidcservice.oidc.access_token.AccessToken 56 | kwargs: {} 57 | userinfo: &userinfo 58 | class: oidcservice.oidc.userinfo.UserInfo 59 | kwargs: {} 60 | end_session: &sess 61 | class: oidcservice.oidc.end_session.EndSession 62 | kwargs: {} 63 | 64 | clients: 65 | "": 66 | client_preferences: *id001 67 | redirect_uris: None 68 | services: *id002 69 | flop: 70 | client_preferences: *id001 71 | issuer: https://127.0.0.1:5000/ 72 | redirect_uris: 73 | - 'https://{domain}:{port}/authz_cb/flop' 74 | post_logout_redirect_uris: 75 | - "https://{domain}:{port}/session_logout/flop" 76 | frontchannel_logout_uri: "https://{domain}:{port}/fc_logout/flop" 77 | frontchannel_logout_session_required: True 78 | backchannel_logout_uri: "https://{domain}:{port}/bc_logout/flop" 79 | backchannel_logout_session_required: True 80 | services: 81 | discovery: *disc 82 | registration: *regist 83 | authorization: *authz 84 | accesstoken: *acctok 85 | userinfo: *userinfo 86 | end_session: *sess 87 | add_ons: 88 | pkce: 89 | function: oidcservice.oidc.add_on.pkce.add_pkce_support 90 | kwargs: 91 | code_challenge_length: 64 92 | code_challenge_method: S256 93 | # status_check: 94 | # function: oidcservice.oidc.add_on.status_check.add_status_check_support 95 | # kwargs: 96 | # rp_iframe_path: "templates/rp_iframe.html" 97 | bobcat: 98 | client_id: client3 99 | client_secret: 'abcdefghijklmnop' 100 | client_preferences: *id001 101 | issuer: http://127.0.0.1:8080/ 102 | jwks_uri: 'static/jwks.json' 103 | redirect_uris: ['https://{domain}:{port}/authz_cb/bobcat'] 104 | post_logout_redirect_uris: 105 | - "https://{domain}:{port}/session_logout/bobcat" 106 | services: *id002 107 | request_args: 108 | claims: 109 | id_token: 110 | acr: 111 | essential: 112 | true 113 | -------------------------------------------------------------------------------- /tests/test_07_service.py: -------------------------------------------------------------------------------- 1 | from oidcmsg.oauth2 import Message 2 | from oidcmsg.oauth2 import SINGLE_OPTIONAL_INT 3 | from oidcmsg.oauth2 import SINGLE_OPTIONAL_STRING 4 | from oidcmsg.oauth2 import SINGLE_REQUIRED_STRING 5 | import pytest 6 | 7 | from oidcrp.entity import Entity 8 | from oidcrp.service import Service 9 | from oidcrp.service_context import ServiceContext 10 | from oidcrp.state_interface import InMemoryStateDataBase 11 | from oidcrp.state_interface import State 12 | 13 | 14 | class DummyMessage(Message): 15 | c_param = { 16 | "req_str": SINGLE_REQUIRED_STRING, 17 | "opt_str": SINGLE_OPTIONAL_STRING, 18 | "opt_int": SINGLE_OPTIONAL_INT, 19 | } 20 | 21 | 22 | class Response(object): 23 | def __init__(self, status_code, text, headers=None): 24 | self.status_code = status_code 25 | self.text = text 26 | self.headers = headers or {"content-type": "text/plain"} 27 | 28 | 29 | class DummyService(Service): 30 | msg_type = DummyMessage 31 | 32 | 33 | class TestDummyService(object): 34 | @pytest.fixture(autouse=True) 35 | def create_service(self): 36 | config = { 37 | "issuer": 'https://www.example.org/as', 38 | 'client_id': 'client_id', 39 | 'client_secret': 'a longesh password', 40 | 'redirect_uris': ['https://example.com/cli/authz_cb'], 41 | 'behaviour': {'response_types': ['code']} 42 | } 43 | service = { 44 | "dummy": { 45 | "class": DummyService 46 | } 47 | } 48 | 49 | entity = Entity(config=config, services=service) 50 | self.service = DummyService(client_get=entity.client_get, conf={}) 51 | 52 | def test_construct(self): 53 | req_args = {'foo': 'bar'} 54 | _req = self.service.construct(request_args=req_args) 55 | assert isinstance(_req, Message) 56 | assert list(_req.keys()) == ['foo'] 57 | 58 | def test_construct_service_context(self): 59 | req_args = {'foo': 'bar', 'req_str': 'some string'} 60 | _req = self.service.construct(request_args=req_args) 61 | assert isinstance(_req, Message) 62 | assert set(_req.keys()) == {'foo', 'req_str'} 63 | 64 | def test_get_request_parameters(self): 65 | req_args = {'foo': 'bar', 'req_str': 'some string'} 66 | self.service.endpoint = 'https://example.com/authorize' 67 | _info = self.service.get_request_parameters(request_args=req_args) 68 | assert set(_info.keys()) == {'url', 'method', "request"} 69 | msg = DummyMessage().from_urlencoded( 70 | self.service.get_urlinfo(_info['url'])) 71 | 72 | def test_request_init(self): 73 | req_args = {'foo': 'bar', 'req_str': 'some string'} 74 | self.service.endpoint = 'https://example.com/authorize' 75 | _info = self.service.get_request_parameters(request_args=req_args) 76 | assert set(_info.keys()) == {'url', 'method', "request"} 77 | msg = DummyMessage().from_urlencoded( 78 | self.service.get_urlinfo(_info['url'])) 79 | assert msg.to_dict() == {'foo': 'bar', 'req_str': 'some string'} 80 | 81 | 82 | # class TestRequest(object): 83 | # @pytest.fixture(autouse=True) 84 | # def create_service(self): 85 | # entity = Entity() 86 | # service_context = entity.get_service_context() 87 | # self.service = Service(service_context, client_authn_method=None) 88 | # 89 | # def test_construct(self): 90 | # req_args = {'foo': 'bar'} 91 | # _req = self.service.construct(request_args=req_args) 92 | # assert isinstance(_req, Message) 93 | # assert list(_req.keys()) == ['foo'] 94 | -------------------------------------------------------------------------------- /tests/test_17_read_registration.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | 4 | from cryptojwt.utils import as_bytes 5 | from oidcmsg.oidc import RegistrationResponse 6 | import pytest 7 | import responses 8 | 9 | from oidcrp.entity import Entity 10 | import requests 11 | 12 | ISS = "https://example.com" 13 | RP_BASEURL = "https://example.com/rp" 14 | 15 | 16 | class TestRegistrationRead(object): 17 | @pytest.fixture(autouse=True) 18 | def create_request(self): 19 | self._iss = ISS 20 | client_config = { 21 | "redirect_uris": ["https://example.com/cli/authz_cb"], 22 | "issuer": self._iss, "requests_dir": "requests", 23 | "base_url": "https://example.com/cli/", 24 | "client_preferences": { 25 | "application_type": "web", 26 | "response_types": ["code"], 27 | "contacts": ["ops@example.org"], 28 | "jwks_uri": "https://example.com/rp/static/jwks.json", 29 | "redirect_uris": ["{}/authz_cb".format(RP_BASEURL)], 30 | "token_endpoint_auth_method": "client_secret_basic", 31 | "grant_types": ["authorization_code"] 32 | } 33 | } 34 | services = { 35 | 'registration': { 36 | 'class': 'oidcrp.oidc.registration.Registration' 37 | }, 38 | 'read_registration': { 39 | 'class': 'oidcrp.oidc.read_registration.RegistrationRead' 40 | } 41 | } 42 | 43 | self.entity = Entity(config=client_config, services=services) 44 | 45 | self.reg_service = self.entity.client_get("service", 'registration') 46 | self.read_service = self.entity.client_get("service", 'registration_read') 47 | 48 | def test_construct(self): 49 | self.reg_service.endpoint = "{}/registration".format(ISS) 50 | 51 | _param = self.reg_service.get_request_parameters() 52 | 53 | now = int(time.time()) 54 | 55 | _client_registration_response = json.dumps({ 56 | "client_id": "zls2qhN1jO6A", 57 | "client_secret": "c8434f28cf9375d9a7", 58 | "registration_access_token": "NdGrGR7LCuzNtixvBFnDphGXv7wRcONn", 59 | "registration_client_uri": "{}/registration_api?client_id=zls2qhN1jO6A".format(ISS), 60 | "client_secret_expires_at": now + 3600, 61 | "client_id_issued_at": now, 62 | "application_type": "web", 63 | "response_types": ["code"], 64 | "contacts": ["ops@example.com"], 65 | "redirect_uris": ["{}/authz_cb".format(RP_BASEURL)], 66 | "token_endpoint_auth_method": "client_secret_basic", 67 | "grant_types": ["authorization_code"] 68 | }) 69 | 70 | with responses.RequestsMock() as rsps: 71 | rsps.add(_param["method"], _param["url"], body=_client_registration_response, 72 | status=200) 73 | _resp = requests.request( 74 | _param["method"], _param["url"], 75 | data=as_bytes(_param["body"]), 76 | headers=_param["headers"], 77 | verify=False 78 | ) 79 | 80 | resp = self.reg_service.parse_response(_resp.text) 81 | self.reg_service.update_service_context(resp) 82 | 83 | assert resp 84 | 85 | _read_param = self.read_service.get_request_parameters() 86 | with responses.RequestsMock() as rsps: 87 | rsps.add(_param["method"], _param["url"], body=_client_registration_response, 88 | adding_headers={"Content-Type": "application/json"}, status=200) 89 | _resp = requests.request( 90 | _param["method"], 91 | _param["url"], 92 | headers=_param["headers"], 93 | verify=False 94 | ) 95 | 96 | read_resp = self.reg_service.parse_response(_resp.text) 97 | assert isinstance(read_resp, RegistrationResponse) 98 | -------------------------------------------------------------------------------- /src/oidcrp/oauth2/add_on/pkce.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from cryptojwt.utils import b64e 4 | from oidcmsg.message import Message 5 | 6 | from oidcrp.defaults import CC_METHOD 7 | from oidcrp.exception import Unsupported 8 | from oidcrp.oauth2.utils import get_state_parameter 9 | from oidcrp.util import unreserved 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def add_code_challenge(request_args, service, **kwargs): 15 | """ 16 | PKCE RFC 7636 support 17 | To be added as a post_construct method to an 18 | :py:class:`oidcrp.oidc.service.Authorization` instance 19 | 20 | :param service: The service that uses this function 21 | :param request_args: Set of request arguments 22 | :param kwargs: Extra set of keyword arguments 23 | :return: Updated set of request arguments 24 | """ 25 | _context = service.client_get("service_context") 26 | _kwargs = _context.add_on["pkce"] 27 | 28 | try: 29 | cv_len = _kwargs['code_challenge_length'] 30 | except KeyError: 31 | cv_len = 64 # Use default 32 | 33 | # code_verifier: string of length cv_len 34 | code_verifier = unreserved(cv_len) 35 | _cv = code_verifier.encode() 36 | 37 | try: 38 | _method = _kwargs['code_challenge_method'] 39 | except KeyError: 40 | _method = 'S256' 41 | 42 | try: 43 | # Pick hash method 44 | _hash_method = CC_METHOD[_method] 45 | # Use it on the code_verifier 46 | _hv = _hash_method(_cv).digest() 47 | # base64 encode the hash value 48 | code_challenge = b64e(_hv).decode('ascii') 49 | except KeyError: 50 | raise Unsupported( 51 | 'PKCE Transformation method:{}'.format(_method)) 52 | 53 | _item = Message(code_verifier=code_verifier, code_challenge_method=_method) 54 | _context.state.store_item(_item, 'pkce', request_args['state']) 55 | 56 | request_args.update( 57 | { 58 | "code_challenge": code_challenge, 59 | "code_challenge_method": _method 60 | }) 61 | return request_args, {} 62 | 63 | 64 | def add_code_verifier(request_args, service, **kwargs): 65 | """ 66 | PKCE RFC 7636 support 67 | To be added as a post_construct method to an 68 | :py:class:`oidcrp.oidc.service.AccessToken` instance 69 | 70 | :param service: The service that uses this function 71 | :param request_args: Set of request arguments 72 | :return: updated set of request arguments 73 | """ 74 | _state = request_args.get('state') 75 | if _state is None: 76 | _state = kwargs.get('state') 77 | _item = service.client_get("service_context").state.get_item(Message, 'pkce', _state) 78 | request_args.update({'code_verifier': _item['code_verifier']}) 79 | return request_args 80 | 81 | 82 | def put_state_in_post_args(request_args, **kwargs): 83 | state = get_state_parameter(request_args, kwargs) 84 | return request_args, {'state': state} 85 | 86 | 87 | def add_support(service, code_challenge_length, code_challenge_method): 88 | """ 89 | PKCE support can only be considered if this client can access authorization and 90 | access token services. 91 | 92 | :param service: Dictionary of services 93 | :param code_challenge_length: 94 | :param code_challenge_method: 95 | :return: 96 | """ 97 | if "authorization" in service and "accesstoken" in service: 98 | _service = service["authorization"] 99 | _context = _service.client_get("service_context") 100 | _context.add_on['pkce'] = { 101 | "code_challenge_length": code_challenge_length, 102 | "code_challenge_method": code_challenge_method 103 | } 104 | 105 | _service.pre_construct.append(add_code_challenge) 106 | 107 | token_service = service['accesstoken'] 108 | token_service.pre_construct.append(put_state_in_post_args) 109 | token_service.post_construct.append(add_code_verifier) 110 | else: 111 | logger.warning("PKCE support could NOT be added") 112 | -------------------------------------------------------------------------------- /src/oidcrp/http.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from http.cookiejar import FileCookieJar 3 | from http.cookies import CookieError 4 | from http.cookies import SimpleCookie 5 | import logging 6 | 7 | import requests 8 | 9 | from oidcrp.exception import NonFatalException 10 | from oidcrp.util import sanitize 11 | from oidcrp.util import set_cookie 12 | 13 | __author__ = 'roland' 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class HTTPLib(object): 19 | def __init__(self, httpc_params=None): 20 | """ 21 | A base class for OAuth2 clients and servers 22 | 23 | :param httpc_params: Default arguments to be used for HTTP requests 24 | """ 25 | 26 | self.request_args = {"allow_redirects": False} 27 | if httpc_params: 28 | self.request_args.update(httpc_params) 29 | 30 | self.cookiejar = FileCookieJar() 31 | 32 | self.events = None 33 | self.req_callback = None 34 | 35 | def _cookies(self): 36 | """ 37 | Return a dictionary of all the cookies I have keyed on cookie name 38 | 39 | :return: Dictionary 40 | """ 41 | cookie_dict = {} 42 | 43 | for _, a in list(self.cookiejar._cookies.items()): 44 | for _, b in list(a.items()): 45 | for cookie in list(b.values()): 46 | cookie_dict[cookie.name] = cookie.value 47 | 48 | return cookie_dict 49 | 50 | def add_cookies(self, kwargs): 51 | if self.cookiejar: 52 | kwargs["cookies"] = self._cookies() 53 | logger.debug("SENT {} COOKIES".format(len(kwargs["cookies"]))) 54 | return kwargs 55 | 56 | def run_req_callback(self, url, method, kwargs): 57 | if self.req_callback is not None: 58 | kwargs = self.req_callback(method, url, **kwargs) 59 | return kwargs 60 | 61 | def set_cookie(self, response): 62 | try: 63 | _cookie = response.headers["set-cookie"] 64 | logger.debug("RECEIVED COOKIE") 65 | try: 66 | # add received cookies to the cookie jar 67 | set_cookie(self.cookiejar, SimpleCookie(_cookie)) 68 | except CookieError as err: 69 | logger.error(err) 70 | raise NonFatalException(response, "{}".format(err)) 71 | except (AttributeError, KeyError) as err: 72 | pass 73 | 74 | def __call__(self, url, method="GET", **kwargs): 75 | """ 76 | Send a HTTP request to a URL using a specified method 77 | 78 | :param url: The URL to access 79 | :param method: The method to use (GET, POST, ..) 80 | :param kwargs: extra HTTP request parameters 81 | :return: A Response 82 | """ 83 | 84 | # copy the default set before starting to modify it. 85 | _kwargs = copy.copy(self.request_args) 86 | if kwargs: 87 | _kwargs.update(kwargs) 88 | 89 | # If I have cookies add them all to the request 90 | self.add_cookies(kwargs) 91 | 92 | # If I want to modify the request arguments based on URL, method 93 | # and current arguments I can use this call back function. 94 | self.run_req_callback(url, method, kwargs) 95 | 96 | try: 97 | # Do the request 98 | r = requests.request(method, url, **_kwargs) 99 | except Exception as err: 100 | logger.error( 101 | "http_request failed: %s, url: %s, htargs: %s, method: %s" % ( 102 | err, url, sanitize(_kwargs), method)) 103 | raise 104 | 105 | if self.events is not None: 106 | self.events.store('HTTP response', r, ref=url) 107 | 108 | self.set_cookie(r) 109 | 110 | # return the response 111 | return r 112 | 113 | def send(self, url, method="GET", **kwargs): 114 | """ 115 | Another name for the send method 116 | 117 | :param url: URL 118 | :param method: HTTP method 119 | :param kwargs: HTTP request argument 120 | :return: Request response 121 | """ 122 | return self(url, method, **kwargs) 123 | -------------------------------------------------------------------------------- /tests/conf.yaml: -------------------------------------------------------------------------------- 1 | logging: 2 | version: 1 3 | disable_existing_loggers: False 4 | root: 5 | handlers: 6 | - console 7 | - file 8 | level: DEBUG 9 | loggers: 10 | idp: 11 | level: DEBUG 12 | handlers: 13 | console: 14 | class: logging.StreamHandler 15 | stream: 'ext://sys.stdout' 16 | formatter: default 17 | file: 18 | class: logging.FileHandler 19 | filename: 'debug.log' 20 | formatter: default 21 | formatters: 22 | default: 23 | format: '%(asctime)s %(name)s %(levelname)s %(message)s' 24 | 25 | port: &port 8090 26 | domain: &domain 127.0.0.1 27 | base_url: "https://{domain}:{port}" 28 | 29 | httpc_params: 30 | # This is just for testing a local usage. In all other cases it MUST be True 31 | verify: false 32 | # Client side 33 | #client_cert: "certs/client.crt" 34 | #client_key: "certs/client.key" 35 | 36 | keydefs: &keydef 37 | - "type": "RSA" 38 | "key": '' 39 | "use": ["sig"] 40 | - "type": "EC" 41 | "crv": "P-256" 42 | "use": ["sig"] 43 | 44 | rp_keys: 45 | 'private_path': 'private/jwks.json' 46 | 'key_defs': *keydef 47 | 'public_path': 'static/jwks.json' 48 | # this will create the jwks files if they are absent 49 | 'read_only': False 50 | 51 | client_preferences: &id001 52 | application_name: rphandler 53 | application_type: web 54 | contacts: 55 | - ops@example.com 56 | response_types: 57 | - code 58 | scope: 59 | - openid 60 | - profile 61 | - email 62 | - address 63 | - phone 64 | token_endpoint_auth_method: 65 | - client_secret_basic 66 | - client_secret_post 67 | 68 | services: &id002 69 | discovery: &disc 70 | class: oidcservice.oidc.provider_info_discovery.ProviderInfoDiscovery 71 | kwargs: {} 72 | registration: ®ist 73 | class: oidcservice.oidc.registration.Registration 74 | kwargs: {} 75 | authorization: &authz 76 | class: oidcservice.oidc.authorization.Authorization 77 | kwargs: {} 78 | accesstoken: &acctok 79 | class: oidcservice.oidc.access_token.AccessToken 80 | kwargs: {} 81 | userinfo: &userinfo 82 | class: oidcservice.oidc.userinfo.UserInfo 83 | kwargs: {} 84 | end_session: &sess 85 | class: oidcservice.oidc.end_session.EndSession 86 | kwargs: {} 87 | 88 | clients: 89 | "": 90 | client_preferences: *id001 91 | redirect_uris: None 92 | services: *id002 93 | flop: 94 | client_preferences: *id001 95 | issuer: https://127.0.0.1:5000/ 96 | redirect_uris: 97 | - 'https://{domain}:{port}/authz_cb/flop' 98 | post_logout_redirect_uris: 99 | - "https://{domain}:{port}/session_logout/flop" 100 | frontchannel_logout_uri: "https://{domain}:{port}/fc_logout/flop" 101 | frontchannel_logout_session_required: True 102 | backchannel_logout_uri: "https://{domain}:{port}/bc_logout/flop" 103 | backchannel_logout_session_required: True 104 | services: 105 | discovery: *disc 106 | registration: *regist 107 | authorization: *authz 108 | accesstoken: *acctok 109 | userinfo: *userinfo 110 | end_session: *sess 111 | add_ons: 112 | pkce: 113 | function: oidcservice.oidc.add_on.pkce.add_pkce_support 114 | kwargs: 115 | code_challenge_length: 64 116 | code_challenge_method: S256 117 | # status_check: 118 | # function: oidcservice.oidc.add_on.status_check.add_status_check_support 119 | # kwargs: 120 | # rp_iframe_path: "templates/rp_iframe.html" 121 | bobcat: 122 | client_id: client3 123 | client_secret: 'abcdefghijklmnop' 124 | client_preferences: *id001 125 | issuer: http://127.0.0.1:8080/ 126 | jwks_uri: 'static/jwks.json' 127 | redirect_uris: ['https://{domain}:{port}/authz_cb/bobcat'] 128 | post_logout_redirect_uris: 129 | - "https://{domain}:{port}/session_logout/bobcat" 130 | services: *id002 131 | request_args: 132 | claims: 133 | id_token: 134 | acr: 135 | essential: 136 | true 137 | 138 | 139 | webserver: 140 | port: *port 141 | domain: *domain 142 | # If BASE is https these has to be specified 143 | server_cert: "certs/cert.pem" 144 | server_key: "certs/key.pem" 145 | # If you want the clients cert to be verified 146 | # verify_user: optional 147 | # The you also need 148 | # ca_bundle: '' 149 | debug: true 150 | -------------------------------------------------------------------------------- /src/oidcrp/configure.py: -------------------------------------------------------------------------------- 1 | """Configuration management for RP""" 2 | import importlib 3 | import json 4 | import logging 5 | import os 6 | from typing import Dict 7 | from typing import List 8 | from typing import Optional 9 | 10 | from oidcmsg.configure import Base 11 | 12 | from oidcrp.logging import configure_logging 13 | from oidcrp.util import load_yaml_config 14 | from oidcrp.util import lower_or_upper 15 | 16 | try: 17 | from secrets import token_urlsafe as rnd_token 18 | except ImportError: 19 | from cryptojwt import rndstr as rnd_token 20 | 21 | URIS = [ 22 | "redirect_uris", 'post_logout_redirect_uris', 'frontchannel_logout_uri', 23 | 'backchannel_logout_uri', 'issuer', 'base_url'] 24 | 25 | 26 | class RPConfiguration(Base): 27 | def __init__(self, 28 | conf: Dict, 29 | base_path: Optional[str] = '', 30 | entity_conf: Optional[List[dict]] = None, 31 | domain: Optional[str] = "127.0.0.1", 32 | port: Optional[int] = 80, 33 | file_attributes: Optional[List[str]] = None, 34 | dir_attributes: Optional[List[str]] = None, 35 | ): 36 | 37 | Base.__init__(self, conf, 38 | base_path=base_path, 39 | domain=domain, 40 | port=port, 41 | file_attributes=file_attributes, 42 | dir_attributes=dir_attributes) 43 | 44 | self.key_conf = lower_or_upper(conf, 'rp_keys') or lower_or_upper(conf, 'oidc_keys') 45 | self.clients = lower_or_upper(conf, "clients") 46 | 47 | hash_seed = lower_or_upper(conf, 'hash_seed') 48 | if not hash_seed: 49 | hash_seed = rnd_token(32) 50 | self.hash_seed = hash_seed 51 | 52 | self.services = lower_or_upper(conf, "services") 53 | self.base_url = lower_or_upper(conf, "base_url") 54 | self.httpc_params = lower_or_upper(conf, "httpc_params", {"verify": True}) 55 | 56 | if entity_conf: 57 | self.extend(entity_conf=entity_conf, conf=conf, base_path=base_path, 58 | file_attributes=file_attributes, domain=domain, port=port) 59 | 60 | 61 | class Configuration(Base): 62 | """RP Configuration""" 63 | 64 | def __init__(self, 65 | conf: Dict, 66 | base_path: str = '', 67 | entity_conf: Optional[List[dict]] = None, 68 | file_attributes: Optional[List[str]] = None, 69 | domain: Optional[str] = "", 70 | port: Optional[int] = 0, 71 | dir_attributes: Optional[List[str]] = None, 72 | ): 73 | Base.__init__(self, conf, base_path=base_path, file_attributes=file_attributes, 74 | dir_attributes=dir_attributes) 75 | 76 | log_conf = conf.get('logging') 77 | if log_conf: 78 | self.logger = configure_logging(config=log_conf).getChild(__name__) 79 | else: 80 | self.logger = logging.getLogger('oidcrp') 81 | 82 | self.web_conf = lower_or_upper(conf, "webserver") 83 | 84 | if entity_conf: 85 | self.extend(entity_conf=entity_conf, conf=conf, base_path=base_path, 86 | file_attributes=file_attributes, domain=domain, port=port, 87 | dir_attributes=dir_attributes) 88 | 89 | 90 | # def create_from_config_file(cls, 91 | # filename: str, 92 | # base_path: Optional[str] = '', 93 | # entity_conf: Optional[List[dict]] = None, 94 | # file_attributes: Optional[List[str]] = None, 95 | # dir_attributes: Optional[List[str]] = None, 96 | # domain: Optional[str] = "", 97 | # port: Optional[int] = 0): 98 | # if filename.endswith(".yaml"): 99 | # """Load configuration as YAML""" 100 | # _cnf = load_yaml_config(filename) 101 | # elif filename.endswith(".json"): 102 | # _str = open(filename).read() 103 | # _cnf = json.loads(_str) 104 | # elif filename.endswith(".py"): 105 | # head, tail = os.path.split(filename) 106 | # tail = tail[:-3] 107 | # module = importlib.import_module(tail) 108 | # _cnf = getattr(module, "CONFIG") 109 | # else: 110 | # raise ValueError("Unknown file type") 111 | # 112 | # return cls(_cnf, 113 | # entity_conf=entity_conf, 114 | # base_path=base_path, file_attributes=file_attributes, 115 | # domain=domain, port=port, dir_attributes=dir_attributes) 116 | -------------------------------------------------------------------------------- /tests/test_31_oauth2_persistent.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | 5 | import pytest 6 | from cryptojwt.jwk.rsa import import_private_rsa_key_from_file 7 | from cryptojwt.key_bundle import KeyBundle 8 | from oidcmsg.oauth2 import AccessTokenRequest 9 | from oidcmsg.oauth2 import AccessTokenResponse 10 | from oidcmsg.oauth2 import AuthorizationRequest 11 | from oidcmsg.oauth2 import AuthorizationResponse 12 | from oidcmsg.oauth2 import RefreshAccessTokenRequest 13 | from oidcmsg.oauth2 import ResponseMessage 14 | from oidcmsg.oidc import IdToken 15 | from oidcmsg.time_util import utc_time_sans_frac 16 | from oidcrp.exception import OidcServiceError 17 | from oidcrp.exception import ParseError 18 | 19 | from oidcrp.oauth2 import Client 20 | 21 | sys.path.insert(0, '.') 22 | 23 | _dirname = os.path.dirname(os.path.abspath(__file__)) 24 | BASE_PATH = os.path.join(_dirname, "data", "keys") 25 | 26 | _key = import_private_rsa_key_from_file(os.path.join(BASE_PATH, "rsa.key")) 27 | KC_RSA = KeyBundle({"priv_key": _key, "kty": "RSA", "use": "sig"}) 28 | 29 | CLIENT_ID = "client_1" 30 | IDTOKEN = IdToken(iss="http://oidc.example.org/", sub="sub", 31 | aud=CLIENT_ID, exp=utc_time_sans_frac() + 86400, 32 | nonce="N0nce", 33 | iat=time.time()) 34 | 35 | CONF = { 36 | 'issuer': 'https://op.example.com', 37 | 'redirect_uris': ['https://example.com/cli/authz_cb'], 38 | 'client_id': CLIENT_ID, 39 | 'client_secret': 'abcdefghijklmnop' 40 | } 41 | 42 | 43 | class MockResponse(): 44 | def __init__(self, status_code, text, headers=None): 45 | self.status_code = status_code 46 | self.text = text 47 | self.headers = headers or {} 48 | self.url = '' 49 | 50 | 51 | class TestClient(object): 52 | def test_construct_accesstoken_request(self): 53 | # Client 1 starts the chain of event 54 | client_1 = Client(config=CONF) 55 | _context_1 = client_1.client_get("service_context") 56 | _state = _context_1.state.create_state('issuer') 57 | 58 | auth_request = AuthorizationRequest( 59 | redirect_uri='https://example.com/cli/authz_cb', 60 | state=_state 61 | ) 62 | 63 | _context_1.state.store_item(auth_request, 'auth_request', _state) 64 | 65 | # Client 2 carries on 66 | client_2 = Client(config=CONF) 67 | _state_dump = _context_1.dump() 68 | 69 | _context2 = client_2.client_get("service_context") 70 | _context2.load(_state_dump) 71 | 72 | auth_response = AuthorizationResponse(code='access_code') 73 | _context2.state.store_item(auth_response,'auth_response', _state) 74 | 75 | msg = client_2.client_get("service",'accesstoken').construct(request_args={}, state=_state) 76 | 77 | assert isinstance(msg, AccessTokenRequest) 78 | assert msg.to_dict() == { 79 | 'client_id': 'client_1', 80 | 'code': 'access_code', 81 | 'client_secret': 'abcdefghijklmnop', 82 | 'grant_type': 'authorization_code', 83 | 'redirect_uri': 84 | 'https://example.com/cli/authz_cb', 85 | 'state': _state 86 | } 87 | 88 | def test_construct_refresh_token_request(self): 89 | # Client 1 starts the chain event 90 | client_1 = Client(config=CONF) 91 | _state = client_1.client_get("service_context").state.create_state('issuer') 92 | 93 | auth_request = AuthorizationRequest( 94 | redirect_uri='https://example.com/cli/authz_cb', 95 | state=_state 96 | ) 97 | 98 | client_1.client_get("service_context").state.store_item(auth_request, 'auth_request', _state) 99 | 100 | # Client 2 carries on 101 | client_2 = Client(config=CONF) 102 | _state_dump = client_1.client_get("service_context").dump() 103 | client_2.client_get("service_context").load(_state_dump) 104 | 105 | auth_response = AuthorizationResponse(code='access_code') 106 | client_2.client_get("service_context").state.store_item(auth_response, 'auth_response', _state) 107 | 108 | token_response = AccessTokenResponse(refresh_token="refresh_with_me", 109 | access_token="access") 110 | 111 | client_2.client_get("service_context").state.store_item(token_response, 'token_response', _state) 112 | 113 | # Next up is Client 1 114 | _state_dump = client_2.client_get("service_context").dump() 115 | client_1.client_get("service_context").load(_state_dump) 116 | 117 | req_args = {} 118 | msg = client_1.client_get("service",'refresh_token').construct(request_args=req_args, state=_state) 119 | assert isinstance(msg, RefreshAccessTokenRequest) 120 | assert msg.to_dict() == { 121 | 'client_id': 'client_1', 122 | 'client_secret': 'abcdefghijklmnop', 123 | 'grant_type': 'refresh_token', 124 | 'refresh_token': 'refresh_with_me' 125 | } 126 | -------------------------------------------------------------------------------- /src/oidcrp/oauth2/provider_info_discovery.py: -------------------------------------------------------------------------------- 1 | """The service that talks to the OAuth2 provider info discovery endpoint.""" 2 | import logging 3 | 4 | from cryptojwt.key_jar import KeyJar 5 | from oidcmsg import oauth2 6 | from oidcmsg.oauth2 import ResponseMessage 7 | 8 | from oidcrp.defaults import OIDCONF_PATTERN 9 | from oidcrp.exception import OidcServiceError 10 | from oidcrp.service import Service 11 | 12 | LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class ProviderInfoDiscovery(Service): 16 | """The service that talks to the OAuth2 provider info discovery endpoint.""" 17 | msg_type = oauth2.Message 18 | response_cls = oauth2.ASConfigurationResponse 19 | error_msg = ResponseMessage 20 | synchronous = True 21 | service_name = 'provider_info' 22 | http_method = 'GET' 23 | 24 | def __init__(self, client_get, client_authn_factory=None, conf=None): 25 | Service.__init__(self, client_get, 26 | client_authn_factory=client_authn_factory, conf=conf) 27 | 28 | def get_endpoint(self): 29 | """ 30 | Find the issuer ID and from it construct the service endpoint 31 | 32 | :return: Service endpoint 33 | """ 34 | try: 35 | _iss = self.client_get("service_context").issuer 36 | except AttributeError: 37 | _iss = self.endpoint 38 | 39 | if _iss.endswith('/'): 40 | return OIDCONF_PATTERN.format(_iss[:-1]) 41 | 42 | return OIDCONF_PATTERN.format(_iss) 43 | 44 | def get_request_parameters(self, method="GET", **kwargs): 45 | """ 46 | The Provider info discovery version of get_request_parameters() 47 | 48 | :param method: 49 | :param kwargs: 50 | :return: 51 | """ 52 | return {'url': self.get_endpoint(), 'method': method} 53 | 54 | def _verify_issuer(self, resp, issuer): 55 | _pcr_issuer = resp["issuer"] 56 | if resp["issuer"].endswith("/"): 57 | if issuer.endswith("/"): 58 | _issuer = issuer 59 | else: 60 | _issuer = issuer + "/" 61 | else: 62 | if issuer.endswith("/"): 63 | _issuer = issuer[:-1] 64 | else: 65 | _issuer = issuer 66 | 67 | # In some cases we can live with the two URLs not being 68 | # the same. But this is an excepted that has to be explicit 69 | try: 70 | self.client_get("service_context").allow['issuer_mismatch'] 71 | except KeyError: 72 | if _issuer != _pcr_issuer: 73 | raise OidcServiceError( 74 | "provider info issuer mismatch '%s' != '%s'" % ( 75 | _issuer, _pcr_issuer)) 76 | return _issuer 77 | 78 | def _set_endpoints(self, resp): 79 | """ 80 | If there are services defined set the service endpoint to be 81 | the URLs specified in the provider information.""" 82 | for key, val in resp.items(): 83 | # All service endpoint parameters in the provider info has 84 | # a name ending in '_endpoint' so I can look specifically 85 | # for those 86 | if key.endswith("_endpoint"): 87 | _srv = self.client_get("service_by_endpoint_name", key) 88 | if _srv: 89 | _srv.endpoint = val 90 | 91 | def _update_service_context(self, resp): 92 | """ 93 | Deal with Provider Config Response. Based on the provider info 94 | response a set of parameters in different places needs to be set. 95 | 96 | :param resp: The provider info response 97 | :param service_context: Information collected/used by services 98 | """ 99 | 100 | _context = self.client_get("service_context") 101 | # Verify that the issuer value received is the same as the 102 | # url that was used as service endpoint (without the .well-known part) 103 | if "issuer" in resp: 104 | _pcr_issuer = self._verify_issuer(resp, _context.issuer) 105 | else: # No prior knowledge 106 | _pcr_issuer = _context.issuer 107 | 108 | _context.issuer = _pcr_issuer 109 | _context.provider_info = resp 110 | 111 | self._set_endpoints(resp) 112 | 113 | # If I already have a Key Jar then I'll add then provider keys to 114 | # that. Otherwise a new Key Jar is minted 115 | try: 116 | _keyjar = _context.keyjar 117 | except KeyError: 118 | _keyjar = KeyJar() 119 | 120 | # Load the keys. Note that this only means that the key specification 121 | # is loaded not necessarily that any keys are fetched. 122 | if 'jwks_uri' in resp: 123 | _keyjar.load_keys(_pcr_issuer, jwks_uri=resp['jwks_uri']) 124 | elif 'jwks' in resp: 125 | _keyjar.load_keys(_pcr_issuer, jwks=resp['jwks']) 126 | 127 | _context.keyjar = _keyjar 128 | 129 | def update_service_context(self, resp, **kwargs): 130 | return self._update_service_context(resp) 131 | -------------------------------------------------------------------------------- /src/oidcrp/oidc/userinfo.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | from typing import Union 4 | 5 | from oidcmsg import oidc 6 | from oidcmsg.exception import MissingSigningKey 7 | from oidcmsg.message import Message 8 | 9 | from oidcrp.oauth2.utils import get_state_parameter 10 | from oidcrp.service import Service 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | UI2REG = { 15 | 'sigalg': 'userinfo_signed_response_alg', 16 | 'encalg': 'userinfo_encrypted_response_alg', 17 | 'encenc': 'userinfo_encrypted_response_enc' 18 | } 19 | 20 | 21 | def carry_state(request_args=None, **kwargs): 22 | """ 23 | Make sure post_construct_methods have access to state 24 | 25 | :param request_args: 26 | :param kwargs: 27 | :return: The value of the state parameter 28 | """ 29 | return request_args, {'state': get_state_parameter(request_args, kwargs)} 30 | 31 | 32 | class UserInfo(Service): 33 | msg_type = Message 34 | response_cls = oidc.OpenIDSchema 35 | error_msg = oidc.ResponseMessage 36 | endpoint_name = 'userinfo_endpoint' 37 | synchronous = True 38 | service_name = 'userinfo' 39 | default_authn_method = 'bearer_header' 40 | http_method = 'GET' 41 | 42 | def __init__(self, client_get, client_authn_factory=None, conf=None): 43 | Service.__init__(self, client_get, 44 | client_authn_factory=client_authn_factory, 45 | conf=conf) 46 | self.pre_construct = [self.oidc_pre_construct, carry_state] 47 | 48 | def oidc_pre_construct(self, request_args=None, **kwargs): 49 | if request_args is None: 50 | request_args = {} 51 | 52 | if "access_token" in request_args: 53 | pass 54 | else: 55 | request_args = self.client_get("service_context").state.multiple_extend_request_args( 56 | request_args, kwargs['state'], ['access_token'], 57 | ['auth_response', 'token_response', 'refresh_token_response'] 58 | ) 59 | 60 | return request_args, {} 61 | 62 | def post_parse_response(self, response, **kwargs): 63 | _context = self.client_get("service_context") 64 | _state_interface = _context.state 65 | _args = _state_interface.multiple_extend_request_args( 66 | {}, kwargs['state'], ['id_token'], 67 | ['auth_response', 'token_response', 'refresh_token_response'] 68 | ) 69 | 70 | try: 71 | _sub = _args['id_token']['sub'] 72 | except KeyError: 73 | logger.warning("Can not verify value on sub") 74 | else: 75 | if response['sub'] != _sub: 76 | raise ValueError('Incorrect "sub" value') 77 | 78 | try: 79 | _csrc = response["_claim_sources"] 80 | except KeyError: 81 | pass 82 | else: 83 | for csrc, spec in _csrc.items(): 84 | if "JWT" in spec: 85 | try: 86 | aggregated_claims = Message().from_jwt( 87 | spec["JWT"].encode("utf-8"), 88 | keyjar=_context.keyjar) 89 | except MissingSigningKey as err: 90 | logger.warning( 91 | 'Error encountered while unpacking aggregated ' 92 | 'claims'.format(err)) 93 | else: 94 | claims = [value for value, src in 95 | response["_claim_names"].items() if 96 | src == csrc] 97 | 98 | for key in claims: 99 | response[key] = aggregated_claims[key] 100 | elif 'endpoint' in spec: 101 | _info = { 102 | "headers": self.get_authn_header( 103 | {}, self.default_authn_method, 104 | authn_endpoint=self.endpoint_name, 105 | key=kwargs["state"] 106 | ), 107 | "url": spec["endpoint"] 108 | } 109 | 110 | # Extension point 111 | for meth in self.post_parse_process: 112 | response = meth(response, _state_interface, kwargs['state']) 113 | 114 | _state_interface.store_item(response, 'user_info', kwargs['state']) 115 | return response 116 | 117 | def gather_verify_arguments(self, 118 | response: Optional[Union[dict, Message]] = None, 119 | behaviour_args: Optional[dict] = None): 120 | """ 121 | Need to add some information before running verify() 122 | 123 | :return: dictionary with arguments to the verify call 124 | """ 125 | _context = self.client_get("service_context") 126 | kwargs = { 127 | 'client_id': _context.client_id, 128 | 'iss': _context.issuer, 129 | 'keyjar': _context.keyjar, 'verify': True, 130 | 'skew': _context.clock_skew 131 | } 132 | 133 | _reg_resp = _context.registration_response 134 | if _reg_resp: 135 | for attr, param in UI2REG.items(): 136 | try: 137 | kwargs[attr] = _reg_resp[param] 138 | except KeyError: 139 | pass 140 | 141 | try: 142 | kwargs['allow_missing_kid'] = _context.allow['missing_kid'] 143 | except KeyError: 144 | pass 145 | 146 | return kwargs 147 | 148 | -------------------------------------------------------------------------------- /tests/test_14_pkce.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from cryptojwt.key_jar import init_key_jar 4 | from oidcmsg.message import Message 5 | from oidcmsg.message import SINGLE_REQUIRED_STRING 6 | from oidcmsg.oauth2 import AuthorizationResponse 7 | import pytest 8 | 9 | from oidcrp.entity import Entity 10 | from oidcrp.oauth2 import DEFAULT_OAUTH2_SERVICES 11 | from oidcrp.oauth2.add_on import do_add_ons 12 | from oidcrp.oauth2.add_on.pkce import add_code_challenge 13 | from oidcrp.oauth2.add_on.pkce import add_code_verifier 14 | from oidcrp.service import Service 15 | 16 | 17 | class DummyMessage(Message): 18 | c_param = { 19 | "req_str": SINGLE_REQUIRED_STRING, 20 | } 21 | 22 | 23 | class DummyService(Service): 24 | msg_type = DummyMessage 25 | 26 | 27 | _dirname = os.path.dirname(os.path.abspath(__file__)) 28 | 29 | ISS = 'https://example.com' 30 | 31 | KEYSPEC = [ 32 | {"type": "RSA", "use": ["sig"]}, 33 | {"type": "EC", "crv": "P-256", "use": ["sig"]}, 34 | ] 35 | 36 | CLI_KEY = init_key_jar(public_path='{}/pub_client.jwks'.format(_dirname), 37 | private_path='{}/priv_client.jwks'.format(_dirname), 38 | key_defs=KEYSPEC, issuer_id='client_id') 39 | 40 | 41 | class TestPKCE256: 42 | @pytest.fixture(autouse=True) 43 | def create_client(self): 44 | config = { 45 | 'client_id': 'client_id', 46 | 'client_secret': 'a longesh password', 47 | 'redirect_uris': ['https://example.com/cli/authz_cb'], 48 | 'behaviour': {'response_types': ['code']}, 49 | 'add_ons': { 50 | "pkce": { 51 | "function": "oidcrp.oauth2.add_on.pkce.add_support", 52 | "kwargs": { 53 | "code_challenge_length": 64, 54 | "code_challenge_method": "S256" 55 | } 56 | } 57 | } 58 | } 59 | self.entity = Entity(keyjar=CLI_KEY, config=config, services=DEFAULT_OAUTH2_SERVICES) 60 | 61 | if 'add_ons' in config: 62 | do_add_ons(config['add_ons'], self.entity.client_get("services")) 63 | 64 | def test_add_code_challenge_default_values(self): 65 | auth_serv = self.entity.client_get("service","authorization") 66 | _state_key = self.entity.client_get("service_context").state.create_state(iss="Issuer") 67 | request_args, _ = add_code_challenge({'state': _state_key}, auth_serv) 68 | 69 | # default values are length:64 method:S256 70 | assert set(request_args.keys()) == {'code_challenge', 'code_challenge_method', 71 | 'state'} 72 | assert request_args['code_challenge_method'] == 'S256' 73 | 74 | request_args = add_code_verifier({}, auth_serv, state=_state_key) 75 | assert len(request_args['code_verifier']) == 64 76 | 77 | def test_authorization_and_pkce(self): 78 | auth_serv = self.entity.client_get("service","authorization") 79 | _state = self.entity.client_get("service_context").state.create_state(iss='Issuer') 80 | 81 | request = auth_serv.construct_request({"state": _state, "response_type": "code"}) 82 | assert set(request.keys()) == {'client_id', 'code_challenge', 83 | 'code_challenge_method', 'state', 84 | 'redirect_uri', 'response_type'} 85 | 86 | def test_access_token_and_pkce(self): 87 | authz_service = self.entity.client_get("service","authorization") 88 | request = authz_service.construct_request({"state": 'state', "response_type": "code"}) 89 | _state = request['state'] 90 | auth_response = AuthorizationResponse(code='access code') 91 | self.entity.client_get("service_context").state.store_item(auth_response, 'auth_response', _state) 92 | 93 | token_service = self.entity.client_get("service","accesstoken") 94 | request = token_service.construct_request(state=_state) 95 | assert set(request.keys()) == {'client_id', 'redirect_uri', 'grant_type', 96 | 'client_secret', 'code_verifier', 'code', 97 | 'state'} 98 | 99 | 100 | class TestPKCE384: 101 | @pytest.fixture(autouse=True) 102 | def create_client(self): 103 | config = { 104 | 'client_id': 'client_id', 'client_secret': 'a longesh password', 105 | 'redirect_uris': ['https://example.com/cli/authz_cb'], 106 | 'add_ons': { 107 | "pkce": { 108 | "function": "oidcrp.oauth2.add_on.pkce.add_support", 109 | "kwargs": { 110 | "code_challenge_length": 128, 111 | "code_challenge_method": "S384" 112 | } 113 | } 114 | } 115 | } 116 | self.entity = Entity(keyjar=CLI_KEY, config=config, services=DEFAULT_OAUTH2_SERVICES) 117 | if 'add_ons' in config: 118 | do_add_ons(config['add_ons'], self.entity.client_get("services")) 119 | 120 | def test_add_code_challenge_spec_values(self): 121 | auth_serv = self.entity.client_get("service","authorization") 122 | request_args, _ = add_code_challenge({'state': 'state'}, auth_serv) 123 | assert set(request_args.keys()) == {'code_challenge', 'code_challenge_method', 124 | 'state'} 125 | assert request_args['code_challenge_method'] == 'S384' 126 | 127 | request_args = add_code_verifier({}, auth_serv, state='state') 128 | assert len(request_args['code_verifier']) == 128 129 | -------------------------------------------------------------------------------- /src/oidcrp/oauth2/add_on/dpop.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | import uuid 3 | 4 | from cryptojwt.jwk.jwk import key_from_jwk_dict 5 | from cryptojwt.jws.jws import JWS 6 | from cryptojwt.jws.jws import factory 7 | from cryptojwt.key_bundle import key_by_alg 8 | from oidcmsg.message import Message 9 | from oidcmsg.message import SINGLE_REQUIRED_INT 10 | from oidcmsg.message import SINGLE_REQUIRED_JSON 11 | from oidcmsg.message import SINGLE_REQUIRED_STRING 12 | from oidcmsg.time_util import utc_time_sans_frac 13 | 14 | from oidcrp.service_context import ServiceContext 15 | 16 | 17 | class DPoPProof(Message): 18 | c_param = { 19 | # header 20 | "typ": SINGLE_REQUIRED_STRING, 21 | "alg": SINGLE_REQUIRED_STRING, 22 | "jwk": SINGLE_REQUIRED_JSON, 23 | # body 24 | "jti": SINGLE_REQUIRED_STRING, 25 | "htm": SINGLE_REQUIRED_STRING, 26 | "htu": SINGLE_REQUIRED_STRING, 27 | "iat": SINGLE_REQUIRED_INT 28 | } 29 | header_params = {"typ", "alg", "jwk"} 30 | body_params = {"jti", "htm", "htu", "iat"} 31 | 32 | def __init__(self, set_defaults=True, **kwargs): 33 | self.key = None 34 | Message.__init__(self, set_defaults=set_defaults, **kwargs) 35 | 36 | if self.key: 37 | pass 38 | elif "jwk" in self: 39 | self.key = key_from_jwk_dict(self["jwk"]) 40 | self.key.deserialize() 41 | 42 | def from_dict(self, dictionary, **kwargs): 43 | Message.from_dict(self, dictionary, **kwargs) 44 | 45 | if "jwk" in self: 46 | self.key = key_from_jwk_dict(self["jwk"]) 47 | self.key.deserialize() 48 | 49 | return self 50 | 51 | def verify(self, **kwargs): 52 | Message.verify(self, **kwargs) 53 | if self["typ"] != "dpop+jwt": 54 | raise ValueError("Wrong type") 55 | if self["alg"] == "none": 56 | raise ValueError("'none' is not allowed as signing algorithm") 57 | 58 | def create_header(self) -> str: 59 | payload = {k: self[k] for k in self.body_params} 60 | _jws = JWS(payload, alg=self["alg"]) 61 | _jws_headers = {k: self[k] for k in self.header_params} 62 | _signed_jwt = _jws.sign_compact(keys=[self.key], **_jws_headers) 63 | return _signed_jwt 64 | 65 | def verify_header(self, dpop_header) -> Optional["DPoPProof"]: 66 | _jws = factory(dpop_header) 67 | if _jws: 68 | _jwt = _jws.jwt 69 | if "jwk" in _jwt.headers: 70 | _pub_key = key_from_jwk_dict(_jwt.headers["jwk"]) 71 | _pub_key.deserialize() 72 | _info = _jws.verify_compact(keys=[_pub_key], sigalg=_jwt.headers["alg"]) 73 | for k, v in _jwt.headers.items(): 74 | self[k] = v 75 | 76 | for k, v in _info.items(): 77 | self[k] = v 78 | else: 79 | raise Exception() 80 | 81 | return self 82 | else: 83 | return None 84 | 85 | 86 | def dpop_header(service_context: ServiceContext, 87 | service_endpoint: str, 88 | http_method: str, 89 | headers: Optional[dict] = None, 90 | **kwargs) -> dict: 91 | """ 92 | 93 | :param service_context: 94 | :param service_endpoint: 95 | :param http_method: 96 | :param headers: 97 | :param kwargs: 98 | :return: 99 | """ 100 | 101 | provider_info = service_context.provider_info 102 | dpop_key = service_context.add_on['dpop'].get('key') 103 | 104 | if not dpop_key: 105 | algs_supported = provider_info["dpop_signing_alg_values_supported"] 106 | if not algs_supported: # does not support DPoP 107 | return headers 108 | 109 | chosen_alg = '' 110 | for alg in service_context.add_on['dpop']["sign_algs"]: 111 | if alg in algs_supported: 112 | chosen_alg = alg 113 | break 114 | 115 | if not chosen_alg: 116 | return headers 117 | 118 | # Mint a new key 119 | dpop_key = key_by_alg(chosen_alg) 120 | service_context.add_on['dpop']['key'] = dpop_key 121 | service_context.add_on['dpop']['alg'] = chosen_alg 122 | 123 | header_dict = { 124 | "typ": "dpop+jwt", 125 | "alg": service_context.add_on['dpop']['alg'], 126 | "jwk": dpop_key.serialize(), 127 | "jti": uuid.uuid4().hex, 128 | "htm": http_method, 129 | "htu": provider_info[service_endpoint], 130 | "iat": utc_time_sans_frac() 131 | } 132 | 133 | _dpop = DPoPProof(**header_dict) 134 | _dpop.key = dpop_key 135 | jws = _dpop.create_header() 136 | 137 | if headers is None: 138 | headers = {"dpop": jws} 139 | else: 140 | headers["dpop"] = jws 141 | 142 | return headers 143 | 144 | 145 | def add_support(services, signing_algorithms): 146 | """ 147 | Add the necessary pieces to make pushed authorization happen. 148 | 149 | :param services: A dictionary with all the services the client has access to. 150 | :param signing_algorithms: Allowed signing algorithms, there is no default algorithms 151 | """ 152 | 153 | # Access token request should use DPoP header 154 | _service = services["accesstoken"] 155 | _context = _service.client_get("service_context") 156 | _context.add_on['dpop'] = { 157 | # "key": key_by_alg(signing_algorithm), 158 | "sign_algs": signing_algorithms 159 | } 160 | _service.construct_extra_headers.append(dpop_header) 161 | 162 | # The same for userinfo requests 163 | _userinfo_service = services.get("userinfo") 164 | if _userinfo_service: 165 | _userinfo_service.construct_extra_headers.append(dpop_header) -------------------------------------------------------------------------------- /tests/test_02_cookie.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from http.cookies import SimpleCookie 3 | 4 | import pytest 5 | 6 | from oidcrp.cookie import CookieDealer 7 | from oidcrp.cookie import InvalidCookieSign 8 | from oidcrp.cookie import cookie_parts 9 | from oidcrp.cookie import cookie_signature 10 | from oidcrp.cookie import make_cookie 11 | from oidcrp.cookie import parse_cookie 12 | from oidcrp.cookie import verify_cookie_signature 13 | from oidcrp.exception import ImproperlyConfigured 14 | 15 | __author__ = 'roland' 16 | 17 | 18 | @pytest.fixture 19 | def cookie_dealer(): 20 | class DummyServer(): 21 | def __init__(self): 22 | self.symkey = b"0123456789012345" 23 | 24 | return CookieDealer(DummyServer()) 25 | 26 | 27 | class TestCookieDealer(object): 28 | def test_create_cookie_value(self, cookie_dealer): 29 | cookie_value = "Something to pass along" 30 | cookie_typ = "sso" 31 | cookie_name = "Foobar" 32 | 33 | kaka = cookie_dealer.create_cookie(cookie_value, cookie_typ, 34 | cookie_name) 35 | value, timestamp, typ = cookie_dealer.get_cookie_value(kaka[1], 36 | "Foobar") 37 | 38 | assert (value, typ) == (cookie_value, cookie_typ) 39 | 40 | def test_delete_cookie(self, cookie_dealer): 41 | cookie_name = "Foobar" 42 | kaka = cookie_dealer.delete_cookie(cookie_name) 43 | cookie_expiration = kaka[1].split(";")[1].split("=")[1] 44 | 45 | now = datetime.datetime.utcnow() # 46 | cookie_timestamp = datetime.datetime.strptime( 47 | cookie_expiration, "%a, %d-%b-%Y %H:%M:%S GMT") 48 | assert cookie_timestamp < now 49 | 50 | def test_cookie_dealer_improperly_configured(self): 51 | class BadServer(): 52 | def __init__(self): 53 | self.symkey = "" 54 | 55 | with pytest.raises(ImproperlyConfigured): 56 | CookieDealer(BadServer()) 57 | 58 | def test_cookie_dealer_with_domain(self): 59 | class DomServer(): 60 | def __init__(self): 61 | self.symkey = b"0123456789012345" 62 | self.cookie_domain = "op.example.org" 63 | 64 | cookie_dealer = CookieDealer(DomServer()) 65 | 66 | cookie_value = "Something to pass along" 67 | cookie_typ = "sso" 68 | cookie_name = "Foobar" 69 | 70 | kaka = cookie_dealer.create_cookie(cookie_value, cookie_typ, 71 | cookie_name) 72 | C = SimpleCookie() 73 | C.load(kaka[1]) 74 | 75 | assert C[cookie_name]["domain"] == "op.example.org" 76 | 77 | def test_cookie_dealer_with_path(self): 78 | class DomServer(): 79 | def __init__(self): 80 | self.symkey = b"0123456789012345" 81 | self.cookie_path = "/oidc" 82 | 83 | cookie_dealer = CookieDealer(DomServer()) 84 | 85 | cookie_value = "Something to pass along" 86 | cookie_typ = "sso" 87 | cookie_name = "Foobar" 88 | 89 | kaka = cookie_dealer.create_cookie(cookie_value, cookie_typ, 90 | cookie_name) 91 | C = SimpleCookie() 92 | C.load(kaka[1]) 93 | 94 | assert C[cookie_name]["path"] == "/oidc" 95 | 96 | 97 | def test_cookie_signature(): 98 | key = b'1234567890abcdef' 99 | parts = ['abc', 'def'] 100 | sig = cookie_signature(key, *parts) 101 | assert verify_cookie_signature(sig, key, *parts) 102 | 103 | 104 | def test_broken_cookie_signature(): 105 | key = b'1234567890abcdef' 106 | parts = ['abc', 'def'] 107 | sig = cookie_signature(key, *parts) 108 | parts.reverse() 109 | assert not verify_cookie_signature(sig, key, *parts) 110 | 111 | 112 | def test_parse_cookie(): 113 | kaka = ('pyoidc=bjmc::1463043535::upm|' 114 | '1463043535|18a201305fa15a96ce4048e1fbb03f7715f86499') 115 | seed = b'' 116 | name = 'pyoidc' 117 | result = parse_cookie(name, seed, kaka) 118 | assert result == ('bjmc::1463043535::upm', '1463043535') 119 | 120 | 121 | def test_parse_manipulated_cookie_payload(): 122 | kaka = ('pyoidc=bjmc::1463043536::upm|' 123 | '1463043535|18a201305fa15a96ce4048e1fbb03f7715f86499') 124 | seed = b'' 125 | name = 'pyoidc' 126 | with pytest.raises(InvalidCookieSign): 127 | parse_cookie(name, seed, kaka) 128 | 129 | 130 | def test_parse_manipulated_cookie_timestamp(): 131 | kaka = ('pyoidc=bjmc::1463043535::upm|' 132 | '1463043537|18a201305fa15a96ce4048e1fbb03f7715f86499') 133 | seed = b'' 134 | name = 'pyoidc' 135 | with pytest.raises(InvalidCookieSign): 136 | parse_cookie(name, seed, kaka) 137 | 138 | 139 | def test_cookie_parts(): 140 | name = 'pyoidc' 141 | kaka = ('pyoidc=bjmc::1463043535::upm|' 142 | '1463043535|18a201305fa15a96ce4048e1fbb03f7715f86499') 143 | result = cookie_parts(name, kaka) 144 | assert result == ['bjmc::1463043535::upm', 145 | '1463043535', 146 | '18a201305fa15a96ce4048e1fbb03f7715f86499'] 147 | 148 | 149 | def test_cookie_default(): 150 | kaka = make_cookie('test', "data", b"1234567890abcdefg") 151 | assert "Secure" in kaka[1] 152 | assert "HttpOnly" in kaka[1] 153 | assert "SameSite" not in kaka[1] 154 | 155 | 156 | def test_cookie_http_only_false(): 157 | kaka = make_cookie('test', "data", b"1234567890abcdefg", http_only=False) 158 | assert "Secure" in kaka[1] 159 | assert "HttpOnly" not in kaka[1] 160 | 161 | 162 | def test_cookie_not_secure(): 163 | kaka = make_cookie('test', "data", b"1234567890abcdefg", secure=False) 164 | assert "Secure" not in kaka[1] 165 | assert "HttpOnly" in kaka[1] 166 | 167 | 168 | def test_cookie_same_site_none(): 169 | kaka = make_cookie('test', "data", b"1234567890abcdefg", same_site="None") 170 | assert "SameSite=None" in kaka[1] 171 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # oidcrp documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Feb 23 13:32:06 2018. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = ['sphinx.ext.autodoc', 35 | 'sphinx.ext.intersphinx', 36 | 'sphinx.ext.coverage', 37 | 'sphinx.ext.viewcode', 38 | 'sphinx.ext.githubpages'] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix(es) of source filenames. 44 | # You can specify multiple suffix as a list of string: 45 | # 46 | # source_suffix = ['.rst', '.md'] 47 | source_suffix = '.rst' 48 | 49 | # The master toctree document. 50 | master_doc = 'index' 51 | 52 | # General information about the project. 53 | project = 'oidcrp' 54 | copyright = '2018, Roland Hedberg' 55 | author = 'Roland Hedberg' 56 | 57 | # The version info for the project you're documenting, acts as replacement for 58 | # |version| and |release|, also used in various other places throughout the 59 | # built documents. 60 | # 61 | # The short X.Y version. 62 | version = '0.1.0' 63 | # The full version, including alpha/beta/rc tags. 64 | release = '0.1.0' 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | # 69 | # This is also used if you do content translation via gettext catalogs. 70 | # Usually you set "language" from the command line for these cases. 71 | language = None 72 | 73 | # List of patterns, relative to source directory, that match files and 74 | # directories to ignore when looking for source files. 75 | # This patterns also effect to html_static_path and html_extra_path 76 | exclude_patterns = [] 77 | 78 | # The name of the Pygments (syntax highlighting) style to use. 79 | pygments_style = 'sphinx' 80 | 81 | # If true, `todo` and `todoList` produce output, else they produce nothing. 82 | todo_include_todos = False 83 | 84 | 85 | # -- Options for HTML output ---------------------------------------------- 86 | 87 | # The theme to use for HTML and HTML Help pages. See the documentation for 88 | # a list of builtin themes. 89 | # 90 | html_theme = 'alabaster' 91 | 92 | # Theme options are theme-specific and customize the look and feel of a theme 93 | # further. For a list of options available for each theme, see the 94 | # documentation. 95 | # 96 | # html_theme_options = {} 97 | 98 | # Add any paths that contain custom static files (such as style sheets) here, 99 | # relative to this directory. They are copied after the builtin static files, 100 | # so a file named "default.css" will overwrite the builtin "default.css". 101 | html_static_path = ['_static'] 102 | 103 | # Custom sidebar templates, must be a dictionary that maps document names 104 | # to template names. 105 | # 106 | # This is required for the alabaster theme 107 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 108 | html_sidebars = { 109 | '**': [ 110 | 'relations.html', # needs 'show_related': True theme option to display 111 | 'searchbox.html', 112 | ] 113 | } 114 | 115 | 116 | # -- Options for HTMLHelp output ------------------------------------------ 117 | 118 | # Output file base name for HTML help builder. 119 | htmlhelp_basename = 'oidcrpdoc' 120 | 121 | 122 | # -- Options for LaTeX output --------------------------------------------- 123 | 124 | latex_elements = { 125 | # The paper size ('letterpaper' or 'a4paper'). 126 | # 127 | # 'papersize': 'letterpaper', 128 | 129 | # The font size ('10pt', '11pt' or '12pt'). 130 | # 131 | # 'pointsize': '10pt', 132 | 133 | # Additional stuff for the LaTeX preamble. 134 | # 135 | # 'preamble': '', 136 | 137 | # Latex figure (float) alignment 138 | # 139 | # 'figure_align': 'htbp', 140 | } 141 | 142 | # Grouping the document tree into LaTeX files. List of tuples 143 | # (source start file, target name, title, 144 | # author, documentclass [howto, manual, or own class]). 145 | latex_documents = [ 146 | (master_doc, 'oidcrp.tex', 'oidcrp Documentation', 147 | 'Roland Hedberg', 'manual'), 148 | ] 149 | 150 | 151 | # -- Options for manual page output --------------------------------------- 152 | 153 | # One entry per manual page. List of tuples 154 | # (source start file, name, description, authors, manual section). 155 | man_pages = [ 156 | (master_doc, 'oidcrp', 'oidcrp Documentation', 157 | [author], 1) 158 | ] 159 | 160 | 161 | # -- Options for Texinfo output ------------------------------------------- 162 | 163 | # Grouping the document tree into Texinfo files. List of tuples 164 | # (source start file, target name, title, author, 165 | # dir menu entry, description, category) 166 | texinfo_documents = [ 167 | (master_doc, 'oidcrp', 'oidcrp Documentation', 168 | author, 'oidcrp', 'One line description of project.', 169 | 'Miscellaneous'), 170 | ] 171 | 172 | 173 | 174 | 175 | # Example configuration for intersphinx: refer to the Python standard library. 176 | intersphinx_mapping = {'https://docs.python.org/': None} 177 | -------------------------------------------------------------------------------- /example/flask_rp/dpop_conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "logging": { 3 | "version": 1, 4 | "disable_existing_loggers": false, 5 | "root": { 6 | "handlers": [ 7 | "file" 8 | ], 9 | "level": "DEBUG" 10 | }, 11 | "loggers": { 12 | "idp": { 13 | "level": "DEBUG" 14 | } 15 | }, 16 | "handlers": { 17 | "file": { 18 | "class": "logging.FileHandler", 19 | "filename": "dpoop_debug.log", 20 | "formatter": "default" 21 | } 22 | }, 23 | "formatters": { 24 | "default": { 25 | "format": "%(asctime)s %(name)s %(levelname)s %(message)s" 26 | } 27 | } 28 | }, 29 | "port": 8090, 30 | "domain": "127.0.0.1", 31 | "base_url": "https://{domain}:{port}", 32 | "httpc_params": { 33 | "verify": false 34 | }, 35 | "rp_keys": { 36 | "private_path": "private/jwks.json", 37 | "key_defs": [ 38 | { 39 | "type": "RSA", 40 | "key": "", 41 | "use": [ 42 | "sig" 43 | ] 44 | }, 45 | { 46 | "type": "EC", 47 | "crv": "P-256", 48 | "use": [ 49 | "sig" 50 | ] 51 | } 52 | ], 53 | "public_path": "static/jwks.json", 54 | "read_only": false 55 | }, 56 | "services": { 57 | "discovery": { 58 | "class": "oidcrp.oidc.provider_info_discovery.ProviderInfoDiscovery", 59 | "kwargs": {} 60 | }, 61 | "registration": { 62 | "class": "oidcrp.oidc.registration.Registration", 63 | "kwargs": {} 64 | }, 65 | "authorization": { 66 | "class": "oidcrp.oidc.authorization.Authorization", 67 | "kwargs": {} 68 | }, 69 | "accesstoken": { 70 | "class": "oidcrp.oidc.access_token.AccessToken", 71 | "kwargs": {} 72 | }, 73 | "userinfo": { 74 | "class": "oidcrp.oidc.userinfo.UserInfo", 75 | "kwargs": {} 76 | }, 77 | "end_session": { 78 | "class": "oidcrp.oidc.end_session.EndSession", 79 | "kwargs": {} 80 | } 81 | }, 82 | "clients": { 83 | "": { 84 | "client_preferences": { 85 | "application_name": "rphandler", 86 | "application_type": "web", 87 | "contacts": [ 88 | "ops@example.com" 89 | ], 90 | "response_types": [ 91 | "code" 92 | ], 93 | "scope": [ 94 | "openid", 95 | "profile", 96 | "email", 97 | "address", 98 | "phone" 99 | ], 100 | "token_endpoint_auth_method": [ 101 | "client_secret_basic", 102 | "client_secret_post" 103 | ] 104 | }, 105 | "redirect_uris": [], 106 | "services": { 107 | "discovery": { 108 | "class": "oidcrp.oidc.provider_info_discovery.ProviderInfoDiscovery", 109 | "kwargs": {} 110 | }, 111 | "registration": { 112 | "class": "oidcrp.oidc.registration.Registration", 113 | "kwargs": {} 114 | }, 115 | "authorization": { 116 | "class": "oidcrp.oidc.authorization.Authorization", 117 | "kwargs": {} 118 | }, 119 | "accesstoken": { 120 | "class": "oidcrp.oidc.access_token.AccessToken", 121 | "kwargs": {} 122 | }, 123 | "userinfo": { 124 | "class": "oidcrp.oidc.userinfo.UserInfo", 125 | "kwargs": {} 126 | }, 127 | "end_session": { 128 | "class": "oidcrp.oidc.end_session.EndSession", 129 | "kwargs": {} 130 | } 131 | } 132 | }, 133 | "flask_provider": { 134 | "client_preferences": { 135 | "application_name": "rphandler", 136 | "application_type": "web", 137 | "contacts": [ 138 | "ops@example.com" 139 | ], 140 | "response_types": [ 141 | "code" 142 | ], 143 | "scope": [ 144 | "openid", 145 | "profile", 146 | "email", 147 | "address", 148 | "phone" 149 | ], 150 | "token_endpoint_auth_method": [ 151 | "client_secret_basic", 152 | "client_secret_post" 153 | ] 154 | }, 155 | "issuer": "https://127.0.0.1:5000/", 156 | "redirect_uris": [ 157 | "https://{domain}:{port}/authz_cb/local" 158 | ], 159 | "post_logout_redirect_uris": [ 160 | "https://{domain}:{port}/session_logout/local" 161 | ], 162 | "frontchannel_logout_uri": "https://{domain}:{port}/fc_logout/local", 163 | "frontchannel_logout_session_required": true, 164 | "backchannel_logout_uri": "https://{domain}:{port}/bc_logout/local", 165 | "backchannel_logout_session_required": true, 166 | "services": { 167 | "discovery": { 168 | "class": "oidcrp.oidc.provider_info_discovery.ProviderInfoDiscovery", 169 | "kwargs": {} 170 | }, 171 | "registration": { 172 | "class": "oidcrp.oidc.registration.Registration", 173 | "kwargs": {} 174 | }, 175 | "authorization": { 176 | "class": "oidcrp.oidc.authorization.Authorization", 177 | "kwargs": {} 178 | }, 179 | "accesstoken": { 180 | "class": "oidcrp.oidc.access_token.AccessToken", 181 | "kwargs": {} 182 | }, 183 | "userinfo": { 184 | "class": "oidcrp.oidc.userinfo.UserInfo", 185 | "kwargs": {} 186 | }, 187 | "end_session": { 188 | "class": "oidcrp.oidc.end_session.EndSession", 189 | "kwargs": {} 190 | } 191 | }, 192 | "add_ons": { 193 | "pkce": { 194 | "function": "oidcrp.oauth2.add_on.pkce.add_support", 195 | "kwargs": { 196 | "code_challenge_length": 64, 197 | "code_challenge_method": "S256" 198 | } 199 | }, 200 | "dpop": { 201 | "function": "oidcrp.oauth2.add_on.dpop.add_support", 202 | "kwargs": { 203 | "signing_algorithms": [ 204 | "ES256", "ES384", "ES512" 205 | ] 206 | } 207 | } 208 | } 209 | } 210 | }, 211 | "webserver": { 212 | "port": 8090, 213 | "domain": "127.0.0.1", 214 | "server_cert": "certs/cert.pem", 215 | "server_key": "certs/key.pem", 216 | "debug": true 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /tests/test_32_oidc_persistent.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | 5 | from cryptojwt.jwk.rsa import import_private_rsa_key_from_file 6 | from cryptojwt.key_bundle import KeyBundle 7 | from oidcmsg.oauth2 import AccessTokenRequest 8 | from oidcmsg.oauth2 import AccessTokenResponse 9 | from oidcmsg.oauth2 import AuthorizationRequest 10 | from oidcmsg.oauth2 import AuthorizationResponse 11 | from oidcmsg.oauth2 import RefreshAccessTokenRequest 12 | from oidcmsg.oidc import IdToken 13 | from oidcmsg.time_util import utc_time_sans_frac 14 | 15 | from oidcrp.oidc import RP 16 | 17 | sys.path.insert(0, '.') 18 | 19 | _dirname = os.path.dirname(os.path.abspath(__file__)) 20 | BASE_PATH = os.path.join(_dirname, "data", "keys") 21 | 22 | _key = import_private_rsa_key_from_file(os.path.join(BASE_PATH, "rsa.key")) 23 | KC_RSA = KeyBundle({"priv_key": _key, "kty": "RSA", "use": "sig"}) 24 | 25 | CLIENT_ID = "client_1" 26 | ISSUER = "http://op.example.com" 27 | 28 | IDTOKEN = IdToken(iss=ISSUER, sub="sub", 29 | aud=CLIENT_ID, exp=utc_time_sans_frac() + 86400, 30 | nonce="N0nce", 31 | iat=time.time()) 32 | 33 | CONF = { 34 | 'issuer': ISSUER, 35 | 'redirect_uris': ['https://example.com/cli/authz_cb'], 36 | 'client_id': CLIENT_ID, 37 | 'client_secret': 'abcdefghijklmnop' 38 | } 39 | 40 | 41 | def access_token_callback(endpoint): 42 | if endpoint: 43 | return 'access_token' 44 | 45 | 46 | class TestClient(object): 47 | def test_construct_accesstoken_request(self): 48 | # Client 1 starts 49 | client_1 = RP(config=CONF) 50 | _state = client_1.client_get("service_context").state.create_state(ISSUER) 51 | auth_request = AuthorizationRequest( 52 | redirect_uri='https://example.com/cli/authz_cb', 53 | state=_state 54 | ) 55 | client_1.client_get("service_context").state.store_item(auth_request, 'auth_request', _state) 56 | 57 | # Client 2 carries on 58 | client_2 = RP(config=CONF) 59 | _state_dump = client_1.client_get("service_context").dump() 60 | client_2.client_get("service_context").load(_state_dump) 61 | 62 | auth_response = AuthorizationResponse(code='access_code') 63 | client_2.client_get("service_context").state.store_item(auth_response, 'auth_response', _state) 64 | 65 | # Bind access code to state 66 | req_args = {} 67 | msg = client_2.client_get("service",'accesstoken').construct( 68 | request_args=req_args, state=_state) 69 | assert isinstance(msg, AccessTokenRequest) 70 | assert msg.to_dict() == { 71 | 'client_id': 'client_1', 'code': 'access_code', 72 | 'client_secret': 'abcdefghijklmnop', 73 | 'grant_type': 'authorization_code', 74 | 'redirect_uri': 'https://example.com/cli/authz_cb', 75 | 'state': _state 76 | } 77 | 78 | def test_construct_refresh_token_request(self): 79 | # Client 1 starts 80 | client_1 = RP(config=CONF) 81 | _state = client_1.client_get("service_context").state.create_state(ISSUER) 82 | 83 | auth_request = AuthorizationRequest( 84 | redirect_uri='https://example.com/cli/authz_cb', 85 | state=_state 86 | ) 87 | 88 | client_1.client_get("service_context").state.store_item(auth_request, 'auth_request', _state) 89 | 90 | # Client 2 carries on 91 | client_2 = RP(config=CONF) 92 | _state_dump = client_1.client_get("service_context").dump() 93 | client_2.client_get("service_context").load(_state_dump) 94 | 95 | auth_response = AuthorizationResponse(code='access_code') 96 | client_2.client_get("service_context").state.store_item(auth_response, 'auth_response', _state) 97 | 98 | token_response = AccessTokenResponse(refresh_token="refresh_with_me", 99 | access_token="access") 100 | client_2.client_get("service_context").state.store_item(token_response, 101 | 'token_response', _state) 102 | 103 | # Back to Client 1 104 | _state_dump = client_2.client_get("service_context").dump() 105 | client_1.client_get("service_context").load(_state_dump) 106 | 107 | req_args = {} 108 | msg = client_1.client_get("service",'refresh_token').construct(request_args=req_args, state=_state) 109 | assert isinstance(msg, RefreshAccessTokenRequest) 110 | assert msg.to_dict() == { 111 | 'client_id': 'client_1', 112 | 'client_secret': 'abcdefghijklmnop', 113 | 'grant_type': 'refresh_token', 114 | 'refresh_token': 'refresh_with_me' 115 | } 116 | 117 | def test_do_userinfo_request_init(self): 118 | # Client 1 starts 119 | client_1 = RP(config=CONF) 120 | _state = client_1.client_get("service_context").state.create_state(ISSUER) 121 | 122 | auth_request = AuthorizationRequest( 123 | redirect_uri='https://example.com/cli/authz_cb', 124 | state='state' 125 | ) 126 | 127 | # Client 2 carries on 128 | client_2 = RP(config=CONF) 129 | _state_dump = client_1.client_get("service_context").dump() 130 | client_2.client_get("service_context").load(_state_dump) 131 | 132 | auth_response = AuthorizationResponse(code='access_code') 133 | client_2.client_get("service_context").state.store_item(auth_response, 'auth_response', _state) 134 | 135 | token_response = AccessTokenResponse(refresh_token="refresh_with_me", 136 | access_token="access") 137 | client_2.client_get("service_context").state.store_item(token_response, 'token_response', _state) 138 | 139 | # Back to Client 1 140 | _state_dump = client_2.client_get("service_context").dump() 141 | client_1.client_get("service_context").load(_state_dump) 142 | 143 | _srv = client_1.client_get("service",'userinfo') 144 | _srv.endpoint = "https://example.com/userinfo" 145 | _info = _srv.get_request_parameters(state=_state) 146 | assert _info 147 | assert _info['headers'] == {'Authorization': 'Bearer access'} 148 | assert _info['url'] == 'https://example.com/userinfo' 149 | -------------------------------------------------------------------------------- /tests/test_40_dpop.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from cryptojwt.jws.jws import factory 4 | from cryptojwt.key_jar import init_key_jar 5 | import pytest 6 | 7 | from oidcrp.oauth2 import Client 8 | from oidcrp.oauth2 import DEFAULT_OAUTH2_SERVICES 9 | 10 | _dirname = os.path.dirname(os.path.abspath(__file__)) 11 | 12 | KEYSPEC = [ 13 | {"type": "RSA", "use": ["sig"]}, 14 | {"type": "EC", "crv": "P-256", "use": ["sig"]}, 15 | ] 16 | 17 | CLI_KEY = init_key_jar(public_path='{}/pub_client.jwks'.format(_dirname), 18 | private_path='{}/priv_client.jwks'.format(_dirname), 19 | key_defs=KEYSPEC, issuer_id='client_id') 20 | 21 | 22 | class TestDPoPWithoutUserinfo: 23 | @pytest.fixture(autouse=True) 24 | def create_client(self): 25 | config = { 26 | 'client_id': 'client_id', 27 | 'client_secret': 'a longesh password', 28 | 'redirect_uris': ['https://example.com/cli/authz_cb'], 29 | 'behaviour': {'response_types': ['code']}, 30 | 'add_ons': { 31 | "dpop": { 32 | "function": "oidcrp.oauth2.add_on.dpop.add_support", 33 | "kwargs": { 34 | "signing_algorithms": ["ES256", "ES512"] 35 | } 36 | } 37 | } 38 | } 39 | 40 | self.client = Client(keyjar=CLI_KEY, config=config, services=DEFAULT_OAUTH2_SERVICES) 41 | 42 | self.client.client_get("service_context").provider_info = { 43 | "authorization_endpoint": "https://example.com/auth", 44 | "token_endpoint": "https://example.com/token", 45 | "dpop_signing_alg_values_supported": ["RS256", "ES256"] 46 | } 47 | 48 | def test_add_header(self): 49 | token_serv = self.client.client_get("service", "accesstoken") 50 | req_args = { 51 | "grant_type": "authorization_code", 52 | "code": "SplxlOBeZQQYbYS6WxSbIA", 53 | "redirect_uri": "https://client/example.com/cb" 54 | } 55 | headers = token_serv.get_headers(request=req_args, http_method="POST") 56 | assert headers 57 | assert "dpop" in headers 58 | 59 | # Now for the content of the DPoP proof 60 | _jws = factory(headers["dpop"]) 61 | _payload = _jws.jwt.payload() 62 | assert _payload["htu"] == "https://example.com/token" 63 | assert _payload["htm"] == "POST" 64 | _header = _jws.jwt.headers 65 | assert "jwk" in _header 66 | assert _header["typ"] == "dpop+jwt" 67 | assert _header["alg"] == "ES256" 68 | assert _header["jwk"]["kty"] == "EC" 69 | assert _header["jwk"]["crv"] == "P-256" 70 | 71 | 72 | class TestDPoPWithUserinfo: 73 | @pytest.fixture(autouse=True) 74 | def create_client(self): 75 | config = { 76 | 'client_id': 'client_id', 77 | 'client_secret': 'a longesh password', 78 | 'redirect_uris': ['https://example.com/cli/authz_cb'], 79 | 'behaviour': {'response_types': ['code']}, 80 | 'add_ons': { 81 | "dpop": { 82 | "function": "oidcrp.oauth2.add_on.dpop.add_support", 83 | "kwargs": { 84 | "signing_algorithms": ["ES256", "ES512"] 85 | } 86 | } 87 | } 88 | } 89 | 90 | services = { 91 | "discovery": { 92 | 'class': 'oidcrp.oauth2.provider_info_discovery.ProviderInfoDiscovery' 93 | }, 94 | 'authorization': { 95 | 'class': 'oidcrp.oauth2.authorization.Authorization' 96 | }, 97 | 'access_token': { 98 | 'class': 'oidcrp.oauth2.access_token.AccessToken' 99 | }, 100 | 'refresh_access_token': { 101 | 'class': 'oidcrp.oauth2.refresh_access_token.RefreshAccessToken' 102 | }, 103 | 'userinfo': { 104 | 'class': 'oidcrp.oidc.userinfo.UserInfo' 105 | } 106 | } 107 | self.client = Client(keyjar=CLI_KEY, config=config, services=services) 108 | 109 | self.client.client_get("service_context").provider_info = { 110 | "authorization_endpoint": "https://example.com/auth", 111 | "token_endpoint": "https://example.com/token", 112 | "dpop_signing_alg_values_supported": ["RS256", "ES256"], 113 | "userinfo_endpoint": "https://example.com/user", 114 | } 115 | 116 | def test_add_header_token(self): 117 | token_serv = self.client.client_get("service", "accesstoken") 118 | req_args = { 119 | "grant_type": "authorization_code", 120 | "code": "SplxlOBeZQQYbYS6WxSbIA", 121 | "redirect_uri": "https://client/example.com/cb" 122 | } 123 | headers = token_serv.get_headers(request=req_args, http_method="POST") 124 | assert headers 125 | assert "dpop" in headers 126 | 127 | # Now for the content of the DPoP proof 128 | _jws = factory(headers["dpop"]) 129 | _payload = _jws.jwt.payload() 130 | assert _payload["htu"] == "https://example.com/token" 131 | assert _payload["htm"] == "POST" 132 | _header = _jws.jwt.headers 133 | assert "jwk" in _header 134 | assert _header["typ"] == "dpop+jwt" 135 | assert _header["alg"] == "ES256" 136 | assert _header["jwk"]["kty"] == "EC" 137 | assert _header["jwk"]["crv"] == "P-256" 138 | 139 | def test_add_header_userinfo(self): 140 | userinfo_serv = self.client.client_get("service", "userinfo") 141 | req_args = {} 142 | access_token = 'access.token.sign' 143 | headers = userinfo_serv.get_headers(request=req_args, http_method="GET", 144 | access_token=access_token) 145 | assert headers 146 | assert "dpop" in headers 147 | 148 | # Now for the content of the DPoP proof 149 | _jws = factory(headers["dpop"]) 150 | _payload = _jws.jwt.payload() 151 | assert _payload["htu"] == "https://example.com/user" 152 | assert _payload["htm"] == "GET" 153 | _header = _jws.jwt.headers 154 | assert "jwk" in _header 155 | assert _header["typ"] == "dpop+jwt" 156 | assert _header["alg"] == "ES256" 157 | assert _header["jwk"]["kty"] == "EC" 158 | assert _header["jwk"]["crv"] == "P-256" 159 | -------------------------------------------------------------------------------- /tests/test_21_rph_defaults.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import parse_qs 2 | from urllib.parse import urlparse 3 | 4 | import pytest 5 | import responses 6 | from cryptojwt.key_jar import build_keyjar 7 | from oidcmsg.oidc import ProviderConfigurationResponse 8 | from oidcmsg.oidc import RegistrationResponse 9 | 10 | from oidcrp.defaults import DEFAULT_KEY_DEFS 11 | from oidcrp.rp_handler import RPHandler 12 | 13 | BASE_URL = "https://example.com" 14 | 15 | 16 | class TestRPHandler(object): 17 | @pytest.fixture(autouse=True) 18 | def rphandler_setup(self): 19 | self.rph = RPHandler(BASE_URL) 20 | 21 | def test_pick_config(self): 22 | cnf = self.rph.pick_config('') 23 | assert cnf 24 | 25 | def test_init_client(self): 26 | client = self.rph.init_client('') 27 | assert set(client.client_get("services").keys()) == { 28 | 'registration', 'provider_info', 'webfinger', 29 | 'authorization', 'accesstoken', 'userinfo', 'refresh_token'} 30 | 31 | _context = client.client_get("service_context") 32 | 33 | assert _context.config['client_preferences'] == { 34 | 'application_type': 'web', 35 | 'application_name': 'rphandler', 36 | 'response_types': ['code', 'id_token', 'id_token token', 'code id_token', 37 | 'code id_token token', 'code token'], 38 | 'scope': ['openid'], 39 | 'token_endpoint_auth_method': 'client_secret_basic' 40 | } 41 | 42 | assert list(_context.keyjar.owners()) == ['', BASE_URL] 43 | keys = _context.keyjar.get_issuer_keys('') 44 | assert len(keys) == 2 45 | 46 | assert _context.base_url == BASE_URL 47 | 48 | def test_begin(self): 49 | ISS_ID = "https://op.example.org" 50 | OP_KEYS = build_keyjar(DEFAULT_KEY_DEFS) 51 | # The 4 steps of client_setup 52 | client = self.rph.init_client(ISS_ID) 53 | with responses.RequestsMock() as rsps: 54 | request_uri = '{}/.well-known/openid-configuration'.format(ISS_ID) 55 | _jws = ProviderConfigurationResponse( 56 | issuer=ISS_ID, 57 | authorization_endpoint='{}/authorization'.format(ISS_ID), 58 | jwks_uri='{}/jwks.json'.format(ISS_ID), 59 | response_types_supported=['code', 'id_token', 'id_token token'], 60 | subject_types_supported=['public'], 61 | id_token_signing_alg_values_supported=["RS256", "ES256"], 62 | token_endpoint='{}/token'.format(ISS_ID), 63 | registration_endpoint='{}/register'.format(ISS_ID) 64 | ).to_json() 65 | rsps.add("GET", request_uri, body=_jws, status=200) 66 | 67 | rsps.add("GET", '{}/jwks.json'.format(ISS_ID), body=OP_KEYS.export_jwks_as_json(), 68 | status=200) 69 | 70 | issuer = self.rph.do_provider_info(client) 71 | 72 | _context = client.client_get("service_context") 73 | 74 | # Calculating request so I can build a reasonable response 75 | _req = client.client_get("service",'registration').construct_request() 76 | 77 | with responses.RequestsMock() as rsps: 78 | request_uri = _context.get('provider_info')["registration_endpoint"] 79 | _jws = RegistrationResponse( 80 | client_id="client uno", client_secret="VerySecretAndLongEnough", **_req.to_dict() 81 | ).to_json() 82 | rsps.add("POST", request_uri, body=_jws, status=200) 83 | self.rph.do_client_registration(client, ISS_ID) 84 | 85 | self.rph.issuer2rp[issuer] = client 86 | 87 | assert set(_context.get('behaviour').keys()) == { 88 | 'token_endpoint_auth_method', 'response_types', 'scope', 'application_type', 89 | 'application_name'} 90 | assert _context.get('client_id') == "client uno" 91 | assert _context.get('client_secret') == "VerySecretAndLongEnough" 92 | assert _context.get('issuer') == ISS_ID 93 | 94 | res = self.rph.init_authorization(client) 95 | assert set(res.keys()) == {'url', 'state'} 96 | p = urlparse(res["url"]) 97 | assert p.hostname == 'op.example.org' 98 | assert p.path == "/authorization" 99 | qs = parse_qs(p.query) 100 | assert qs['state'] == [res['state']] 101 | # PKCE stuff 102 | assert 'code_challenge' in qs 103 | assert qs["code_challenge_method"] == ["S256"] 104 | 105 | def test_begin_2(self): 106 | ISS_ID = "https://op.example.org" 107 | OP_KEYS = build_keyjar(DEFAULT_KEY_DEFS) 108 | # The 4 steps of client_setup 109 | client = self.rph.init_client(ISS_ID) 110 | with responses.RequestsMock() as rsps: 111 | request_uri = '{}/.well-known/openid-configuration'.format(ISS_ID) 112 | _jws = ProviderConfigurationResponse( 113 | issuer=ISS_ID, 114 | authorization_endpoint='{}/authorization'.format(ISS_ID), 115 | jwks_uri='{}/jwks.json'.format(ISS_ID), 116 | response_types_supported=['code', 'id_token', 'id_token token'], 117 | subject_types_supported=['public'], 118 | id_token_signing_alg_values_supported=["RS256", "ES256"], 119 | token_endpoint='{}/token'.format(ISS_ID), 120 | registration_endpoint='{}/register'.format(ISS_ID) 121 | ).to_json() 122 | rsps.add("GET", request_uri, body=_jws, status=200) 123 | 124 | rsps.add("GET", '{}/jwks.json'.format(ISS_ID), body=OP_KEYS.export_jwks_as_json(), 125 | status=200) 126 | 127 | issuer = self.rph.do_provider_info(client) 128 | 129 | _context = client.client_get("service_context") 130 | # Calculating request so I can build a reasonable response 131 | # Publishing a JWKS instead of a JWKS_URI 132 | _context.jwks_uri = '' 133 | _context.jwks = _context.keyjar.export_jwks() 134 | 135 | _req = client.client_get("service",'registration').construct_request() 136 | 137 | with responses.RequestsMock() as rsps: 138 | request_uri = _context.get('provider_info')["registration_endpoint"] 139 | _jws = RegistrationResponse( 140 | client_id="client uno", client_secret="VerySecretAndLongEnough", **_req.to_dict() 141 | ).to_json() 142 | rsps.add("POST", request_uri, body=_jws, status=200) 143 | self.rph.do_client_registration(client, ISS_ID) 144 | 145 | assert 'jwks' in _context.get('registration_response') -------------------------------------------------------------------------------- /src/oidcrp/oidc/webfinger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from urllib.parse import urlsplit 3 | from urllib.parse import urlunsplit 4 | 5 | from oidcmsg import oidc 6 | from oidcmsg.exception import MissingRequiredAttribute 7 | from oidcmsg.oauth2 import Message 8 | from oidcmsg.oauth2 import ResponseMessage 9 | from oidcmsg.oidc import JRD 10 | 11 | from oidcrp.oidc import OIC_ISSUER 12 | from oidcrp.oidc import WF_URL 13 | from oidcrp.service import Service 14 | 15 | __author__ = 'Roland Hedberg' 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | SCHEME = 0 20 | NETLOC = 1 21 | PATH = 2 22 | QUERY = 3 23 | FRAGMENT = 4 24 | 25 | 26 | class WebFinger(Service): 27 | """ 28 | Implements RFC 7033 29 | """ 30 | msg_type = Message 31 | response_cls = JRD 32 | error_msg = ResponseMessage 33 | synchronous = True 34 | service_name = 'webfinger' 35 | http_method = 'GET' 36 | response_body_type = 'json' 37 | 38 | def __init__(self, client_get, client_authn_factory=None, 39 | conf=None, rel='', **kwargs): 40 | Service.__init__(self, client_get, 41 | client_authn_factory=client_authn_factory, 42 | conf=conf, **kwargs) 43 | 44 | self.rel = rel or OIC_ISSUER 45 | 46 | def update_service_context(self, resp, key='', **kwargs): 47 | try: 48 | links = resp['links'] 49 | except KeyError: 50 | raise MissingRequiredAttribute('links') 51 | else: 52 | for link in links: 53 | if link['rel'] == self.rel: 54 | _href = link['href'] 55 | try: 56 | _http_allowed = self.get_conf_attr( 57 | 'allow', default={})['http_links'] 58 | except KeyError: 59 | _http_allowed = False 60 | 61 | if _href.startswith('http://') and not _http_allowed: 62 | raise ValueError( 63 | 'http link not allowed ({})'.format(_href)) 64 | 65 | self.client_get("service_context").issuer = link['href'] 66 | break 67 | return resp 68 | 69 | @staticmethod 70 | def create_url(part, ignore): 71 | res = [] 72 | for a in range(0, 5): 73 | if a in ignore: 74 | res.append('') 75 | else: 76 | res.append(part[a]) 77 | return urlunsplit(tuple(res)) 78 | 79 | def query(self, resource): 80 | """ 81 | Given a resource identifier find the domain specifier and then 82 | construct the webfinger request. Implements 83 | http://openid.net/specs/openid-connect-discovery-1_0.html#NormalizationSteps 84 | 85 | :param resource: 86 | """ 87 | if resource[0] in ['=', '@', '!']: # Have no process for handling these 88 | raise ValueError('Not allowed resource identifier') 89 | 90 | try: 91 | part = urlsplit(resource) 92 | except Exception: 93 | raise ValueError('Unparsable resource') 94 | else: 95 | if not part[SCHEME]: 96 | if not part[NETLOC]: 97 | _path = part[PATH] 98 | if not part[QUERY] and not part[FRAGMENT]: 99 | if '/' in _path or ':' in _path: 100 | resource = "https://{}".format(resource) 101 | part = urlsplit(resource) 102 | authority = part[NETLOC] 103 | else: 104 | if '@' in _path: 105 | authority = _path.split('@')[1] 106 | else: 107 | authority = _path 108 | resource = 'acct:{}'.format(_path) 109 | elif part[QUERY]: 110 | resource = "https://{}?{}".format(_path, part[QUERY]) 111 | parts = urlsplit(resource) 112 | authority = parts[NETLOC] 113 | else: 114 | resource = "https://{}".format(_path) 115 | part = urlsplit(resource) 116 | authority = part[NETLOC] 117 | else: 118 | raise ValueError('Missing netloc') 119 | else: 120 | _scheme = part[SCHEME] 121 | if _scheme not in ['http', 'https', 'acct']: 122 | # assume it to be a hostname port combo, 123 | # eg. example.com:8080 124 | resource = 'https://{}'.format(resource) 125 | part = urlsplit(resource) 126 | authority = part[NETLOC] 127 | resource = self.create_url(part, [FRAGMENT]) 128 | elif _scheme in ['http', 'https'] and not part[NETLOC]: 129 | raise ValueError( 130 | 'No authority part in the resource specification') 131 | elif _scheme == 'acct': 132 | _path = part[PATH] 133 | for c in ['/', '?']: 134 | _path = _path.split(c)[0] 135 | 136 | if '@' in _path: 137 | authority = _path.split('@')[1] 138 | else: 139 | raise ValueError( 140 | 'No authority part in the resource specification') 141 | authority = authority.split('#')[0] 142 | resource = self.create_url(part, [FRAGMENT]) 143 | else: 144 | authority = part[NETLOC] 145 | resource = self.create_url(part, [FRAGMENT]) 146 | 147 | location = WF_URL.format(authority) 148 | return oidc.WebFingerRequest( 149 | resource=resource, rel=OIC_ISSUER).request(location) 150 | 151 | def get_request_parameters(self, request_args=None, **kwargs): 152 | 153 | if request_args is None: 154 | request_args = {} 155 | 156 | try: 157 | _resource = request_args['resource'] 158 | except KeyError: 159 | try: 160 | _resource = kwargs['resource'] 161 | except KeyError: 162 | try: 163 | _resource = self.client_get("service_context").config['resource'] 164 | except KeyError: 165 | raise MissingRequiredAttribute('resource') 166 | 167 | return {'url': self.query(_resource), 'method': 'GET'} 168 | --------------------------------------------------------------------------------