├── 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 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # oidcrp
2 |
3 | 
4 | 
5 | [](https://pepy.tech/project/oidcrp)
6 | [](https://pepy.tech/project/oidcrp)
7 | 
8 | 
9 | 
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 | 
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 |
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 |
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 |
14 |
15 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/example/flask_rp/templates/repost_fragment.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | pyoidc RP
6 |
7 |
8 |
9 |
10 |
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 |
--------------------------------------------------------------------------------