├── .github ├── release-drafter.yml └── workflows │ ├── pypi.yml │ ├── python-app.yml │ └── release-drafter.yml ├── .gitignore ├── .isort.cfg ├── .readthedocs.yml ├── LICENSE ├── README.md ├── docs ├── Makefile ├── make.bat └── source │ ├── _images │ ├── 1.png │ ├── 2.png │ ├── 3.png │ ├── 4.png │ └── oid-l-certification-mark-l-rgb-150dpi-90mm-300x157.png │ ├── _static │ ├── custom.css │ └── logo.png │ ├── _templates │ └── idpy_template │ │ ├── footer.html │ │ └── layout.html │ ├── conf.py │ ├── contents │ ├── clients.rst │ ├── conf.rst │ ├── developers.md │ ├── faq.md │ ├── intro.rst │ ├── session_management.rst │ ├── setup.md │ └── usage.md │ ├── diagrams │ └── session_relations.mermaid │ └── index.rst ├── example ├── django_op │ └── README.md ├── fastapi │ ├── __init__.py │ ├── config.json │ ├── main.py │ ├── models.py │ ├── passwd.json │ ├── users.json │ └── utils.py └── flask_op │ ├── Dockerfile │ ├── README.md │ ├── __init__.py │ ├── application.py │ ├── certs │ ├── cert.pem │ ├── client.crt │ ├── client.key │ └── key.pem │ ├── config.json │ ├── passwd.json │ ├── requirements.txt │ ├── run.sh │ ├── server.py │ ├── templates │ ├── check_session_iframe.html │ ├── error.html │ ├── frontchannel_logout.html │ ├── index.html │ ├── logout.html │ ├── post_logout.html │ └── user_pass.jinja2 │ ├── users.json │ ├── views.py │ └── yaml_to_json.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements-docs.txt ├── requirements.txt ├── setup.py ├── src └── oidcop │ ├── __init__.py │ ├── authn_event.py │ ├── authz │ └── __init__.py │ ├── client_authn.py │ ├── configure.py │ ├── constant.py │ ├── construct.py │ ├── cookie_handler.py │ ├── endpoint.py │ ├── endpoint_context.py │ ├── exception.py │ ├── logging.py │ ├── login_hint.py │ ├── oauth2 │ ├── __init__.py │ ├── add_on │ │ ├── __init__.py │ │ ├── dpop.py │ │ └── extra_args.py │ ├── authorization.py │ ├── introspection.py │ ├── pushed_authorization.py │ └── token.py │ ├── oidc │ ├── __init__.py │ ├── add_on │ │ ├── __init__.py │ │ ├── custom_scopes.py │ │ └── pkce.py │ ├── authorization.py │ ├── discovery.py │ ├── provider_config.py │ ├── read_registration.py │ ├── registration.py │ ├── session.py │ ├── token.py │ └── userinfo.py │ ├── scopes.py │ ├── server.py │ ├── session │ ├── __init__.py │ ├── claims.py │ ├── database.py │ ├── grant.py │ ├── info.py │ ├── manager.py │ └── token.py │ ├── template_handler.py │ ├── token │ ├── __init__.py │ ├── exception.py │ ├── handler.py │ ├── id_token.py │ └── jwt_token.py │ ├── user_authn │ ├── __init__.py │ ├── authn_context.py │ └── user.py │ ├── user_info │ └── __init__.py │ ├── util.py │ └── utils.py └── tests ├── __init__.py ├── donot_test_49_session_persistence.py ├── logging.yaml ├── logging_config.json ├── op_config.json ├── op_config_defaults.py ├── passwd.json ├── srv_config.yaml ├── templates └── user_pass.jinja2 ├── test_00_configure.py ├── test_00_server.py ├── test_01_claims.py ├── test_01_grant.py ├── test_01_session_info.py ├── test_01_session_token.py ├── test_01_util.py ├── test_02_authz_handling.py ├── test_02_client_authn.py ├── test_02_sess_mngm_db.py ├── test_04_token_handler.py ├── test_05_id_token.py ├── test_05_jwt_token.py ├── test_06_authn_context.py ├── test_06_session_manager.py ├── test_06_session_manager_pairwise.py ├── test_07_userinfo.py ├── test_08_session_life.py ├── test_09_cookie_handler.py ├── test_12_user_authn.py ├── test_13_login_hint.py ├── test_20_endpoint.py ├── test_21_oidc_discovery_endpoint.py ├── test_22_oidc_provider_config_endpoint.py ├── test_23_oidc_registration_endpoint.py ├── test_24_oauth2_authorization_endpoint.py ├── test_24_oauth2_authorization_endpoint_jar.py ├── test_24_oauth2_token_endpoint.py ├── test_24_oidc_authorization_endpoint.py ├── test_26_oidc_userinfo_endpoint.py ├── test_30_oidc_end_session.py ├── test_31_oauth2_introspection.py ├── test_32_oidc_read_registration.py ├── test_33_oauth2_pkce.py ├── test_34_oidc_sso.py ├── test_35_oidc_token_endpoint.py ├── test_36_oauth2_token_exchange.py ├── test_40_oauth2_pushed_authorization.py ├── test_50_persistence.py ├── test_60_dpop.py ├── test_61_add_on.py └── users.json /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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: oidc-op 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 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v2 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi 35 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 36 | python setup.py install 37 | - name: Lint with flake8 38 | run: | 39 | # stop the build if there are Python syntax errors or undefined names 40 | flake8 src/oidcop --count --select=E9,F63,F7,F82 --show-source --statistics 41 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 42 | flake8 src/oidcop --max-line-length 120 --count --exit-zero --statistics 43 | 44 | - name: Test with pytest 45 | run: | 46 | pytest --cov=oidcop tests/ 47 | - name: Bandit Security Scan 48 | run: | 49 | bandit --skip B105,B106,B107 -r src/oidcop/ 50 | #- name: Upload coverage to Codecov 51 | #uses: codecov/codecov-action@v1 52 | #with: 53 | #token: ${{ secrets.CODECOV_TOKEN }} 54 | #file: example/coverage.xml 55 | #flags: unittests 56 | #env_vars: OS,PYTHON 57 | #name: codecov-umbrella 58 | #fail_ci_if_error: true 59 | #path_to_write_report: ./codecov_report.txt 60 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | static/ 2 | private/ 3 | conf.yaml 4 | flask_op/debug.log 5 | flask_op/static/ 6 | debug.log 7 | .pytest_cache/ 8 | # Created by .ignore support plugin (hsz.mobi) 9 | ### Python template 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # C extensions 16 | *.so 17 | 18 | # Distribution / packaging 19 | .Python 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib/ 27 | lib64/ 28 | parts/ 29 | sdist/ 30 | var/ 31 | wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | .hypothesis/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | .static_storage/ 65 | .media/ 66 | local_settings.py 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # celery beat schedule file 88 | celerybeat-schedule 89 | 90 | # SageMath parsed files 91 | *.sage.py 92 | 93 | # Environments 94 | .env 95 | .venv 96 | env/ 97 | venv/ 98 | ENV/ 99 | env.bak/ 100 | venv.bak/ 101 | 102 | # Spyder project settings 103 | .spyderproject 104 | .spyproject 105 | 106 | # Rope project settings 107 | .ropeproject 108 | 109 | # mkdocs documentation 110 | /site 111 | 112 | # mypy 113 | .mypy_cache/ 114 | 115 | .idea/ 116 | src/oidcendpoint.egg-info/ 117 | 118 | .iframes/ 119 | tests/pairwise.salt 120 | tests/public.salt 121 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | force_single_line = 1 3 | known_first_party = oidcop 4 | known_third_party = cryptojwt, oidcmsg 5 | known_future_library = future,past 6 | default_section = THIRDPARTY 7 | line_length = 100 -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/source/conf.py 11 | 12 | # Build documentation with MkDocs 13 | #mkdocs: 14 | # configuration: mkdocs.yml 15 | 16 | # Optionally build your docs in additional formats such as PDF and ePub 17 | formats: all 18 | 19 | # Optionally set the version of Python and requirements required to build your docs 20 | python: 21 | version: 3.8 22 | install: 23 | - requirements: requirements-docs.txt 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # oidc-op 2 | 3 | ![CI build](https://github.com/IdentityPython/oidc-op/workflows/oidc-op/badge.svg) 4 | ![pypi](https://img.shields.io/pypi/v/oidcop.svg) 5 | [![Downloads total](https://pepy.tech/badge/oidcop)](https://pepy.tech/project/oidcop) 6 | [![Downloads week](https://pepy.tech/badge/oidcop/week)](https://pepy.tech/project/oidcop) 7 | ![License](https://img.shields.io/badge/license-Apache%202-blue.svg) 8 | ![Documentation Status](https://readthedocs.org/projects/oidcop/badge/?version=latest) 9 | ![Python version](https://img.shields.io/badge/python-3.7%20%7C%203.8%20%7C%203.9-blue.svg) 10 | 11 | This project is a Python implementation of an **OIDC Provider** on top of [jwtconnect.io](https://jwtconnect.io/) that shows to you how to 'build' an OP using the classes and functions provided by oidc-op. 12 | 13 | If you want to add or replace functionality the official documentation should be able to tell you how. 14 | If you are just going to build a standard OP you only have to understand how to write your configuration file. 15 | In `example/` folder you'll find some complete examples based on flask and django. 16 | 17 | Idpy OIDC-op implements the following standards: 18 | 19 | * [OpenID Connect Core 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-core-1_0.html) 20 | * [Web Finger](https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery) 21 | * [OpenID Connect Discovery 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-discovery-1_0.html) 22 | * [OpenID Connect Dynamic Client Registration 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-registration-1_0.html) 23 | * [OpenID Connect Session Management 1.0](https://openid.net/specs/openid-connect-session-1_0.html) 24 | * [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html) 25 | * [OpenID Connect Front-Channel Logout 1.0](https://openid.net/specs/openid-connect-frontchannel-1_0.html) 26 | * [OAuth2 Token introspection](https://tools.ietf.org/html/rfc7662) 27 | 28 | It also comes with the following `add_on` modules. 29 | 30 | * Custom scopes, that extends [OIDC standard ScopeClaims](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) 31 | * [Proof Key for Code Exchange by OAuth Public Clients (PKCE)](https://tools.ietf.org/html/rfc7636) 32 | * [OAuth2 PAR](https://datatracker.ietf.org/doc/html/rfc9126) 33 | * [OAuth2 RAR](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rar) 34 | * [OAuth2 DPoP](https://tools.ietf.org/id/draft-fett-oauth-dpop-04.html) 35 | * [OAuth 2.0 Authorization Server Issuer Identification](https://datatracker.ietf.org/doc/draft-ietf-oauth-iss-auth-resp) 36 | 37 | The entire project code is open sourced and therefore licensed under the [Apache 2.0](https://en.wikipedia.org/wiki/Apache_License) 38 | 39 | For any futher information please read the [Official Documentation](https://oidcop.readthedocs.io/en/latest/). 40 | 41 | # Certifications 42 | [![OIDC Certification](https://openid.net/wordpress-content/uploads/2016/04/oid-l-certification-mark-l-rgb-150dpi-90mm-300x157.png)](https://www.certification.openid.net/plan-detail.html?public=true&plan=7p3iPQmff6Ohv) 43 | 44 | 45 | # Contribute 46 | 47 | [Join in](https://idpy.org/contribute/). 48 | 49 | 50 | # Authors 51 | 52 | - Roland Hedberg 53 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/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 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/_images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityPython/oidc-op/2f81e246ff9ef412f5dc786cd2cfa5dd5fccf9e1/docs/source/_images/1.png -------------------------------------------------------------------------------- /docs/source/_images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityPython/oidc-op/2f81e246ff9ef412f5dc786cd2cfa5dd5fccf9e1/docs/source/_images/2.png -------------------------------------------------------------------------------- /docs/source/_images/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityPython/oidc-op/2f81e246ff9ef412f5dc786cd2cfa5dd5fccf9e1/docs/source/_images/3.png -------------------------------------------------------------------------------- /docs/source/_images/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityPython/oidc-op/2f81e246ff9ef412f5dc786cd2cfa5dd5fccf9e1/docs/source/_images/4.png -------------------------------------------------------------------------------- /docs/source/_images/oid-l-certification-mark-l-rgb-150dpi-90mm-300x157.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityPython/oidc-op/2f81e246ff9ef412f5dc786cd2cfa5dd5fccf9e1/docs/source/_images/oid-l-certification-mark-l-rgb-150dpi-90mm-300x157.png -------------------------------------------------------------------------------- /docs/source/_static/custom.css: -------------------------------------------------------------------------------- 1 | body, 2 | h1, h2, 3 | .rst-content .toctree-wrapper p.caption, 4 | h3, h4, h5, h6, 5 | legend{ 6 | font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; 7 | } 8 | 9 | .wy-side-nav-search{ 10 | background: #ffffff; 11 | } 12 | 13 | .wy-side-nav-search>a, 14 | .wy-side-nav-search .wy-dropdown>a{ 15 | color: #9b9c9e; 16 | font-weight: normal; 17 | } 18 | 19 | .wy-menu-vertical header, 20 | .wy-menu-vertical p.caption{ 21 | color: #fff; 22 | font-size:85%; 23 | } 24 | 25 | .wy-nav-top{ 26 | background: #fff; 27 | border-bottom: 1px solid #f7f5f5; 28 | } 29 | 30 | .wy-nav-top a{ 31 | display: block; 32 | color: #9b9c9e; 33 | font-weight: normal; 34 | } 35 | 36 | .wy-nav-top i{ 37 | color: #BE0417; 38 | } 39 | 40 | .wy-nav-top img{ 41 | border-radius: 0; 42 | background: none; 43 | width: 65%; 44 | } 45 | 46 | img{ 47 | height: auto !important; 48 | } 49 | 50 | .document{ 51 | text-align: justify; 52 | } 53 | 54 | h1{ 55 | text-align: left; 56 | } 57 | 58 | #logo_main{ 59 | margin-bottom: 0; 60 | } 61 | 62 | #title_under_logo{ 63 | margin-bottom: 1em; 64 | } 65 | 66 | .alert-danger { 67 | color: #721c24; 68 | background-color: #f8d7da; 69 | border-color: #f5c6cb; 70 | } 71 | .alert-primary { 72 | color: #004085; 73 | background-color: #cce5ff; 74 | border-color: #b8daff; 75 | } 76 | .alert { 77 | position: relative; 78 | padding: .75rem 1.25rem; 79 | margin-bottom: 1rem; 80 | border: 1px solid transparent; 81 | border-radius: .25rem; 82 | } 83 | -------------------------------------------------------------------------------- /docs/source/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityPython/oidc-op/2f81e246ff9ef412f5dc786cd2cfa5dd5fccf9e1/docs/source/_static/logo.png -------------------------------------------------------------------------------- /docs/source/_templates/idpy_template/footer.html: -------------------------------------------------------------------------------- 1 | 54 | -------------------------------------------------------------------------------- /docs/source/_templates/idpy_template/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | 3 | {% block extrahead %} 4 | 5 | 6 | {% endblock %} 7 | 8 | 9 | 10 | {% block sidebartitle %} 11 | 12 | {% if logo %} 13 | {# Not strictly valid HTML, but it's the only way to display/scale 14 | it properly, without weird scripting or heaps of work 15 | #} 16 | 17 | {% endif %} 18 | 19 | {% if logo and theme_logo_only %} 20 | 25 | 26 | {% if theme_display_version %} 27 | {%- set nav_version = version %} 28 | {% if READTHEDOCS and current_version %} 29 | {%- set nav_version = current_version %} 30 | {% endif %} 31 | {% if nav_version %} 32 |
33 | {{ nav_version }} 34 |
35 | {% endif %} 36 | {% endif %} 37 | 38 | {% include "searchbox.html" %} 39 | 40 | {% endblock %} 41 | 42 | 43 | 44 | 45 | {% block mobile_nav %} 46 | 47 | 48 | 49 | 50 | 51 | {{ project }} 52 | 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | # from recommonmark.parser import CommonMarkParser 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'oidcop' 21 | copyright = '2021, Identity Python' 22 | author = 'Giuseppe De Marco, Roland Hedberg' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = '0.1.0' 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinxcontrib.images', 'recommonmark'] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates/idpy_template'] 37 | html_logo = "_static/logo.png" 38 | 39 | # List of patterns, relative to source directory, that match files and 40 | # directories to ignore when looking for source files. 41 | # This pattern also affects html_static_path and html_extra_path. 42 | exclude_patterns = [] 43 | 44 | 45 | # -- Options for HTML output ------------------------------------------------- 46 | 47 | # The theme to use for HTML and HTML Help pages. See the documentation for 48 | # a list of builtin themes. 49 | # 50 | html_theme = 'sphinx_rtd_theme' 51 | # Add any paths that contain custom static files (such as style sheets) here, 52 | # relative to this directory. They are copied after the builtin static files, 53 | # so a file named "default.css" will overwrite the builtin "default.css". 54 | html_static_path = ['_static'] 55 | 56 | source_suffix = ['.rst', '.md'] 57 | -------------------------------------------------------------------------------- /docs/source/contents/clients.rst: -------------------------------------------------------------------------------- 1 | ******************** 2 | The clients database 3 | ******************** 4 | 5 | Information kept about clients in the client database are to begin with the 6 | client metadata as defined in 7 | https://openid.net/specs/openid-connect-registration-1_0.html . 8 | 9 | To that we have the following additions specified in OIDC extensions. 10 | 11 | * https://openid.net/specs/openid-connect-rpinitiated-1_0.html 12 | + post_logout_redirect_uri 13 | * https://openid.net/specs/openid-connect-frontchannel-1_0.html 14 | + frontchannel_logout_uri 15 | + frontchannel_logout_session_required 16 | * https://openid.net/specs/openid-connect-backchannel-1_0.html#Backchannel 17 | + backchannel_logout_uri 18 | + backchannel_logout_session_required 19 | * https://openid.net/specs/openid-connect-federation-1_0.html#rfc.section.3.1 20 | + client_registration_types 21 | + organization_name 22 | + signed_jwks_uri 23 | 24 | And finally we add a number of parameters that are OidcOP specific. 25 | These are described in this document. 26 | 27 | -------------- 28 | allowed_scopes 29 | -------------- 30 | 31 | Which scopes that can be returned to a client. This is used to filter 32 | the set of scopes a user can authorize release of. 33 | 34 | ----------------- 35 | token_usage_rules 36 | ----------------- 37 | 38 | There are usage rules for tokens. Rules are set per token type (the basic set is 39 | authorization_code, refresh_token, access_token and id_token). 40 | The possible rules are: 41 | 42 | + how many times they can be used 43 | + if other tokens can be minted based on this token 44 | + how fast they expire 45 | 46 | A typical example (this is the default) would be:: 47 | 48 | "token_usage_rules": { 49 | "authorization_code": { 50 | "max_usage": 1 51 | "supports_minting": ["access_token", "refresh_token"], 52 | "expires_in": 600, 53 | }, 54 | "refresh_token": { 55 | "supports_minting": ["access_token"], 56 | "expires_in": -1 57 | }, 58 | } 59 | 60 | This then means that access_tokens can be used any number of times, 61 | can not be used to mint other tokens and will expire after 300 seconds 62 | which is the default for any token. An authorization_code can only used once 63 | and it can be used to mint access_tokens and refresh_tokens. Note that normally 64 | an authorization_code is used to mint an access_token and a refresh_token at 65 | the same time. Such a dual minting is counted as one usage. 66 | And lastly an refresh_token can be used to mint access_tokens any number of 67 | times. An *expires_in* of -1 means that the token will never expire. 68 | 69 | If token_usage_rules are defined in the client metadata then it will be used 70 | whenever a token is minted unless circumstances makes the OP modify the rules. 71 | 72 | Also this does not mean that what is valid for a token can not be changed 73 | during run time. 74 | 75 | 76 | -------------------------------------------------------------------------------- /docs/source/contents/developers.md: -------------------------------------------------------------------------------- 1 | Tests 2 | ----- 3 | 4 | ```` 5 | pip install -r requirements-dev.txt 6 | pytest --cov=oidcop tests/ 7 | ```` 8 | -------------------------------------------------------------------------------- /docs/source/contents/faq.md: -------------------------------------------------------------------------------- 1 | FAQ 2 | ----- 3 | 4 | * 5 | -------------------------------------------------------------------------------- /docs/source/contents/intro.rst: -------------------------------------------------------------------------------- 1 | *************************** 2 | The OpenID Connect Provider 3 | *************************** 4 | 5 | ============ 6 | Introduction 7 | ============ 8 | 9 | This documentation are here to show you how to 'build' an OP using the 10 | classes and functions provided by oidcop. 11 | 12 | OAuth2 and thereby OpenID Connect (OIDC) are built on a request-response paradigm. 13 | The RP issues a request and the OP returns a response. 14 | 15 | The OIDC core standard defines a set of such request-responses. 16 | This is a basic list of request-responses and the normal sequence in which they 17 | occur: 18 | 19 | 1. Provider discovery (WebFinger) 20 | 2. Provider Info Discovery 21 | 3. Client registration 22 | 4. Authorization/Authentication 23 | 5. Access token 24 | 6. User info 25 | 26 | If you are just going to build a standard OP you only have to write the 27 | configuration file and of course add authentication and user consent services. 28 | If you want to add or replace functionality this document should be able to 29 | tell you how. 30 | 31 | Setting up an OP means making a number if decisions. Like, should the OP support 32 | WebFinger_ , `dynamic discovery`_ and/or `dynamic client registration`_ . 33 | 34 | All these are services you can access at endpoints. The total set of endpoints 35 | that this package supports are 36 | 37 | - webfinger 38 | - provider_info 39 | - registration 40 | - authorization 41 | - token 42 | - refresh_token 43 | - userinfo 44 | - end_session 45 | 46 | .. _WebFinger: https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery 47 | .. _dynamic discovery: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig 48 | .. _dynamic client registration: https://openid.net/specs/openid-connect-registration-1_0.html 49 | 50 | =============== 51 | Endpoint layout 52 | =============== 53 | 54 | When an endpoint receives a request it has to do a number of things: 55 | 56 | - Verify that the client can issue the request (client authentication/authorization) 57 | - Verify that the request is correct and that it contains the necessary information. 58 | - Process the request, which includes applying server policies and gathering information. 59 | - Construct the response 60 | 61 | I should note at this point that this package is expected to work within the 62 | confines of a web server framework such that the actual receiving and sending 63 | of the HTTP messages are dealt with by the framework. 64 | 65 | Based on the actions an endpoint has to perform a method call structure 66 | has been constructed. It looks like this: 67 | 68 | 1. parse_request 69 | 70 | - client_authentication (*) 71 | - post_parse_request (*) 72 | 73 | 2. process_request 74 | 75 | 3. do_response 76 | 77 | - response_info 78 | - construct 79 | - pre_construct (*) 80 | - _parse_args 81 | - post_construct (*) 82 | - update_http_args 83 | 84 | Steps marked with '*' are places where extensions can be applied. 85 | 86 | *parse_request* expects as input the request itself in a number of formats and 87 | also, if available, information about client authentication. The later is 88 | normally the authorization element of the HTTP header. 89 | 90 | *do_response* returns a dictionary that can look like this:: 91 | 92 | { 93 | 'response': 94 | _response as a string or as a Message instance_ 95 | 'http_headers': [ 96 | ('Content-type', 'application/json'), 97 | ('Pragma', 'no-cache'), 98 | ('Cache-Control', 'no-store') 99 | ], 100 | 'cookie': _list of cookies_, 101 | 'response_placement': 'body' 102 | } 103 | 104 | cookie 105 | MAY be present 106 | http_headers 107 | MAY be present 108 | http_response 109 | Already clear and formatted HTTP response 110 | response 111 | MUST be present 112 | response_placement 113 | If absent defaults to the endpoints response_placement parameter value or 114 | if that is also missing 'url' 115 | redirect_location 116 | Where to send a redirect 117 | -------------------------------------------------------------------------------- /docs/source/contents/setup.md: -------------------------------------------------------------------------------- 1 | Setup 2 | ----- 3 | 4 | Create an environment 5 | 6 | virtualenv -ppython3 env 7 | source env/bin/activate 8 | 9 | Install 10 | 11 | pip install oidcop 12 | 13 | Get the usage examples 14 | 15 | git clone https://github.com/identitypython/oidc-op.git 16 | cd oidc-op/example/flask_op/ 17 | bash run.sh 18 | 19 | 20 | To configure a standard OIDC Provider you have to edit the oidcop configuration file. 21 | See `example/flask_op/config.json` to get in. 22 | 23 | ~/DEV/IdentityPython/OIDC/oidc-op/example/flask_op$ bash run.sh 24 | 2021-05-02 14:57:44,727 root DEBUG Configured logging using dictionary 25 | 2021-05-02 14:57:44,728 oidcop.configure DEBUG Set server password to {'kty': 'oct', 'use': 'sig', 'k': 'n4G9OjOixYMOotXvP15grwq0peN2zq9I'} 26 | * Serving Flask app "oidc_op" (lazy loading) 27 | * Environment: production 28 | WARNING: This is a development server. Do not use it in a production deployment. 29 | Use a production WSGI server instead. 30 | * Debug mode: on 31 | 2021-05-02 14:57:44,764 werkzeug INFO * Running on https://127.0.0.1:5000/ (Press CTRL+C to quit) 32 | 2021-05-02 14:57:44,765 werkzeug INFO * Restarting with stat 33 | 2021-05-02 14:57:45,011 root DEBUG Configured logging using dictionary 34 | 2021-05-02 14:57:45,011 oidcop.configure DEBUG Set server password to {'kty': 'oct', 'use': 'sig', 'k': 'bceYal7bK9zvlBAA7-23lsi5crcv_8Cd'} 35 | 2021-05-02 14:57:45,037 werkzeug WARNING * Debugger is active! 36 | 2021-05-02 14:57:45,092 werkzeug INFO * Debugger PIN: 560-973-597 37 | 38 | 39 | Then open your browser to `https://127.0.0.1:5000/.well-known/openid-configuration` to get the OpenID Provider Configuration resource. 40 | 41 | 42 | -------------------- 43 | JWK Set (JWKS) files 44 | -------------------- 45 | see: [cryptojwt documentation](https://cryptojwt.readthedocs.io/en/latest/keyhandling.html private/cookie_sign_jwk.json 101 | -------------------------------------------------------------------------------- /docs/source/contents/usage.md: -------------------------------------------------------------------------------- 1 | Usage 2 | ----- 3 | 4 | Some examples, how to run [flask_op](https://github.com/IdentityPython/oidc-op/tree/master/example/flask_op) and [django_op](https://github.com/peppelinux/django-oidc-op) but also some typical configuration in relation to common use cases. 5 | 6 | 7 | 8 | Configure flask-rp 9 | ------------------ 10 | 11 | _JWTConnect-Python-OidcRP_ is Relaing Party for tests, see [related page](https://github.com/openid/JWTConnect-Python-OidcRP). 12 | You can run a working instance of `JWTConnect-Python-OidcRP.flask_rp` with: 13 | 14 | ```` 15 | pip install git+https://github.com/openid/JWTConnect-Python-OidcRP.git 16 | 17 | # get entire project to have examples files 18 | git clone https://github.com/openid/JWTConnect-Python-OidcRP.git 19 | cd JWTConnect-Python-OidcRP/example/flask_rp 20 | 21 | # run it as it come 22 | bash run.sh 23 | ```` 24 | 25 | Now you can connect to `https://127.0.0.1:8090/` to see the RP landing page and select your authentication endpoint. 26 | 27 | ### Authentication examples 28 | 29 | ![RP](../_images/1.png) 30 | 31 | Get to the RP landing page to choose your authentication endpoint. The first option aims to use _Provider Discovery_. 32 | 33 | ---------------------------------- 34 | 35 | ![OP Auth](../_images/2.png) 36 | 37 | The AS/OP supports dynamic client registration, it accepts the authentication request and prompt to us the login form. Read [passwd.json](https://github.com/IdentityPython/oidc-op/blob/master/example/flask_op/passwd.json) file to get credentials. 38 | 39 | ---------------------------------- 40 | 41 | ![Access](../_images/3.png) 42 | 43 | The identity representation with the information fetched from the user info endpoint. 44 | 45 | ---------------------------------- 46 | 47 | ![Logout](../_images/4.png) 48 | 49 | We can even test the single logout 50 | 51 | 52 | Refresh token 53 | ------------- 54 | 55 | To obtain a refresh token, you have to use `response_type=code`, add `offline_access` to `scope` and also use `prompt=consent`, otherwise there will be an error (based on [OpenID Connect specification](https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.11)). 56 | 57 | To refresh a token: 58 | 59 | 60 | import requests 61 | 62 | CLIENT_ID = "DBP60x3KUQfCYWZlqFaS_Q" 63 | CLIENT_SECRET="8526270403788522b2444e87ea90c53bcafb984119cec92eeccc12f1" 64 | REFRESH_TOKEN = "Z0FBQUFBQ ... lN2JNODYtZThjMnFsZUNDcg==" 65 | 66 | data = { 67 | "grant_type" : "refresh_token", 68 | "client_id" : f"{CLIENT_ID}", 69 | "client_secret" : f"{CLIENT_SECRET}", 70 | "refresh_token" : f"{REFRESH_TOKEN}" 71 | } 72 | headers = {'Content-Type': "application/x-www-form-urlencoded" } 73 | response = requests.post( 74 | 'https://127.0.0.1:8000/oidcop/token', verify=False, data=data, headers=headers 75 | ) 76 | 77 | oidc-op will return a json response like this: 78 | 79 | { 80 | 'access_token': 'eyJhbGc ... CIOH_09tT_YVa_gyTqg', 81 | 'token_type': 'Bearer', 82 | 'scope': 'openid profile email address phone offline_access', 83 | 'refresh_token': 'Z0FBQ ... 1TE16cm1Tdg==' 84 | } 85 | 86 | 87 | 88 | Introspection endpoint 89 | ---------------------- 90 | 91 | Here an example about how to consume oidc-op introspection endpoint. 92 | This example uses a client with an HTTP Basic Authentication:: 93 | 94 | import base64 95 | import requests 96 | 97 | TOKEN = "eyJhbGciOiJFUzI1NiIsImtpZCI6IlQwZGZTM1ZVYUcxS1ZubG9VVTQwUXpJMlMyMHpjSHBRYlMxdGIzZ3hZVWhCYzNGaFZWTlpTbWhMTUEifQ.eyJzY29wZSI6IFsib3BlbmlkIiwgInByb2ZpbGUiLCAiZW1haWwiLCAiYWRkcmVzcyIsICJwaG9uZSJdLCAiYXVkIjogWyJvTHlSajdzSkozWHZBWWplRENlOHJRIl0sICJqdGkiOiAiOWQzMjkzYjZiYmNjMTFlYmEzMmU5ODU0MWIwNzE1ZWQiLCAiY2xpZW50X2lkIjogIm9MeVJqN3NKSjNYdkFZamVEQ2U4clEiLCAic3ViIjogIm9MeVJqN3NKSjNYdkFZamVEQ2U4clEiLCAic2lkIjogIlowRkJRVUZCUW1keGJIVlpkRVJKYkZaUFkxQldaa0pQVUVGc1pHOUtWWFZ3VFdkZmVEY3diMVprYmpSamRrNXRMVzB4YTNnelExOHlRbHBHYTNRNVRHZEdUUzF1UW1sMlkzVnhjRE5sUm01dFRFSmxabGRXYVhJeFpFdHVSV2xtUzBKcExWTmFaRzV3VjJodU0yNXlSbTU0U1ZWVWRrWTRRM2x2UWs1TlpVUk9SazlGVlVsRWRteGhjWGx2UWxWRFdubG9WbTFvZGpORlVUSnBkaTFaUTFCcFptZFRabWRDVWt0YVNuaGtOalZCWVhkcGJFNXpaV2xOTTFCMk0yaE1jMDV0ZGxsUlRFc3dObWxsYUcxa1lrTkhkemhuU25OaWFWZE1kVUZzZDBwWFdWbzFiRWhEZFhGTFFXWTBPVzl5VjJOUk4zaGtPRDA9IiwgInR0eXBlIjogIlQiLCAiaXNzIjogImh0dHBzOi8vMTI3LjAuMC4xOjgwMDAiLCAiaWF0IjogMTYyMTc3NzMwNSwgImV4cCI6IDE2MjE3ODA5MDV9.pVqxUNznsoZu9ND18IEMJIHDOT6_HxzoFiTLsniNdbAdXTuOoiaKeRTqtDyjT9WuUPszdHkVjt5xxeFX8gQMuA" 98 | 99 | data = { 100 | 'token': TOKEN, 101 | 'token_type_hint': 'access_token' 102 | } 103 | 104 | _basic_secret = base64.b64encode( 105 | f'{"oLyRj7sJJ3XvAYjeDCe8rQ"}:{"53fb49f2a6501ec775355c89750dc416744a3253138d5a04e409b313"}'.encode() 106 | ) 107 | headers = { 108 | 'Authorization': f"Basic {_basic_secret.decode()}" 109 | } 110 | 111 | requests.post('https://127.0.0.1:8000/introspection', verify=False, data=data, headers=headers) 112 | 113 | 114 | oidc-op will return a json response like this:: 115 | 116 | { 117 | "active": true, 118 | "scope": "openid profile email address phone", 119 | "client_id": "oLyRj7sJJ3XvAYjeDCe8rQ", 120 | "token_type": "access_token", 121 | "exp": 0, 122 | "iat": 1621777305, 123 | "sub": "a7b0dea2958aec275a789d7d7dc8e7d09c6316dd4fc6ae92742ed3297e14dded", 124 | "iss": "https://127.0.0.1:8000", 125 | "aud": [ 126 | "oLyRj7sJJ3XvAYjeDCe8rQ" 127 | ] 128 | } 129 | -------------------------------------------------------------------------------- /docs/source/diagrams/session_relations.mermaid: -------------------------------------------------------------------------------- 1 | erDiagram 2 | Relying-Party ||--|{ Grant : relying_party_id 3 | Relying-Party { 4 | String client_id 5 | String client_salt 6 | String registration_access_token 7 | String registration_client_uri 8 | String client_id_issued_at 9 | String client_secret 10 | datetime client_secret_expires_at 11 | String application_type 12 | List response_types 13 | List contacts 14 | String token_endpoint_auth_method 15 | List response_types 16 | List contacts 17 | String token_endpoint_auth_method 18 | List post_logout_redirect_uris 19 | String jwks_uri 20 | String frontchannel_logout_uri 21 | String frontchannel_logout_session_required 22 | String backchannel_logout_uri 23 | String grant_types 24 | List redirect_uris 25 | } 26 | User ||--o{ Grant : user_id 27 | User { 28 | string username 29 | string firstName 30 | string lastName 31 | string email 32 | } 33 | Grant { 34 | int user_id 35 | int relying_party 36 | String session_key 37 | String token_type 38 | JSon authentication_event 39 | menthod1() 40 | } 41 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Idpy OIDC-op Documentation 2 | ====================================== 3 | 4 | .. image:: _images/oid-l-certification-mark-l-rgb-150dpi-90mm-300x157.png 5 | :width: 300 6 | :alt: OIDC Certified 7 | 8 | This project is a Python implementation of an **OIDC Provider** on top of `jwtconnect.io `_ 9 | that shows you how to 'build' an OP using the classes and functions provided by oidc-op. 10 | 11 | If you are just going to build a standard OP you only have to write the configuration file. If you want to add or replace functionality this documentation 12 | should be able to tell you how. 13 | 14 | Idpy OIDC-op implements the following standards: 15 | 16 | * `OpenID Connect Core 1.0 incorporating errata set 1 `_ 17 | * `Web Finger `_ 18 | * `OpenID Connect Discovery 1.0 incorporating errata set 1 `_ 19 | * `OpenID Connect Dynamic Client Registration 1.0 incorporating errata set 1 `_ 20 | * `OpenID Connect Session Management 1.0 `_ 21 | * `OpenID Connect Back-Channel Logout 1.0 `_ 22 | * `OpenID Connect Front-Channel Logout 1.0 `_ 23 | * `OAuth2 Token introspection `_ 24 | 25 | 26 | It also comes with the following `add_on` modules. 27 | 28 | * Custom scopes, that extends `[OIDC standard ScopeClaims] `_ 29 | * `Proof Key for Code Exchange by OAuth Public Clients (PKCE) `_ 30 | * `OAuth2 PAR `_ 31 | * `OAuth2 RAR `_ 32 | * `OAuth2 DPoP `_ 33 | 34 | The entire project code is open sourced and therefore licensed 35 | under the `Apache 2.0 `_. 36 | 37 | 38 | .. toctree:: 39 | :maxdepth: 2 40 | :caption: Introduction 41 | 42 | contents/intro.rst 43 | 44 | .. toctree:: 45 | :maxdepth: 2 46 | :caption: Setup 47 | 48 | contents/setup.rst 49 | 50 | .. toctree:: 51 | :maxdepth: 2 52 | :caption: Configuration 53 | 54 | contents/conf.rst 55 | 56 | .. toctree:: 57 | :maxdepth: 2 58 | :caption: Usage 59 | 60 | contents/usage.md 61 | 62 | .. toctree:: 63 | :maxdepth: 2 64 | :caption: Session management 65 | 66 | contents/session_management.rst 67 | 68 | .. toctree:: 69 | :maxdepth: 2 70 | :caption: Developer's 71 | 72 | contents/developers.md 73 | 74 | .. toctree:: 75 | :maxdepth: 2 76 | :caption: Client database 77 | 78 | contents/clients.rst 79 | 80 | .. toctree:: 81 | :maxdepth: 2 82 | :caption: FAQ 83 | 84 | contents/faq.md 85 | -------------------------------------------------------------------------------- /example/django_op/README.md: -------------------------------------------------------------------------------- 1 | # django-oidc-op 2 | 3 | The Django oidc-op implementation is available here [django-oidc-op github page](https://github.com/peppelinux/django-oidc-op/tree/develop). 4 | -------------------------------------------------------------------------------- /example/fastapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityPython/oidc-op/2f81e246ff9ef412f5dc786cd2cfa5dd5fccf9e1/example/fastapi/__init__.py -------------------------------------------------------------------------------- /example/fastapi/main.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from fastapi import Depends 5 | from fastapi import FastAPI 6 | from fastapi import HTTPException 7 | from fastapi.logger import logger 8 | from fastapi.openapi.models import Response 9 | from models import AuthorizationRequest 10 | from models import WebFingerRequest 11 | from utils import verify 12 | 13 | from oidcop.exception import FailedAuthentication 14 | from oidcop.server import Server 15 | 16 | logger.setLevel(logging.DEBUG) 17 | 18 | app = FastAPI() 19 | app.server = None 20 | 21 | 22 | def get_app(): 23 | return app 24 | 25 | 26 | @app.on_event("startup") 27 | def op_startup(): 28 | _str = open('config.json').read() 29 | cnf = json.loads(_str) 30 | server = Server(cnf, cwd="/oidc") 31 | app.server = server 32 | 33 | 34 | @app.get("/.well-known/webfinger") 35 | async def well_known(model: WebFingerRequest = Depends()): 36 | endpoint = app.server.server_get("endpoint", "discovery") 37 | args = endpoint.process_request(model.dict()) 38 | response = endpoint.do_response(**args) 39 | resp = json.loads(response["response"]) 40 | return resp 41 | 42 | 43 | @app.get("/.well-known/openid-configuration") 44 | async def openid_config(): 45 | endpoint = app.server.server_get("endpoint", "provider_config") 46 | args = endpoint.process_request() 47 | response = endpoint.do_response(**args) 48 | resp = json.loads(response["response"]) 49 | return resp 50 | 51 | 52 | @app.post('/verify/user', status_code=200) 53 | def verify_user(kwargs: dict, response: Response): 54 | authn_method = app.server.server_get( 55 | "endpoint_context").authn_broker.get_method_by_id('user') 56 | try: 57 | return verify(app, authn_method, kwargs, response) 58 | except FailedAuthentication as exc: 59 | raise HTTPException(404, "Failed authentication") 60 | 61 | 62 | @app.get('/authorization') 63 | def authorization(model: AuthorizationRequest = Depends()): 64 | return service_endpoint(app.server.server_get("endpoint", 'authorization')) 65 | -------------------------------------------------------------------------------- /example/fastapi/models.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | class WebFingerRequest(BaseModel): 8 | rel: Optional[str] = 'http://openid.net/specs/connect/1.0/issuer' 9 | resource: str 10 | 11 | 12 | class AuthorizationRequest(BaseModel): 13 | acr_values: Optional[List[str]] 14 | claims: Optional[dict] 15 | claims_locales: Optional[List[str]] 16 | client_id: str 17 | display: Optional[str] 18 | id_token_hint: Optional[str] 19 | login_hint: Optional[str] 20 | max_age: Optional[int] 21 | nonce: Optional[str] 22 | prompt: Optional[List[str]] 23 | redirect_uri: str 24 | registration: Optional[dict] 25 | request: Optional[str] 26 | request_uri: Optional[str] 27 | response_mode: Optional[str] 28 | response_type: List[str] 29 | scope: List[str] 30 | state: Optional[str] 31 | ui_locales: Optional[List[str]] 32 | -------------------------------------------------------------------------------- /example/fastapi/passwd.json: -------------------------------------------------------------------------------- 1 | { 2 | "diana": "krall", 3 | "babs": "howes", 4 | "upper": "crust" 5 | } -------------------------------------------------------------------------------- /example/fastapi/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "diana": { 3 | "sub": "dikr0001", 4 | "name": "Diana Krall", 5 | "given_name": "Diana", 6 | "family_name": "Krall", 7 | "nickname": "Dina", 8 | "email": "diana@example.org", 9 | "email_verified": false, 10 | "phone_number": "+46 90 7865000", 11 | "address": { 12 | "street_address": "Umeå Universitet", 13 | "locality": "Umeå", 14 | "postal_code": "SE-90187", 15 | "country": "Sweden" 16 | } 17 | }, 18 | "babs": { 19 | "sub": "babs0001", 20 | "name": "Barbara J Jensen", 21 | "given_name": "Barbara", 22 | "family_name": "Jensen", 23 | "nickname": "babs", 24 | "email": "babs@example.com", 25 | "email_verified": true, 26 | "address": { 27 | "street_address": "100 Universal City Plaza", 28 | "locality": "Hollywood", 29 | "region": "CA", 30 | "postal_code": "91608", 31 | "country": "USA" 32 | } 33 | }, 34 | "upper": { 35 | "sub": "uppe0001", 36 | "name": "Upper Crust", 37 | "given_name": "Upper", 38 | "family_name": "Crust", 39 | "email": "uc@example.com", 40 | "email_verified": true 41 | } 42 | } -------------------------------------------------------------------------------- /example/fastapi/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from fastapi import HTTPException 4 | from fastapi import status 5 | from oic.oic import AuthorizationRequest 6 | from oidcmsg.oauth2 import ResponseMessage 7 | 8 | 9 | def do_response(endpoint, req_args, response, error='', **args): 10 | info = endpoint.do_response(request=req_args, error=error, **args) 11 | 12 | try: 13 | _response_placement = info['response_placement'] 14 | except KeyError: 15 | _response_placement = endpoint.response_placement 16 | 17 | if error: 18 | if _response_placement == 'body': 19 | raise HTTPException(400, info['response']) 20 | else: # _response_placement == 'url': 21 | response.status_code = status.HTTP_307_TEMPORARY_REDIRECT 22 | resp = json.loads(info['response']) 23 | else: 24 | if _response_placement == 'body': 25 | resp = json.loads(info['response']) 26 | else: # _response_placement == 'url': 27 | response.status_code = status.HTTP_307_TEMPORARY_REDIRECT 28 | resp = json.loads(info['response']) 29 | 30 | for key, value in info['http_headers']: 31 | response.headers[key] = value 32 | 33 | if 'cookie' in info: 34 | for d in info["cookie"]: 35 | response.set_cookie(key=d["name"], value=d["value"]) 36 | 37 | return resp 38 | 39 | 40 | def verify(app, authn_method, kwargs, response): 41 | """ 42 | Authentication verification 43 | 44 | :param kwargs: response arguments 45 | :return: HTTP redirect 46 | """ 47 | 48 | #kwargs = dict([(k, v) for k, v in request.form.items()]) 49 | username = authn_method.verify(**kwargs) 50 | if not username: 51 | raise HTTPException(403, "Authentication failed") 52 | 53 | auth_args = authn_method.unpack_token(kwargs['token']) 54 | authz_request = AuthorizationRequest().from_urlencoded(auth_args['query']) 55 | 56 | endpoint = app.server.server_get("endpoint", 'authorization') 57 | _session_id = endpoint.create_session(authz_request, username, auth_args['authn_class_ref'], 58 | auth_args['iat'], authn_method) 59 | 60 | args = endpoint.authz_part2(request=authz_request, session_id=_session_id) 61 | 62 | if isinstance(args, ResponseMessage) and 'error' in args: 63 | raise HTTPException(400, args.to_json()) 64 | 65 | return do_response(endpoint, kwargs, response, **args) 66 | 67 | 68 | IGNORE = ["cookie", "user-agent"] 69 | 70 | 71 | def service_endpoint(app, endpoint): 72 | _log = app.srv_config.logger 73 | _log.info('At the "{}" endpoint'.format(endpoint.name)) 74 | 75 | http_info = { 76 | "headers": {k: v for k, v in request.headers.items(lower=True) if k not in IGNORE}, 77 | "method": request.method, 78 | "url": request.url, 79 | # name is not unique 80 | "cookie": [{"name": k, "value": v} for k, v in request.cookies.items()] 81 | } 82 | 83 | if request.method == 'GET': 84 | try: 85 | req_args = endpoint.parse_request(request.args.to_dict(), http_info=http_info) 86 | except (InvalidClient, UnknownClient) as err: 87 | _log.error(err) 88 | return make_response(json.dumps({ 89 | 'error': 'unauthorized_client', 90 | 'error_description': str(err) 91 | }), 400) 92 | except Exception as err: 93 | _log.error(err) 94 | return make_response(json.dumps({ 95 | 'error': 'invalid_request', 96 | 'error_description': str(err) 97 | }), 400) 98 | else: 99 | if request.data: 100 | if isinstance(request.data, str): 101 | req_args = request.data 102 | else: 103 | req_args = request.data.decode() 104 | else: 105 | req_args = dict([(k, v) for k, v in request.form.items()]) 106 | try: 107 | req_args = endpoint.parse_request(req_args, http_info=http_info) 108 | except Exception as err: 109 | _log.error(err) 110 | err_msg = ResponseMessage(error='invalid_request', error_description=str(err)) 111 | return make_response(err_msg.to_json(), 400) 112 | 113 | _log.info('request: {}'.format(req_args)) 114 | if isinstance(req_args, ResponseMessage) and 'error' in req_args: 115 | return make_response(req_args.to_json(), 400) 116 | 117 | try: 118 | if isinstance(endpoint, Token): 119 | args = endpoint.process_request(AccessTokenRequest(**req_args), http_info=http_info) 120 | else: 121 | args = endpoint.process_request(req_args, http_info=http_info) 122 | except Exception as err: 123 | message = traceback.format_exception(*sys.exc_info()) 124 | _log.error(message) 125 | err_msg = ResponseMessage(error='invalid_request', error_description=str(err)) 126 | return make_response(err_msg.to_json(), 400) 127 | 128 | _log.info('Response args: {}'.format(args)) 129 | 130 | if 'redirect_location' in args: 131 | return redirect(args['redirect_location']) 132 | if 'http_response' in args: 133 | return make_response(args['http_response'], 200) 134 | 135 | response = do_response(endpoint, req_args, **args) 136 | return response 137 | -------------------------------------------------------------------------------- /example/flask_op/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:stable-slim 2 | 3 | MAINTAINER Roland Hedberg "roland@catalogix.se" 4 | 5 | COPY . /app 6 | ENV SRCDIR /app/src 7 | 8 | RUN apt-get update && apt-get install -y --no-install-recommends \ 9 | git \ 10 | curl \ 11 | libssl-dev \ 12 | libffi-dev \ 13 | python3-pip \ 14 | python3-setuptools && apt-get clean && rm -rf /var/lib/apt/lists/* 15 | 16 | RUN git clone --depth=1 https://github.com/rohe/oidc-op.git ${SRCDIR}/oidc-op 17 | WORKDIR ${SRCDIR}/oidc-op 18 | RUN python3 setup.py install 19 | 20 | RUN pip3 install ndg-httpsclient 21 | 22 | WORKDIR /app 23 | RUN pip3 install -r requirements.txt 24 | EXPOSE 5000 25 | CMD python3 ./server.py config.yaml 26 | -------------------------------------------------------------------------------- /example/flask_op/README.md: -------------------------------------------------------------------------------- 1 | # OIDC-op example application 2 | To run the Flask based example application, execute the following commands: 3 | 4 | ```bash 5 | cd flask_op/ 6 | pip install -r requirements.txt # install the dependencies 7 | ./server.py config.yaml 8 | ``` -------------------------------------------------------------------------------- /example/flask_op/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityPython/oidc-op/2f81e246ff9ef412f5dc786cd2cfa5dd5fccf9e1/example/flask_op/__init__.py -------------------------------------------------------------------------------- /example/flask_op/application.py: -------------------------------------------------------------------------------- 1 | import os 2 | from urllib.parse import urlparse 3 | 4 | from flask.app import Flask 5 | 6 | from oidcop.server import Server 7 | 8 | folder = os.path.dirname(os.path.realpath(__file__)) 9 | 10 | 11 | def init_oidc_op(app): 12 | _op_config = app.srv_config 13 | 14 | server = Server(_op_config, cwd=folder) 15 | 16 | for endp in server.endpoint.values(): 17 | p = urlparse(endp.endpoint_path) 18 | _vpath = p.path.split('/') 19 | if _vpath[0] == '': 20 | endp.vpath = _vpath[1:] 21 | else: 22 | endp.vpath = _vpath 23 | 24 | return server 25 | 26 | 27 | def oidc_provider_init_app(op_config, name=None, **kwargs): 28 | name = name or __name__ 29 | app = Flask(name, static_url_path='', **kwargs) 30 | app.srv_config = op_config 31 | 32 | try: 33 | from .views import oidc_op_views 34 | except ImportError: 35 | from views import oidc_op_views 36 | 37 | app.register_blueprint(oidc_op_views) 38 | 39 | # Initialize the oidc_provider after views to be able to set correct urls 40 | app.server = init_oidc_op(app) 41 | 42 | return app 43 | -------------------------------------------------------------------------------- /example/flask_op/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 | -------------------------------------------------------------------------------- /example/flask_op/certs/client.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEvjCCAqagAwIBAgIJANUReZOMl8O2MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV 3 | BAMMCTEyNy4wLjAuMTAeFw0yMDAyMDUxOTUyNDZaFw0zMDAyMDIxOTUyNDZaMBQx 4 | EjAQBgNVBAMMCTEyNy4wLjAuMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC 5 | ggIBAJxWxxEGC3dDWTgVNkKf0XvgKpWo8tIQHj78W/qAogki+dPjfmvQWTcv0AEu 6 | P+o9nxUTDb5oWe24/hcqPeCuC6k33bMbq+boEXAeCUSxXCOzefTd6QLw3S4G8coQ 7 | qsH80dGsis9Hr29ubUq1GWWOR1CMMs8gbeIRto5R7FnbMim2Qbnm+KYpOmoo9L8w 8 | EmqFDARowB+OYzu3LDVVaREGs/lURy9YkFbahrrp1KIxYeqp+KMhLiHE4JYBe5p8 9 | hQDnoJNsYZNe0zNWdiB3TRIR0A1mSq4SFnWQqMVPtcfZ6z/FUeGnOohIEeXdf/Nu 10 | RpvqsQy4/WzaXBXxwE910gaH/frEdq3+JBY8mtvNUlaaIfIF1G7V3nJS9GuOSIKR 11 | Rz5sWUgNumexDjEGLMeh1GSYCMtQU3IGLOBj+ZjHjLlNO4AqzNGmyr8Xe9NtA+se 12 | zazOYMyFjRWfA/o80IqIorfc0ifZvD6Y3wmsFi8UPsMVH15QHAiaiiZHMptKOXpr 13 | ChPDiyIIPIcO0AfBkxPPrY27/jtq9Su1JlcXUV7Hq0yWqJC1RhNE34+kKQEt32NQ 14 | T5NhjVF02s8o3s3SH1h9DzJ9wu/3bzjSwYTvX4l7F87RNvd1eH58Fb3PqkTaUxpK 15 | wU8Mbp5WDwJt24YQ31Fwr/dpsiArGjiymjU6Cp+FG5P9FECpAgMBAAGjEzARMA8G 16 | A1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggIBACR4AW0312op+5CZa7ow 17 | fBQ4m+dnsHGmMQFCzFH7EaBej/mTcZ9RV+meNPZ5yvazK6Pr0Daagp1dMsn+YfGu 18 | 8YttyzYdbmNzKq+6OJemaAcn/0MfzQjeP+cjbfW+yTL1Xf+Qfw5MSIrep9izgcEt 19 | 7kYCzDHCxfiLudiirIVxxWPeelN+43SiFnzGz3iISmGHx/UOP+nYfyfKiqzWy+CI 20 | TnQmu+MQMfsAzCO88ZTko61vpXab2nsbX1v1Br8ZKGkDFZ1NoV4EmAV5TyCeHwA5 21 | SuFhWrWID3SvZQHW7W6rlt8hk8kJG2523zjW38A3tdK4M3mxT8F02ru934JDr+DQ 22 | CX9OslO8P1wk4WpbpezAO8pLrtJWDxMTRccIRft9Nqw03QzJ0sS7NPw7IehBHf/z 23 | C/TLre1Mt4OSbowVkNDZztxensIvUHi9lcf8a3GM4hMlkQdk0DFLwVJ3/XSYHnC9 24 | ZDkPiHJyYdR+DUVBlc5mgxUyOWto4pZ9UmVpXtBbDP19jO5X4QKRs9BgiZl2pz0d 25 | mW1LZxkbBJneHlm3/2xZpK2K8jdLjvb3LC0VtGp1aeKAlVjL8yHOaFZeE8lLbx00 26 | L3XueHme3PzU69Leq13XGL1WiCFYGScIP6zBB0JFlkrLQnviXXyGWUAIjVoNRUYh 27 | HuNhZgj3qbjPBXTC3Dkno1d3 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /example/flask_op/certs/client.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCcVscRBgt3Q1k4 3 | FTZCn9F74CqVqPLSEB4+/Fv6gKIJIvnT435r0Fk3L9ABLj/qPZ8VEw2+aFntuP4X 4 | Kj3grgupN92zG6vm6BFwHglEsVwjs3n03ekC8N0uBvHKEKrB/NHRrIrPR69vbm1K 5 | tRlljkdQjDLPIG3iEbaOUexZ2zIptkG55vimKTpqKPS/MBJqhQwEaMAfjmM7tyw1 6 | VWkRBrP5VEcvWJBW2oa66dSiMWHqqfijIS4hxOCWAXuafIUA56CTbGGTXtMzVnYg 7 | d00SEdANZkquEhZ1kKjFT7XH2es/xVHhpzqISBHl3X/zbkab6rEMuP1s2lwV8cBP 8 | ddIGh/36xHat/iQWPJrbzVJWmiHyBdRu1d5yUvRrjkiCkUc+bFlIDbpnsQ4xBizH 9 | odRkmAjLUFNyBizgY/mYx4y5TTuAKszRpsq/F3vTbQPrHs2szmDMhY0VnwP6PNCK 10 | iKK33NIn2bw+mN8JrBYvFD7DFR9eUBwImoomRzKbSjl6awoTw4siCDyHDtAHwZMT 11 | z62Nu/47avUrtSZXF1Fex6tMlqiQtUYTRN+PpCkBLd9jUE+TYY1RdNrPKN7N0h9Y 12 | fQ8yfcLv92840sGE71+JexfO0Tb3dXh+fBW9z6pE2lMaSsFPDG6eVg8CbduGEN9R 13 | cK/3abIgKxo4spo1OgqfhRuT/RRAqQIDAQABAoICAD8xLEGLSfMo+9UZbdc8NjZ2 14 | A4B+y7dw4GjhJGR6vgQnaQfmemEl1Ankf5UalqcdxUGFdBa0ozTdg4blgiFg+EGr 15 | 3SbaVn986h+BZImpju63SuJZGCgiJ6TFFdJxLjQ+9qhjr6/c7+KAphh+XweXnOfH 16 | 43mpSAEK29lm77vaR8poauSzoWm4XG4wo8zrp2X65UKa/J1EtoOapHniThedt/1d 17 | vXA7wgv5RGAkx9fPUh7CGUgGz1jC9WxsqQNmtXQKK/Oq6T3iJEgp+JFi53oYTRo9 18 | cI4vrXhTwoQOlhFz2gzquxAUwin3x5Q8Fc3VkyDOmkXkODtZf8M29l6n5v7Q1S13 19 | oFRLJlkHyCIRJHd4ps1K7o+fStIAg2gSzKJ+lhS5NNCzRbd6zSCIMArxhvEpGscf 20 | px3lnCw8kKNFqNEdcg0YSOOz041vY3adgjVuutJMvGiYZjVLK/DQHnpT2klYhfX4 21 | t4LijvIIqfQDz2bIyGNI3NeUONrm+DJh6ONZV9MvQmQSv2FdEJQIdFG247Tj3W4w 22 | MM28alWqR7Vmj4paPhIrGTWV+Gp2H43tOgPNZLUdKdPp7D+wjG3AghCzPjSRGaLU 23 | CkMxwAUd/w41hrpJjSpjRhT1HVJ/R8vKHNofRbVi+/pmdsj6j2UOO/YtbEO/BVbU 24 | Fu02O7i1ucUjgUMx70cBAoIBAQDLPo8+Jsoy/e/zH9IO7UlUGIPMrlSYfIUd2Y+I 25 | i/iC1nBgXKXvXxvuzA8eJgg/PtTrGuxW4bPwOTVKLvq7A+bGMBtYVta73rmVjC92 26 | pvTm1FWZYZTXwTlN/KQmY8mEm6K4BAx/CeDxCYC7V0KLqmH+4J7pOuDTuSQLYx2e 27 | f0uFyKsRxd9svzlcSB1XC5tvqERzkeGUGaunnpJ19O/G64kDXaaj6xdlexOPD5kV 28 | lSAqfB0Rd4UKo5t8fJxZgmUgxGSq85y1zYBJ9kJ0IpqE3e28fs8MKthU7cwhIkya 29 | qzq0b6s1ATBH8EhQaDn31eLVCGvH5VWUE+R8KH7hKXhPRiAZAoIBAQDE62N3WTM9 30 | CJ5fiVBnvHBk7QGJAAPzgtp+Enix8WrptXarPHKGoYBPhS6OD7WfZRZSOBAMFkFC 31 | 5q3XguefFBGrECOtTX2Zlw0dSr9gyiV6vofYmii5y+fW9uYSQqtJqmf/b8ION9en 32 | PQiD0XZeXQmaWU8gJt1j+2oe5SfWgQknV9uN4nwkAjjnDtTjAB1nZFgh0pm6w8hn 33 | MzxwR71CspHSOvy1OuWd926qi1vVVBV0u+FWiHr9s3Psw3IUmcXtvXegPXVNYKcZ 34 | JVtQ5dwQIHwVFpkLMkdU3qcNua60KoWtPR1bTBnrY9Um6dGqRPj3xENJCe18uHZq 35 | 3V1MgHPSlPcRAoIBAHP4T13EXm24LflJN9/ij4vXrSTWeFjF/GLq6Bae338wgtDJ 36 | LLmoSFT6xMmMI/qKjI1WQHLWuIii7ABXTCP39u8xNfkzG7X5QWXOpqqKW4V2tR0e 37 | 7AIsM9mHBdcN60eqUq+zR7oZVevTY0wCX2s4HlCDtMkaGn1Uz/dbZ+QveFVvCgXL 38 | JVB00HMShwNLETcmCWD0ZYXPG/454hJCX6rebMCp6FLx6tix4Jgp60zAWalERoXX 39 | 7+cBMdBXfhMo7zFCPrq45Ltr9f698G8563dS9rsulE+6BtR3F5n0a0d52rZoXYWS 40 | Fw2FUo3m2uTKe3LZKj7WYf1rWF1r8fHias65EckCggEAdSN1CsT0FuVumHQtcVgG 41 | H/Ngi2eH8i4v3PkN9QQgTiAVFG2jzvR5SFR4SieMKeJPMd+JpDcE4VApr15+fAHL 42 | NNAn+Op0wY26Tmdtip0VSHvYgX/KpCNoqVY7rDcef3av4KJRdHXBgglrbEaIvD9p 43 | +/gOepjD77rZ+MDmPtKJaG75+t/0atMrmD8ZYmNqGlv1lUEbE59tMf6ngD8clXV2 44 | CvHt67y6ZIqQuUCnAzK+hK9Sr1AGoa5DUl89GIYU2IRxic+lXL8XB31SYcAqdSlt 45 | xnmn1qI1DoZYJ3ECPMhitpf5Q5r0fDLp0/kZMMlQtMp6IBOYwy1Tu+QsoNp0i5rz 46 | kQKCAQEArcb4t223KvDZVvMFIdeBTsdwbcocqVn1RLe5oomTBFtZNHWwQYtK62bm 47 | zfiEugOONOgI61jB6l2hW/kcJZP2Z68l+V/pcUvsHGEdTl98tKWuW5DZ2jRaT6D8 48 | ENGUzPhlsK/7mshyQRZHYGrH+wU/56WAaEGWZxl9P+XvvU1rNRJgbfnqcdY/1oqU 49 | uzguQf0xb42eoLKDJ/klLmH5uHipoZOUhMHqIFJi+6vBMy+nN6vYdizKcNwO0T6c 50 | q1AYwHhwyWnNIInI3m2WL32YdZIbSc0FnZlcqnuTcrPuVTOPisOAMg99EF2Gmb3L 51 | ZCZCLsaKMvisUtAXXmxvw8BM2txzoQ== 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /example/flask_op/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 | -------------------------------------------------------------------------------- /example/flask_op/passwd.json: -------------------------------------------------------------------------------- 1 | { 2 | "diana": "krall", 3 | "babs": "howes", 4 | "upper": "crust" 5 | } -------------------------------------------------------------------------------- /example/flask_op/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask -------------------------------------------------------------------------------- /example/flask_op/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | python3 server.py config.json 4 | -------------------------------------------------------------------------------- /example/flask_op/server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import json 4 | import logging 5 | import os 6 | 7 | from oidcmsg.configure import Configuration 8 | from oidcmsg.configure import create_from_config_file 9 | 10 | from oidcop.configure import OPConfiguration 11 | from oidcop.utils import create_context 12 | 13 | try: 14 | from .application import oidc_provider_init_app 15 | except (ModuleNotFoundError, ImportError): 16 | from application import oidc_provider_init_app 17 | 18 | dir_path = os.path.dirname(os.path.realpath(__file__)) 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | # class PeerCertWSGIRequestHandler(werkzeug.serving.WSGIRequestHandler): 24 | # """ 25 | # We subclass this class so that we can gain access to the connection 26 | # property. self.connection is the underlying client socket. When a TLS 27 | # connection is established, the underlying socket is an instance of 28 | # SSLSocket, which in turn exposes the getpeercert() method. 29 | # 30 | # The output from that method is what we want to make available elsewhere 31 | # in the application. 32 | # """ 33 | # 34 | # def make_environ(self): 35 | # """ 36 | # The superclass method develops the environ hash that eventually 37 | # forms part of the Flask request object. 38 | # 39 | # We allow the superclass method to run first, then we insert the 40 | # peer certificate into the hash. That exposes it to us later in 41 | # the request variable that Flask provides 42 | # """ 43 | # environ = super(PeerCertWSGIRequestHandler, self).make_environ() 44 | # x509_binary = self.connection.getpeercert(True) 45 | # if x509_binary: 46 | # x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_ASN1, x509_binary) 47 | # environ['peercert'] = x509 48 | # else: 49 | # logger.warning('No peer certificate') 50 | # environ['peercert'] = '' 51 | # return environ 52 | 53 | 54 | def main(config_file, args): 55 | logging.basicConfig(level=logging.DEBUG) 56 | config = create_from_config_file(Configuration, 57 | entity_conf=[{ 58 | "class": OPConfiguration, "attr": "op", 59 | "path": ["op", "server_info"] 60 | }], 61 | filename=config_file, 62 | base_path=dir_path) 63 | app = oidc_provider_init_app(config.op, 'oidc_op') 64 | app.logger = config.logger 65 | 66 | web_conf = config.web_conf 67 | 68 | context = create_context(dir_path, web_conf) 69 | 70 | if args.display: 71 | print(json.dumps(app.endpoint_context.provider_info, indent=4, sort_keys=True)) 72 | exit(0) 73 | 74 | kwargs = {} 75 | if context: 76 | kwargs["ssl_context"] = context 77 | # kwargs["request_handler"] = PeerCertWSGIRequestHandler 78 | 79 | app.run(host=web_conf['domain'], port=web_conf['port'], debug=web_conf['debug'], **kwargs) 80 | 81 | 82 | if __name__ == '__main__': 83 | parser = argparse.ArgumentParser() 84 | parser.add_argument('-d', dest='display', action='store_true') 85 | parser.add_argument('-t', dest='tls', action='store_true') 86 | parser.add_argument('-k', dest='insecure', action='store_true') 87 | parser.add_argument(dest="config") 88 | args = parser.parse_args() 89 | main(args.config, args) 90 | -------------------------------------------------------------------------------- /example/flask_op/templates/check_session_iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Session Management - OP iframe 6 | 9 | 10 | 11 | 12 | 13 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /example/flask_op/templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Error: {{ title }}

5 | 6 | {% if redirect_url is defined %} 7 |

Continue

8 | {% else %} 9 | {% endif %} 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /example/flask_op/templates/frontchannel_logout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Logout 5 | 6 | 7 | 10 | 11 | 12 | 29 | {{ frames|safe }} 30 | 31 | -------------------------------------------------------------------------------- /example/flask_op/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 |

Hi There!

9 | 10 | -------------------------------------------------------------------------------- /example/flask_op/templates/logout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Logout Request 5 | 7 | 8 | 59 | 60 | 61 |
62 |

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

63 | 79 |
81 |
83 | 84 | 85 |
86 | 87 | -------------------------------------------------------------------------------- /example/flask_op/templates/post_logout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Post Logout 6 | 7 | 8 |

You have now been logged out from this server!

9 | 10 | 11 | -------------------------------------------------------------------------------- /example/flask_op/templates/user_pass.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Please login 7 | 8 | 9 | 10 |

{{ page_header }}

11 | 12 |
13 | 14 | 15 |

16 | 17 | 19 |

20 | 21 |

22 | 23 | 24 |

25 | 26 |

27 | {{ logo_label }} 28 |

29 |

30 | {{ tos_label }} 31 |

32 |

33 | {{ policy_label }} 34 |

35 | 36 | 37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /example/flask_op/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "diana": { 3 | "sub": "dikr0001", 4 | "name": "Diana Krall", 5 | "given_name": "Diana", 6 | "family_name": "Krall", 7 | "nickname": "Dina", 8 | "email": "diana@example.org", 9 | "email_verified": false, 10 | "phone_number": "+46 90 7865000", 11 | "address": { 12 | "street_address": "Umeå Universitet", 13 | "locality": "Umeå", 14 | "postal_code": "SE-90187", 15 | "country": "Sweden" 16 | } 17 | }, 18 | "babs": { 19 | "sub": "babs0001", 20 | "name": "Barbara J Jensen", 21 | "given_name": "Barbara", 22 | "family_name": "Jensen", 23 | "nickname": "babs", 24 | "email": "babs@example.com", 25 | "email_verified": true, 26 | "address": { 27 | "street_address": "100 Universal City Plaza", 28 | "locality": "Hollywood", 29 | "region": "CA", 30 | "postal_code": "91608", 31 | "country": "USA" 32 | } 33 | }, 34 | "upper": { 35 | "sub": "uppe0001", 36 | "name": "Upper Crust", 37 | "given_name": "Upper", 38 | "family_name": "Crust", 39 | "email": "uc@example.com", 40 | "email_verified": true 41 | } 42 | } -------------------------------------------------------------------------------- /example/flask_op/yaml_to_json.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | import json 3 | import sys 4 | 5 | import yaml 6 | 7 | """Load a YAML configuration file.""" 8 | with open(sys.argv[1], "rt", encoding='utf-8') as file: 9 | config_dict = yaml.safe_load(file) 10 | 11 | print(json.dumps(config_dict)) 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [metadata] 9 | name = "oidcop" 10 | version = "2.4.3" 11 | author = "Roland Hedberg" 12 | author_email = "roland@catalogix.se" 13 | description = "Python implementation of an OAuth2 AS and an OIDC Provider" 14 | long_description = "file: README.md" 15 | long_description_content_type = "text/markdown" 16 | url = "https://github.com/IdentityPython/oidc-op" 17 | license = "Apache-2.0" 18 | classifiers =[ 19 | "Programming Language :: Python :: 3", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent" 22 | ] 23 | 24 | [options] 25 | package_dir = "src" 26 | packages = "find:" 27 | python= "^3.6" 28 | 29 | [tool.black] 30 | line-length = 100 31 | 32 | [tool.isort] 33 | force_single_line = true 34 | known_first_party = "oidcop" 35 | include_trailing_comma = true 36 | force_grid_wrap = 0 37 | use_parentheses = true 38 | line_length = 100 39 | 40 | [tool.coverage.run] 41 | branch = true 42 | 43 | [tool.coverage.report] 44 | exclude_lines = [ 45 | "pragma: no cover", 46 | "raise NotImplementedError", 47 | ] 48 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest>=6.2.2 2 | pytest-black>=0.3.12 3 | pytest-cov>=2.11.1 4 | pytest-isort>=1.3.0 5 | pytest-localserver>=0.5.0 6 | flake8 7 | bandit 8 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | recommonmark 3 | sphinx_rtd_theme 4 | sphinxcontrib-images 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | oidcmsg>=1.4.0 2 | pyyaml 3 | jinja2>=2.11.3 4 | responses>=0.13.0 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (C) 2019 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 TestCommand 23 | 24 | __author__ = 'Roland Hedberg' 25 | 26 | 27 | class PyTest(TestCommand): 28 | def finalize_options(self): 29 | TestCommand.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 | extra_install_requires = [] 42 | 43 | with open('src/oidcop/__init__.py', 'r') as fd: 44 | version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', 45 | fd.read(), re.MULTILINE).group(1) 46 | 47 | with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme: 48 | README = readme.read() 49 | 50 | 51 | setup( 52 | name="oidcop", 53 | version=version, 54 | description="Python implementation of OIDC Provider", 55 | long_description=README, 56 | long_description_content_type='text/markdown', 57 | author="Roland Hedberg", 58 | author_email="roland@catalogix.se", 59 | license="Apache 2.0", 60 | url='https://github.com/IdentityPython/oidc-op', 61 | package_dir={"": "src"}, 62 | packages=["oidcop", 'oidcop/oidc', 'oidcop/authz', 63 | 'oidcop/user_authn', 'oidcop/user_info', 64 | 'oidcop/oauth2', 'oidcop/oidc/add_on', 'oidcop/oauth2/add_on', 65 | 'oidcop/session', 'oidcop/token'], 66 | classifiers=[ 67 | "Development Status :: 5 - Production/Stable", 68 | "License :: OSI Approved :: Apache Software License", 69 | "Programming Language :: Python :: 3.6", 70 | "Programming Language :: Python :: 3.7", 71 | "Programming Language :: Python :: 3.8", 72 | "Programming Language :: Python :: 3.9", 73 | "Topic :: Software Development :: Libraries :: Python Modules"], 74 | install_requires=[ 75 | "oidcmsg==1.5.4", 76 | "pyyaml", 77 | "jinja2>=2.11.3", 78 | "responses>=0.13.0" 79 | ], 80 | zip_safe=False, 81 | cmdclass={'test': PyTest}, 82 | ) 83 | -------------------------------------------------------------------------------- /src/oidcop/__init__.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | __version__ = "2.4.3" 4 | 5 | DEF_SIGN_ALG = { 6 | "id_token": "RS256", 7 | "userinfo": "RS256", 8 | "request_object": "RS256", 9 | "client_secret_jwt": "HS256", 10 | "private_key_jwt": "RS256", 11 | } 12 | 13 | HTTP_ARGS = ["headers", "redirections", "connection_type"] 14 | 15 | JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 16 | 17 | URL_ENCODED = "application/x-www-form-urlencoded" 18 | JSON_ENCODED = "application/json" 19 | JOSE_ENCODED = "application/jose" 20 | 21 | 22 | def sanitize(txt): 23 | return txt 24 | 25 | 26 | def rndstr(size=16): 27 | """ 28 | Returns a string of random url safe characters 29 | 30 | :param size: The length of the string 31 | :return: string 32 | """ 33 | return secrets.token_urlsafe(size) 34 | # chars = string.ascii_letters + string.digits 35 | # return "".join(choice(chars) for i in range(size)) 36 | -------------------------------------------------------------------------------- /src/oidcop/authn_event.py: -------------------------------------------------------------------------------- 1 | from oidcmsg.message import SINGLE_OPTIONAL_INT 2 | from oidcmsg.message import SINGLE_OPTIONAL_STRING 3 | from oidcmsg.message import SINGLE_REQUIRED_STRING 4 | from oidcmsg.message import Message 5 | from oidcmsg.time_util import utc_time_sans_frac 6 | 7 | DEFAULT_AUTHN_EXPIRES_IN = 3600 8 | 9 | 10 | class AuthnEvent(Message): 11 | c_param = { 12 | "uid": SINGLE_REQUIRED_STRING, 13 | "authn_info": SINGLE_REQUIRED_STRING, 14 | "authn_time": SINGLE_OPTIONAL_INT, 15 | "valid_until": SINGLE_OPTIONAL_INT, 16 | "sub": SINGLE_OPTIONAL_STRING, 17 | } 18 | 19 | def is_valid(self, now=0): 20 | if now: 21 | return self["valid_until"] > now 22 | else: 23 | return self["valid_until"] > utc_time_sans_frac() 24 | 25 | def expires_in(self): 26 | return self["valid_until"] - utc_time_sans_frac() 27 | 28 | 29 | def create_authn_event( 30 | uid, 31 | authn_info=None, 32 | authn_time: int = 0, 33 | valid_until: int = 0, 34 | expires_in: int = 0, 35 | sub: str = "", 36 | **kwargs 37 | ): 38 | """ 39 | 40 | :param uid: User ID. This is the identifier used by the user DB 41 | :param authn_time: When the authentication took place 42 | :param authn_info: Information about the authentication 43 | :param valid_until: Until when the authentication is valid 44 | :param expires_in: How long before the authentication expires 45 | :param sub: Subject identifier. The identifier for the user used between 46 | the AS and the RP. 47 | :param kwargs: 48 | :return: 49 | """ 50 | args = {"uid": uid, "authn_info": authn_info} 51 | 52 | if sub: 53 | args["sub"] = sub 54 | 55 | if authn_time: 56 | args["authn_time"] = authn_time 57 | else: 58 | _ts = kwargs.get("timestamp") 59 | if _ts: 60 | args["authn_time"] = _ts 61 | else: 62 | args["authn_time"] = utc_time_sans_frac() 63 | 64 | if valid_until: 65 | args["valid_until"] = valid_until 66 | else: 67 | if expires_in: 68 | args["valid_until"] = args["authn_time"] + expires_in 69 | else: 70 | args["valid_until"] = args["authn_time"] + DEFAULT_AUTHN_EXPIRES_IN 71 | 72 | return AuthnEvent(**args) 73 | -------------------------------------------------------------------------------- /src/oidcop/authz/__init__.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import inspect 3 | import logging 4 | import sys 5 | from typing import Optional 6 | from typing import Union 7 | 8 | from oidcmsg.message import Message 9 | 10 | from oidcop.session.grant import Grant 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class AuthzHandling(object): 16 | """ Class that allow an entity to manage authorization """ 17 | 18 | def __init__(self, server_get, grant_config=None, **kwargs): 19 | self.server_get = server_get 20 | self.grant_config = grant_config or {} 21 | self.kwargs = kwargs 22 | 23 | def usage_rules(self, client_id: Optional[str] = ""): 24 | if "usage_rules" in self.grant_config: 25 | _usage_rules = copy.deepcopy(self.grant_config["usage_rules"]) 26 | else: 27 | _usage_rules = {} 28 | 29 | if not client_id: 30 | return _usage_rules 31 | 32 | try: 33 | _per_client = self.server_get("endpoint_context").cdb[client_id]["token_usage_rules"] 34 | except KeyError: 35 | pass 36 | else: 37 | if _usage_rules: 38 | for _token_type, _rule in _usage_rules.items(): 39 | _pc = _per_client.get(_token_type) 40 | if _pc: 41 | _rule.update(_pc) 42 | elif _pc == {}: 43 | _usage_rules[_token_type] = {} 44 | for _token_type, _rule in _per_client.items(): 45 | if _token_type not in _usage_rules: 46 | _usage_rules[_token_type] = _rule 47 | else: 48 | _usage_rules = _per_client 49 | 50 | return _usage_rules 51 | 52 | def usage_rules_for(self, client_id, token_type): 53 | _token_usage = self.usage_rules(client_id=client_id) 54 | try: 55 | return _token_usage[token_type] 56 | except KeyError: 57 | return {} 58 | 59 | def __call__( 60 | self, 61 | session_id: str, 62 | request: Union[dict, Message], 63 | resources: Optional[list] = None, 64 | ) -> Grant: 65 | session_info = self.server_get("endpoint_context").session_manager.get_session_info( 66 | session_id=session_id, grant=True 67 | ) 68 | grant = session_info["grant"] 69 | 70 | args = self.grant_config.copy() 71 | 72 | for key, val in args.items(): 73 | if key == "expires_in": 74 | grant.set_expires_at(val) 75 | elif key == "usage_rules": 76 | setattr(grant, key, self.usage_rules(request.get("client_id"))) 77 | else: 78 | setattr(grant, key, val) 79 | 80 | if resources is None: 81 | grant.resources = [session_info["client_id"]] 82 | else: 83 | grant.resources = resources 84 | 85 | # After this is where user consent should be handled 86 | scopes = grant.scope 87 | if not scopes: 88 | scopes = request.get("scope", []) 89 | grant.scope = scopes 90 | grant.claims = self.server_get("endpoint_context").claims_interface.get_claims_all_usage( 91 | session_id=session_id, scopes=scopes 92 | ) 93 | 94 | return grant 95 | 96 | 97 | class Implicit(AuthzHandling): 98 | def __call__( 99 | self, 100 | session_id: str, 101 | request: Union[dict, Message], 102 | resources: Optional[list] = None, 103 | ) -> Grant: 104 | args = self.grant_config.copy() 105 | grant = self.server_get("endpoint_context").session_manager.get_grant(session_id=session_id) 106 | for arg, val in args: 107 | setattr(grant, arg, val) 108 | return grant 109 | 110 | 111 | def factory(msgtype, server_get, **kwargs): 112 | """ 113 | Factory method that can be used to easily instantiate a class instance 114 | 115 | :param msgtype: The name of the class 116 | :param kwargs: Keyword arguments 117 | :return: An instance of the class or None if the name doesn't match any 118 | known class. 119 | """ 120 | for name, obj in inspect.getmembers(sys.modules[__name__]): 121 | if inspect.isclass(obj) and issubclass(obj, AuthzHandling): 122 | try: 123 | if obj.__name__ == msgtype: 124 | return obj(server_get, **kwargs) 125 | except AttributeError: 126 | pass 127 | -------------------------------------------------------------------------------- /src/oidcop/constant.py: -------------------------------------------------------------------------------- 1 | DIVIDER = ";;" 2 | 3 | DEFAULT_TOKEN_LIFETIME = 1800 4 | -------------------------------------------------------------------------------- /src/oidcop/construct.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | 4 | from functools import cmp_to_key 5 | 6 | from cryptojwt import jwe 7 | from cryptojwt.jws.jws import SIGNER_ALGS 8 | 9 | ALG_SORT_ORDER = {"RS": 0, "ES": 1, "HS": 2, "PS": 3, "no": 4} 10 | WEAK_ALGS = ["RSA1_5", "none"] 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def sort_sign_alg(alg1, alg2): 16 | if ALG_SORT_ORDER[alg1[0:2]] < ALG_SORT_ORDER[alg2[0:2]]: 17 | return -1 18 | 19 | if ALG_SORT_ORDER[alg1[0:2]] > ALG_SORT_ORDER[alg2[0:2]]: 20 | return 1 21 | 22 | if alg1 < alg2: 23 | return -1 24 | 25 | if alg1 > alg2: 26 | return 1 27 | 28 | return 0 29 | 30 | 31 | def assign_algorithms(typ): 32 | if typ == "signing_alg": 33 | # Pick supported signing algorithms from crypto library 34 | # Sort order RS, ES, HS, PS 35 | sign_algs = list(SIGNER_ALGS.keys()) 36 | return sorted(sign_algs, key=cmp_to_key(sort_sign_alg)) 37 | elif typ == "encryption_alg": 38 | return jwe.SUPPORTED["alg"] 39 | elif typ == "encryption_enc": 40 | return jwe.SUPPORTED["enc"] 41 | 42 | 43 | def construct_endpoint_info(default_capabilities, **kwargs): 44 | if default_capabilities is None: 45 | return default_capabilities 46 | 47 | _info = {} 48 | for attr, default_val in default_capabilities.items(): 49 | if attr in kwargs: 50 | _proposal = kwargs[attr] 51 | _permitted = None 52 | 53 | if "signing_alg_values_supported" in attr: 54 | _permitted = set(assign_algorithms("signing_alg")) 55 | elif "encryption_alg_values_supported" in attr: 56 | _permitted = set(assign_algorithms("encryption_alg")) 57 | elif "encryption_enc_values_supported" in attr: 58 | _permitted = set(assign_algorithms("encryption_enc")) 59 | 60 | if _permitted and not _permitted.issuperset(set(_proposal)): 61 | raise ValueError( 62 | "Proposed set of values outside set of permitted, " 63 | f"'{attr}' sould be {_permitted} it's instead {_proposal}" 64 | ) 65 | _info[attr] = _proposal 66 | else: 67 | if default_val is not None: 68 | _info[attr] = default_val 69 | elif "signing_alg_values_supported" in attr: 70 | _info[attr] = assign_algorithms("signing_alg") 71 | if "none" in _info[attr]: 72 | _info[attr].remove("none") 73 | elif "encryption_alg_values_supported" in attr: 74 | # RSA1_5 not among defaults 75 | _info[attr] = [s for s in assign_algorithms("encryption_alg") if s not in WEAK_ALGS] 76 | elif "encryption_enc_values_supported" in attr: 77 | _info[attr] = assign_algorithms("encryption_enc") 78 | 79 | if re.match(r".*(alg|enc).*_values_supported", attr): 80 | for i in _info[attr]: 81 | if i in WEAK_ALGS: 82 | logger.warning( 83 | f"Found {i} in {attr}. This is a weak algorithm " 84 | "that MUST not be used in production!" 85 | ) 86 | return _info 87 | -------------------------------------------------------------------------------- /src/oidcop/exception.py: -------------------------------------------------------------------------------- 1 | class OidcOPError(Exception): 2 | pass 3 | 4 | 5 | class OidcEndpointError(OidcOPError): 6 | pass 7 | 8 | 9 | class InvalidRedirectURIError(OidcEndpointError): 10 | pass 11 | 12 | 13 | class InvalidSectorIdentifier(OidcEndpointError): 14 | pass 15 | 16 | 17 | class ConfigurationError(OidcEndpointError): 18 | pass 19 | 20 | 21 | class NoSuchAuthentication(OidcEndpointError): 22 | pass 23 | 24 | 25 | class TamperAllert(OidcEndpointError): 26 | pass 27 | 28 | 29 | class ToOld(OidcEndpointError): 30 | pass 31 | 32 | 33 | class MultipleUsage(OidcEndpointError): 34 | pass 35 | 36 | 37 | class FailedAuthentication(OidcEndpointError): 38 | pass 39 | 40 | 41 | class InstantiationError(OidcEndpointError): 42 | pass 43 | 44 | 45 | class ImproperlyConfigured(OidcEndpointError): 46 | pass 47 | 48 | 49 | class NotForMe(OidcEndpointError): 50 | pass 51 | 52 | 53 | class UnknownAssertionType(OidcEndpointError): 54 | pass 55 | 56 | 57 | class RedirectURIError(OidcEndpointError): 58 | pass 59 | 60 | 61 | class ClientAuthenticationError(OidcEndpointError): 62 | pass 63 | 64 | 65 | class UnknownClient(ClientAuthenticationError): 66 | pass 67 | 68 | 69 | class InvalidClient(ClientAuthenticationError): 70 | pass 71 | 72 | 73 | class UnAuthorizedClient(ClientAuthenticationError): 74 | pass 75 | 76 | 77 | class BearerTokenAuthenticationError(OidcEndpointError): 78 | pass 79 | 80 | 81 | class UnAuthorizedClientScope(OidcEndpointError): 82 | pass 83 | 84 | 85 | class InvalidCookieSign(Exception): 86 | pass 87 | 88 | 89 | class OnlyForTestingWarning(Warning): 90 | "Warned when using a feature that only should be used for testing." 91 | 92 | 93 | class ProcessError(OidcEndpointError): 94 | pass 95 | 96 | 97 | class ServiceError(OidcEndpointError): 98 | pass 99 | 100 | 101 | class InvalidRequest(OidcEndpointError): 102 | pass 103 | 104 | 105 | class CapabilitiesMisMatch(OidcEndpointError): 106 | pass 107 | 108 | 109 | class MultipleCodeUsage(OidcEndpointError): 110 | pass 111 | -------------------------------------------------------------------------------- /src/oidcop/logging.py: -------------------------------------------------------------------------------- 1 | """Common logging functions""" 2 | import logging 3 | import os 4 | from logging.config import dictConfig 5 | from typing import Optional 6 | 7 | import yaml 8 | 9 | LOGGING_CONF = "logging.yaml" 10 | 11 | LOGGING_DEFAULT = { 12 | "version": 1, 13 | "formatters": {"default": {"format": "%(asctime)s %(name)s %(levelname)s %(message)s"}}, 14 | "handlers": {"default": {"class": "logging.StreamHandler", "formatter": "default"}}, 15 | "root": {"handlers": ["default"], "level": "INFO"}, 16 | } 17 | 18 | 19 | def configure_logging( 20 | debug: Optional[bool] = False, 21 | config: Optional[dict] = None, 22 | filename: Optional[str] = "", 23 | ) -> logging.Logger: 24 | """Configure logging""" 25 | 26 | if config is not None: 27 | config_dict = config 28 | config_source = "dictionary" 29 | elif filename and os.path.exists(filename): 30 | with open(filename, "rt") as file: 31 | config_dict = yaml.safe_load(file) 32 | config_source = "file" 33 | else: 34 | config_dict = LOGGING_DEFAULT 35 | config_source = "default" 36 | 37 | if debug: 38 | config_dict["root"]["level"] = "DEBUG" 39 | 40 | dictConfig(config_dict) 41 | logger = logging.getLogger() 42 | logger.debug("Configured logging using: {}".format(config_source)) 43 | return logger 44 | -------------------------------------------------------------------------------- /src/oidcop/login_hint.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse 2 | 3 | 4 | class LoginHintLookup(object): 5 | def __init__(self, userinfo=None, server_get=None): 6 | self.userinfo = userinfo 7 | self.default_country_code = "46" 8 | self.server_get = server_get 9 | 10 | def __call__(self, arg): 11 | if arg.startswith("tel:"): 12 | _pnr = arg[4:] 13 | if _pnr[0] == "+": 14 | pass 15 | else: 16 | _pnr = "+" + self.default_country_code + _pnr[1:] 17 | return self.userinfo.search(phone_number=_pnr) 18 | elif arg.startswith("mail:"): 19 | _mail = arg[5:] 20 | return self.userinfo.search(email=_mail) 21 | 22 | 23 | class LoginHint2Acrs(object): 24 | """ 25 | OIDC Login hint support 26 | """ 27 | 28 | def __init__(self, scheme_map, server_get=None): 29 | self.scheme_map = scheme_map 30 | self.server_get = server_get 31 | 32 | def __call__(self, hint): 33 | p = urlparse(hint) 34 | try: 35 | return self.scheme_map[p.scheme] 36 | except KeyError: 37 | return [] 38 | -------------------------------------------------------------------------------- /src/oidcop/oauth2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityPython/oidc-op/2f81e246ff9ef412f5dc786cd2cfa5dd5fccf9e1/src/oidcop/oauth2/__init__.py -------------------------------------------------------------------------------- /src/oidcop/oauth2/add_on/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityPython/oidc-op/2f81e246ff9ef412f5dc786cd2cfa5dd5fccf9e1/src/oidcop/oauth2/add_on/__init__.py -------------------------------------------------------------------------------- /src/oidcop/oauth2/add_on/dpop.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from cryptojwt import JWS 4 | from cryptojwt import as_unicode 5 | from cryptojwt.jwk.jwk import key_from_jwk_dict 6 | from cryptojwt.jws.jws import factory 7 | from oidcmsg.message import SINGLE_REQUIRED_INT 8 | from oidcmsg.message import SINGLE_REQUIRED_JSON 9 | from oidcmsg.message import SINGLE_REQUIRED_STRING 10 | from oidcmsg.message import Message 11 | 12 | from oidcop.client_authn import AuthnFailure 13 | from oidcop.client_authn import ClientAuthnMethod 14 | from oidcop.client_authn import basic_authn 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 | _headers = {k: self[k] for k in self.header_params} 62 | self.key.kid = "" 63 | _sjwt = _jws.sign_compact(keys=[self.key], **_headers) 64 | return _sjwt 65 | 66 | def verify_header(self, dpop_header) -> Optional["DPoPProof"]: 67 | _jws = factory(dpop_header) 68 | if _jws: 69 | _jwt = _jws.jwt 70 | if "jwk" in _jwt.headers: 71 | _pub_key = key_from_jwk_dict(_jwt.headers["jwk"]) 72 | _pub_key.deserialize() 73 | _info = _jws.verify_compact(keys=[_pub_key], sigalg=_jwt.headers["alg"]) 74 | for k, v in _jwt.headers.items(): 75 | self[k] = v 76 | 77 | for k, v in _info.items(): 78 | self[k] = v 79 | else: 80 | raise Exception() 81 | 82 | return self 83 | else: 84 | return None 85 | 86 | 87 | def post_parse_request(request, client_id, endpoint_context, **kwargs): 88 | """ 89 | Expect http_info attribute in kwargs. http_info should be a dictionary 90 | containing HTTP information. 91 | 92 | :param request: 93 | :param client_id: 94 | :param endpoint_context: 95 | :param kwargs: 96 | :return: 97 | """ 98 | 99 | _http_info = kwargs.get("http_info") 100 | if not _http_info: 101 | return request 102 | 103 | _dpop = DPoPProof().verify_header(_http_info["headers"]["dpop"]) 104 | 105 | # The signature of the JWS is verified, now for checking the 106 | # content 107 | 108 | if _dpop["htu"] != _http_info["url"]: 109 | raise ValueError("htu in DPoP does not match the HTTP URI") 110 | 111 | if _dpop["htm"] != _http_info["method"]: 112 | raise ValueError("htm in DPoP does not match the HTTP method") 113 | 114 | if not _dpop.key: 115 | _dpop.key = key_from_jwk_dict(_dpop["jwk"]) 116 | 117 | # Need something I can add as a reference when minting tokens 118 | request["dpop_jkt"] = as_unicode(_dpop.key.thumbprint("SHA-256")) 119 | return request 120 | 121 | 122 | def token_args(endpoint_context, client_id, token_args: Optional[dict] = None): 123 | dpop_jkt = endpoint_context.cdb[client_id]["dpop_jkt"] 124 | _jkt = list(dpop_jkt.keys())[0] 125 | if "dpop_jkt" in endpoint_context.cdb[client_id]: 126 | if token_args is None: 127 | token_args = {"cnf": {"jkt": _jkt}} 128 | else: 129 | token_args.update({"cnf": {"jkt": endpoint_context.cdb[client_id]["dpop_jkt"]}}) 130 | 131 | return token_args 132 | 133 | 134 | def add_support(endpoint, **kwargs): 135 | # 136 | _token_endp = endpoint["token"] 137 | _token_endp.post_parse_request.append(post_parse_request) 138 | 139 | # Endpoint Context stuff 140 | # _endp.endpoint_context.token_args_methods.append(token_args) 141 | _algs_supported = kwargs.get("dpop_signing_alg_values_supported") 142 | if not _algs_supported: 143 | _algs_supported = ["RS256"] 144 | 145 | _token_endp.server_get("endpoint_context").provider_info[ 146 | "dpop_signing_alg_values_supported" 147 | ] = _algs_supported 148 | 149 | _endpoint_context = _token_endp.server_get("endpoint_context") 150 | _endpoint_context.dpop_enabled = True 151 | 152 | 153 | # DPoP-bound access token in the "Authorization" header and the DPoP proof in the "DPoP" header 154 | 155 | 156 | class DPoPClientAuth(ClientAuthnMethod): 157 | tag = "dpop_client_auth" 158 | 159 | def is_usable(self, request=None, authorization_info=None, http_headers=None): 160 | if authorization_info is not None and authorization_info.startswith("DPoP "): 161 | return True 162 | return False 163 | 164 | def verify(self, authorization_info, **kwargs): 165 | client_info = basic_authn(authorization_info) 166 | _context = self.server_get("endpoint_context") 167 | if _context.cdb[client_info["id"]]["client_secret"] == client_info["secret"]: 168 | return {"client_id": client_info["id"]} 169 | else: 170 | raise AuthnFailure() 171 | -------------------------------------------------------------------------------- /src/oidcop/oauth2/add_on/extra_args.py: -------------------------------------------------------------------------------- 1 | from oidcmsg.oauth2 import AccessTokenResponse 2 | from oidcmsg.oauth2 import AuthorizationResponse 3 | from oidcmsg.oauth2 import TokenExchangeResponse 4 | from oidcmsg.oauth2 import TokenIntrospectionResponse 5 | from oidcmsg.oidc import OpenIDSchema 6 | 7 | 8 | def pre_construct(response_args, request, endpoint_context, **kwargs): 9 | """ 10 | Add extra arguments to the request. 11 | 12 | :param response_args: 13 | :param request: 14 | :param endpoint_context: 15 | :param kwargs: 16 | :return: 17 | """ 18 | 19 | _extra = endpoint_context.add_on.get("extra_args") 20 | if _extra: 21 | if isinstance(response_args, AuthorizationResponse): 22 | _args = _extra.get("authorization", {}) 23 | elif isinstance(response_args, AccessTokenResponse): 24 | _args = _extra.get('accesstoken', {}) 25 | elif isinstance(response_args, TokenExchangeResponse): 26 | _args = _extra.get('token_exchange', {}) 27 | elif isinstance(response_args, TokenIntrospectionResponse): 28 | _args = _extra.get('token_introspection', {}) 29 | elif isinstance(response_args, OpenIDSchema): 30 | _args = _extra.get('userinfo', {}) 31 | else: 32 | _args = {} 33 | 34 | for arg, _param in _args.items(): 35 | _val = getattr(endpoint_context, _param) 36 | if _val: 37 | response_args[arg] = _val 38 | 39 | return response_args 40 | 41 | 42 | def add_support(endpoint, **kwargs): 43 | # 44 | _added = False 45 | for endpoint_name in list(kwargs.keys()): 46 | _endp = endpoint[endpoint_name] 47 | _endp.pre_construct.append(pre_construct) 48 | 49 | if _added is False: 50 | _endp.server_get("endpoint_context").add_on["extra_args"] = kwargs 51 | _added = True 52 | -------------------------------------------------------------------------------- /src/oidcop/oauth2/introspection.py: -------------------------------------------------------------------------------- 1 | """Implements RFC7662""" 2 | import logging 3 | from typing import Optional 4 | 5 | from oidcmsg import oauth2 6 | 7 | from oidcop.endpoint import Endpoint 8 | from oidcop.token.exception import UnknownToken 9 | from oidcop.token.exception import WrongTokenClass 10 | 11 | LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | class Introspection(Endpoint): 15 | """Implements RFC 7662""" 16 | 17 | request_cls = oauth2.TokenIntrospectionRequest 18 | response_cls = oauth2.TokenIntrospectionResponse 19 | request_format = "urlencoded" 20 | response_format = "json" 21 | endpoint_name = "introspection_endpoint" 22 | name = "introspection" 23 | 24 | def __init__(self, server_get, **kwargs): 25 | Endpoint.__init__(self, server_get, **kwargs) 26 | self.offset = kwargs.get("offset", 0) 27 | 28 | def _introspect(self, token, client_id, grant): 29 | # Make sure that the token is an access_token or a refresh_token 30 | if token.token_class not in ["access_token", "refresh_token"]: 31 | return None 32 | 33 | if not token.is_active(): 34 | return None 35 | 36 | scope = token.scope 37 | if not scope: 38 | if token.based_on: 39 | scope = grant.find_scope(token.based_on) 40 | else: 41 | scope = grant.scope 42 | aud = token.resources 43 | if not aud: 44 | aud = grant.resources 45 | 46 | _context = self.server_get("endpoint_context") 47 | ret = { 48 | "active": True, 49 | "scope": " ".join(scope), 50 | "client_id": client_id, 51 | "token_class": token.token_class, 52 | "exp": token.expires_at, 53 | "iat": token.issued_at, 54 | "sub": grant.sub, 55 | "iss": _context.issuer, 56 | } 57 | 58 | try: 59 | _token_type = token.token_type 60 | except AttributeError: 61 | _token_type = None 62 | 63 | if _token_type: 64 | ret["token_type"] = _token_type 65 | 66 | if aud: 67 | ret["aud"] = aud 68 | 69 | token_args = {} 70 | for meth in _context.token_args_methods: 71 | token_args = meth(_context, client_id, token_args) 72 | 73 | if token_args: 74 | ret.update(token_args) 75 | 76 | return ret 77 | 78 | def process_request(self, request=None, release: Optional[list] = None, **kwargs): 79 | """ 80 | 81 | :param request: The authorization request as a dictionary 82 | :param release: Information about what should be released 83 | :param kwargs: 84 | :return: 85 | """ 86 | _introspect_request = self.request_cls(**request) 87 | if "error" in _introspect_request: 88 | return _introspect_request 89 | 90 | request_token = _introspect_request["token"] 91 | _resp = self.response_cls(active=False) 92 | _context = self.server_get("endpoint_context") 93 | 94 | try: 95 | _session_info = _context.session_manager.get_session_info_by_token( 96 | request_token, grant=True 97 | ) 98 | except (UnknownToken, WrongTokenClass): 99 | return {"response_args": _resp} 100 | 101 | grant = _session_info["grant"] 102 | _token = grant.get_token(request_token) 103 | 104 | _info = self._introspect(_token, _session_info["client_id"], _session_info["grant"]) 105 | if _info is None: 106 | return {"response_args": _resp} 107 | 108 | if release: 109 | if "username" in release: 110 | try: 111 | _info["username"] = _session_info["user_id"] 112 | except KeyError: 113 | pass 114 | 115 | _resp.update(_info) 116 | _resp.weed() 117 | 118 | _claims_restriction = grant.claims.get("introspection") 119 | if _claims_restriction: 120 | user_info = _context.claims_interface.get_user_claims( 121 | _session_info["user_id"], _claims_restriction 122 | ) 123 | if user_info: 124 | _resp.update(user_info) 125 | 126 | _resp["active"] = True 127 | 128 | return {"response_args": _resp} 129 | -------------------------------------------------------------------------------- /src/oidcop/oauth2/pushed_authorization.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from oidcmsg import oauth2 4 | 5 | from oidcop.oauth2.authorization import Authorization 6 | 7 | 8 | class PushedAuthorization(Authorization): 9 | request_cls = oauth2.PushedAuthorizationRequest 10 | response_cls = oauth2.Message 11 | endpoint_name = "pushed_authorization_request_endpoint" 12 | request_placement = "body" 13 | request_format = "urlencoded" 14 | response_placement = "body" 15 | response_format = "json" 16 | name = "pushed_authorization" 17 | 18 | def __init__(self, server_get, **kwargs): 19 | Authorization.__init__(self, server_get, **kwargs) 20 | # self.pre_construct.append(self._pre_construct) 21 | self.post_parse_request.append(self._post_parse_request) 22 | self.ttl = kwargs.get("ttl", 3600) 23 | 24 | def process_request(self, request=None, **kwargs): 25 | """ 26 | Store the request and return a URI. 27 | 28 | :param request: 29 | """ 30 | # create URN 31 | 32 | _urn = "urn:uuid:{}".format(uuid.uuid4()) 33 | self.server_get("endpoint_context").par_db[_urn] = request 34 | 35 | return { 36 | "http_response": {"request_uri": _urn, "expires_in": self.ttl}, 37 | "return_uri": request["redirect_uri"], 38 | } 39 | -------------------------------------------------------------------------------- /src/oidcop/oidc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityPython/oidc-op/2f81e246ff9ef412f5dc786cd2cfa5dd5fccf9e1/src/oidcop/oidc/__init__.py -------------------------------------------------------------------------------- /src/oidcop/oidc/add_on/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityPython/oidc-op/2f81e246ff9ef412f5dc786cd2cfa5dd5fccf9e1/src/oidcop/oidc/add_on/__init__.py -------------------------------------------------------------------------------- /src/oidcop/oidc/add_on/custom_scopes.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from oidcop.scopes import SCOPE2CLAIMS 4 | 5 | LOGGER = logging.getLogger(__name__) 6 | 7 | 8 | def add_custom_scopes(endpoint, **kwargs): 9 | """ 10 | :param endpoint: A dictionary with endpoint instances as values 11 | """ 12 | # Just need an endpoint, anyone will do 13 | LOGGER.warning( 14 | "The custom_scopes add on is deprecated. The `scopes_to_claims` config " 15 | "option should be used instead." 16 | ) 17 | _endpoint = list(endpoint.values())[0] 18 | 19 | _scopes2claims = SCOPE2CLAIMS.copy() 20 | _scopes2claims.update(kwargs) 21 | _context = _endpoint.server_get("endpoint_context") 22 | _context.scopes_handler.set_scopes_mapping(_scopes2claims) 23 | 24 | pi = _context.provider_info 25 | _scopes = set(pi.get("scopes_supported", [])) 26 | _scopes.update(set(kwargs.keys())) 27 | pi["scopes_supported"] = list(_scopes) 28 | _context.scopes_handler.allowed_scopes = pi["scopes_supported"] 29 | 30 | _claims = set(pi.get("claims_supported", [])) 31 | for vals in kwargs.values(): 32 | _claims.update(set(vals)) 33 | pi["claims_supported"] = list(_claims) 34 | -------------------------------------------------------------------------------- /src/oidcop/oidc/add_on/pkce.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | from typing import Dict 4 | 5 | from cryptojwt.utils import b64e 6 | from oidcmsg.oauth2 import AuthorizationErrorResponse 7 | from oidcmsg.oauth2 import RefreshAccessTokenRequest 8 | from oidcmsg.oauth2 import TokenExchangeRequest 9 | from oidcmsg.oidc import TokenErrorResponse 10 | 11 | from oidcop.endpoint import Endpoint 12 | 13 | LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | def hash_fun(f): 17 | def wrapper(code_verifier): 18 | _h = f(code_verifier.encode("ascii")).digest() 19 | _cc = b64e(_h) 20 | return _cc.decode("ascii") 21 | 22 | return wrapper 23 | 24 | 25 | CC_METHOD = { 26 | "plain": lambda x: x, 27 | "S256": hash_fun(hashlib.sha256), 28 | "S384": hash_fun(hashlib.sha384), 29 | "S512": hash_fun(hashlib.sha512), 30 | } 31 | 32 | 33 | def post_authn_parse(request, client_id, endpoint_context, **kwargs): 34 | """ 35 | 36 | :param request: 37 | :param client_id: 38 | :param endpoint_context: 39 | :param kwargs: 40 | :return: 41 | """ 42 | client = endpoint_context.cdb[client_id] 43 | if "pkce_essential" in client: 44 | essential = client["pkce_essential"] 45 | else: 46 | essential = endpoint_context.args["pkce"].get("essential", False) 47 | if essential and "code_challenge" not in request: 48 | return AuthorizationErrorResponse( 49 | error="invalid_request", 50 | error_description="Missing required code_challenge", 51 | ) 52 | 53 | if "code_challenge_method" not in request: 54 | request["code_challenge_method"] = "plain" 55 | 56 | if "code_challenge" in request and ( 57 | request["code_challenge_method"] 58 | not in endpoint_context.args["pkce"]["code_challenge_methods"] 59 | ): 60 | return AuthorizationErrorResponse( 61 | error="invalid_request", 62 | error_description="Unsupported code_challenge_method={}".format( 63 | request["code_challenge_method"] 64 | ), 65 | ) 66 | 67 | return request 68 | 69 | 70 | def verify_code_challenge(code_verifier, code_challenge, code_challenge_method="S256"): 71 | """ 72 | Verify a PKCE (RFC7636) code challenge. 73 | 74 | 75 | :param code_verifier: The origin 76 | :param code_challenge: The transformed verifier used as challenge 77 | :return: 78 | """ 79 | if CC_METHOD[code_challenge_method](code_verifier) != code_challenge: 80 | LOGGER.error("PKCE Code Challenge check failed") 81 | return False 82 | 83 | LOGGER.debug("PKCE Code Challenge check succeeded") 84 | return True 85 | 86 | 87 | def post_token_parse(request, client_id, endpoint_context, **kwargs): 88 | """ 89 | To be used as a post_parse_request function. 90 | 91 | :param token_request: 92 | :return: 93 | """ 94 | if isinstance( 95 | request, 96 | (AuthorizationErrorResponse, RefreshAccessTokenRequest, TokenExchangeRequest), 97 | ): 98 | return request 99 | 100 | try: 101 | _session_info = endpoint_context.session_manager.get_session_info_by_token( 102 | request["code"], grant=True 103 | ) 104 | except KeyError: 105 | return TokenErrorResponse(error="invalid_grant", error_description="Unknown access grant") 106 | 107 | _authn_req = _session_info["grant"].authorization_request 108 | 109 | if "code_challenge" in _authn_req: 110 | if "code_verifier" not in request: 111 | return TokenErrorResponse( 112 | error="invalid_grant", 113 | error_description="Missing code_verifier", 114 | ) 115 | 116 | _method = _authn_req["code_challenge_method"] 117 | 118 | if not verify_code_challenge( 119 | request["code_verifier"], 120 | _authn_req["code_challenge"], 121 | _method, 122 | ): 123 | return TokenErrorResponse(error="invalid_grant", error_description="PKCE check failed") 124 | 125 | return request 126 | 127 | 128 | def add_pkce_support(endpoint: Dict[str, Endpoint], **kwargs): 129 | authn_endpoint = endpoint.get("authorization") 130 | if authn_endpoint is None: 131 | LOGGER.warning("No authorization endpoint found, skipping PKCE configuration") 132 | return 133 | 134 | token_endpoint = endpoint.get("token") 135 | if token_endpoint is None: 136 | LOGGER.warning("No token endpoint found, skipping PKCE configuration") 137 | return 138 | 139 | authn_endpoint.post_parse_request.append(post_authn_parse) 140 | token_endpoint.post_parse_request.append(post_token_parse) 141 | 142 | code_challenge_methods = kwargs.get("code_challenge_methods", CC_METHOD.keys()) 143 | 144 | kwargs["code_challenge_methods"] = {} 145 | for method in code_challenge_methods: 146 | if method not in CC_METHOD: 147 | raise ValueError("Unsupported method: {}".format(method)) 148 | kwargs["code_challenge_methods"][method] = CC_METHOD[method] 149 | 150 | authn_endpoint.server_get("endpoint_context").args["pkce"] = kwargs 151 | -------------------------------------------------------------------------------- /src/oidcop/oidc/authorization.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Callable 3 | from urllib.parse import urlsplit 4 | 5 | from oidcmsg import oidc 6 | from oidcmsg.oidc import Claims 7 | from oidcmsg.oidc import verified_claim_name 8 | 9 | from oidcop.oauth2 import authorization 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def proposed_user(request): 15 | cn = verified_claim_name("it_token_hint") 16 | if request.get(cn): 17 | return request[cn].get("sub", "") 18 | return "" 19 | 20 | 21 | def acr_claims(request): 22 | acrdef = None 23 | 24 | _claims = request.get("claims") 25 | if isinstance(_claims, str): 26 | _claims = Claims().from_json(_claims) 27 | 28 | if _claims: 29 | _id_token_claim = _claims.get("id_token") 30 | if _id_token_claim: 31 | acrdef = _id_token_claim.get("acr") 32 | 33 | if isinstance(acrdef, dict): 34 | if acrdef.get("value"): 35 | return [acrdef["value"]] 36 | elif acrdef.get("values"): 37 | return acrdef["values"] 38 | 39 | 40 | def host_component(url): 41 | res = urlsplit(url) 42 | return "{}://{}".format(res.scheme, res.netloc) 43 | 44 | 45 | ALG_PARAMS = { 46 | "sign": [ 47 | "request_object_signing_alg", 48 | "request_object_signing_alg_values_supported", 49 | ], 50 | "enc_alg": [ 51 | "request_object_encryption_alg", 52 | "request_object_encryption_alg_values_supported", 53 | ], 54 | "enc_enc": [ 55 | "request_object_encryption_enc", 56 | "request_object_encryption_enc_values_supported", 57 | ], 58 | } 59 | 60 | 61 | def re_authenticate(request, authn): 62 | if "prompt" in request and "login" in request["prompt"]: 63 | if authn.done(request): 64 | return True 65 | 66 | return False 67 | 68 | 69 | class Authorization(authorization.Authorization): 70 | request_cls = oidc.AuthorizationRequest 71 | response_cls = oidc.AuthorizationResponse 72 | error_cls = oidc.AuthorizationErrorResponse 73 | request_format = "urlencoded" 74 | response_format = "urlencoded" 75 | response_placement = "url" 76 | endpoint_name = "authorization_endpoint" 77 | name = "authorization" 78 | default_capabilities = { 79 | "claims_parameter_supported": True, 80 | "request_parameter_supported": True, 81 | "request_uri_parameter_supported": True, 82 | "response_types_supported": [ 83 | "code", 84 | "token", 85 | "id_token", 86 | "code token", 87 | "code id_token", 88 | "id_token token", 89 | "code id_token token", 90 | ], 91 | "response_modes_supported": ["query", "fragment", "form_post"], 92 | "request_object_signing_alg_values_supported": None, 93 | "request_object_encryption_alg_values_supported": None, 94 | "request_object_encryption_enc_values_supported": None, 95 | "grant_types_supported": ["authorization_code", "implicit"], 96 | "claim_types_supported": ["normal", "aggregated", "distributed"], 97 | } 98 | 99 | def __init__(self, server_get: Callable, **kwargs): 100 | authorization.Authorization.__init__(self, server_get, **kwargs) 101 | # self.pre_construct.append(self._pre_construct) 102 | self.post_parse_request.append(self._do_request_uri) 103 | self.post_parse_request.append(self._post_parse_request) 104 | 105 | def do_request_user(self, request_info, **kwargs): 106 | if proposed_user(request_info): 107 | kwargs["req_user"] = proposed_user(request_info) 108 | else: 109 | _login_hint = request_info.get("login_hint") 110 | if _login_hint: 111 | _context = self.server_get("endpoint_context") 112 | if _context.login_hint_lookup: 113 | kwargs["req_user"] = _context.login_hint_lookup(_login_hint) 114 | return kwargs 115 | -------------------------------------------------------------------------------- /src/oidcop/oidc/discovery.py: -------------------------------------------------------------------------------- 1 | from oidcmsg import oidc 2 | from oidcmsg.oidc import JRD 3 | from oidcmsg.oidc import Link 4 | 5 | from oidcop.endpoint import Endpoint 6 | 7 | OIC_ISSUER = "http://openid.net/specs/connect/1.0/issuer" 8 | 9 | 10 | class Discovery(Endpoint): 11 | request_cls = oidc.DiscoveryRequest 12 | response_cls = JRD 13 | request_format = "urlencoded" 14 | response_format = "json" 15 | name = "discovery" 16 | 17 | def do_response(self, response_args=None, request=None, **kwargs): 18 | """ 19 | **Placeholder for the time being** 20 | 21 | :param response_args: 22 | :param request: 23 | :param kwargs: request arguments 24 | :return: Response information 25 | """ 26 | 27 | links = [Link(href=h, rel=OIC_ISSUER) for h in kwargs["hrefs"]] 28 | 29 | _response = JRD(subject=kwargs["subject"], links=links) 30 | 31 | info = { 32 | "response": _response.to_json(), 33 | "http_headers": [("Content-type", "application/json")], 34 | } 35 | 36 | return info 37 | 38 | def process_request(self, request=None, **kwargs): 39 | return { 40 | "subject": request["resource"], 41 | "hrefs": [self.server_get("endpoint_context").issuer], 42 | } 43 | -------------------------------------------------------------------------------- /src/oidcop/oidc/provider_config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from oidcmsg import oidc 4 | 5 | from oidcop.endpoint import Endpoint 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class ProviderConfiguration(Endpoint): 11 | request_cls = oidc.Message 12 | response_cls = oidc.ProviderConfigurationResponse 13 | request_format = "" 14 | response_format = "json" 15 | name = "provider_config" 16 | default_capabilities = {"require_request_uri_registration": None} 17 | 18 | def __init__(self, server_get, **kwargs): 19 | Endpoint.__init__(self, server_get=server_get, **kwargs) 20 | self.pre_construct.append(self.add_endpoints) 21 | 22 | def add_endpoints(self, request, client_id, endpoint_context, **kwargs): 23 | for endpoint in [ 24 | "authorization_endpoint", 25 | "registration_endpoint", 26 | "token_endpoint", 27 | "userinfo_endpoint", 28 | "end_session_endpoint", 29 | ]: 30 | endp_instance = self.server_get("endpoint", endpoint) 31 | if endp_instance: 32 | request[endpoint] = endp_instance.endpoint_path 33 | 34 | return request 35 | 36 | def process_request(self, request=None, **kwargs): 37 | return {"response_args": self.server_get("endpoint_context").provider_info} 38 | -------------------------------------------------------------------------------- /src/oidcop/oidc/read_registration.py: -------------------------------------------------------------------------------- 1 | from oidcmsg.message import Message 2 | from oidcmsg.oauth2 import ResponseMessage 3 | from oidcmsg.oidc import RegistrationResponse 4 | 5 | from oidcop.endpoint import Endpoint 6 | from oidcop.oidc.registration import comb_uri 7 | 8 | 9 | class RegistrationRead(Endpoint): 10 | request_cls = Message 11 | response_cls = RegistrationResponse 12 | error_response = ResponseMessage 13 | request_format = "urlencoded" 14 | request_placement = "url" 15 | response_format = "json" 16 | name = "registration_read" 17 | 18 | def get_client_id_from_token(self, endpoint_context, token, request=None): 19 | if "client_id" in request: 20 | if ( 21 | request["client_id"] 22 | == self.server_get("endpoint_context").registration_access_token[token] 23 | ): 24 | return request["client_id"] 25 | return "" 26 | 27 | def process_request(self, request=None, **kwargs): 28 | _cli_info = self.server_get("endpoint_context").cdb[request["client_id"]] 29 | args = {k: v for k, v in _cli_info.items() if k in RegistrationResponse.c_param} 30 | comb_uri(args) 31 | return {"response_args": RegistrationResponse(**args)} 32 | -------------------------------------------------------------------------------- /src/oidcop/scopes.py: -------------------------------------------------------------------------------- 1 | # default set can be changed by configuration 2 | 3 | SCOPE2CLAIMS = { 4 | "openid": ["sub"], 5 | "profile": [ 6 | "name", 7 | "given_name", 8 | "family_name", 9 | "middle_name", 10 | "nickname", 11 | "profile", 12 | "picture", 13 | "website", 14 | "gender", 15 | "birthdate", 16 | "zoneinfo", 17 | "locale", 18 | "updated_at", 19 | "preferred_username", 20 | ], 21 | "email": ["email", "email_verified"], 22 | "address": ["address"], 23 | "phone": ["phone_number", "phone_number_verified"], 24 | "offline_access": [], 25 | } 26 | 27 | 28 | def convert_scopes2claims(scopes, allowed_claims=None, scope2claim_map=None): 29 | scope2claim_map = scope2claim_map or SCOPE2CLAIMS 30 | 31 | res = {} 32 | if allowed_claims is None: 33 | for scope in scopes: 34 | claims = {name: None for name in scope2claim_map.get(scope, [])} 35 | res.update(claims) 36 | else: 37 | for scope in scopes: 38 | try: 39 | claims = { 40 | name: None for name in scope2claim_map.get(scope, []) if name in allowed_claims 41 | } 42 | res.update(claims) 43 | except KeyError: 44 | continue 45 | 46 | return res 47 | 48 | 49 | class Scopes: 50 | def __init__(self, server_get, allowed_scopes=None, scopes_to_claims=None): 51 | self.server_get = server_get 52 | if not scopes_to_claims: 53 | scopes_to_claims = dict(SCOPE2CLAIMS) 54 | self._scopes_to_claims = scopes_to_claims 55 | if not allowed_scopes: 56 | allowed_scopes = list(scopes_to_claims.keys()) 57 | self.allowed_scopes = allowed_scopes 58 | 59 | def get_allowed_scopes(self, client_id=None): 60 | """ 61 | Returns the set of scopes that a specific client can use. 62 | 63 | :param client_id: The client identifier 64 | :returns: List of scope names. Can be empty. 65 | """ 66 | allowed_scopes = self.allowed_scopes 67 | if client_id: 68 | client = self.server_get("endpoint_context").cdb.get(client_id) 69 | if client is not None: 70 | if "allowed_scopes" in client: 71 | allowed_scopes = client.get("allowed_scopes") 72 | elif "scopes_to_claims" in client: 73 | allowed_scopes = list(client.get("scopes_to_claims").keys()) 74 | 75 | return allowed_scopes 76 | 77 | def get_scopes_mapping(self, client_id=None): 78 | """ 79 | Returns the mapping of scopes to claims fora specific client. 80 | 81 | :param client_id: The client identifier 82 | :returns: Dict of scopes to claims. Can be empty. 83 | """ 84 | scopes_to_claims = self._scopes_to_claims 85 | if client_id: 86 | client = self.server_get("endpoint_context").cdb.get(client_id) 87 | if client is not None: 88 | scopes_to_claims = client.get("scopes_to_claims", scopes_to_claims) 89 | return scopes_to_claims 90 | 91 | def filter_scopes(self, scopes, client_id=None): 92 | allowed_scopes = self.get_allowed_scopes(client_id) 93 | return [s for s in scopes if s in allowed_scopes] 94 | 95 | def scopes_to_claims(self, scopes, scopes_to_claims=None, client_id=None): 96 | if not scopes_to_claims: 97 | scopes_to_claims = self.get_scopes_mapping(client_id) 98 | 99 | scopes = self.filter_scopes(scopes, client_id) 100 | 101 | return convert_scopes2claims(scopes, scope2claim_map=scopes_to_claims) 102 | 103 | def set_scopes_mapping(self, scopes_to_claims): 104 | self._scopes_to_claims = scopes_to_claims 105 | -------------------------------------------------------------------------------- /src/oidcop/session/__init__.py: -------------------------------------------------------------------------------- 1 | class Revoked(Exception): 2 | pass 3 | 4 | 5 | class MintingNotAllowed(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /src/oidcop/session/info.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from typing import Optional 3 | 4 | from oidcmsg.impexp import ImpExp 5 | 6 | 7 | class SessionInfo(ImpExp): 8 | parameter = {"subordinate": [], "revoked": bool, "type": "", "extra_args": {}} 9 | 10 | def __init__( 11 | self, 12 | subordinate: Optional[List[str]] = None, 13 | revoked: Optional[bool] = False, 14 | type: Optional[str] = "", 15 | **kwargs 16 | ): 17 | ImpExp.__init__(self) 18 | self.subordinate = subordinate or [] 19 | self.revoked = revoked 20 | self.type = type 21 | self.extra_args = {} 22 | 23 | def add_subordinate(self, value: str) -> "SessionInfo": 24 | if value not in self.subordinate: 25 | self.subordinate.append(value) 26 | return self 27 | 28 | def remove_subordinate(self, value: str) -> "SessionInfo": 29 | self.subordinate.remove(value) 30 | return self 31 | 32 | def revoke(self) -> "SessionInfo": 33 | self.revoked = True 34 | return self 35 | 36 | def is_revoked(self) -> bool: 37 | return self.revoked 38 | 39 | def keys(self): 40 | return self.parameter.keys() 41 | 42 | 43 | class UserSessionInfo(SessionInfo): 44 | parameter = SessionInfo.parameter.copy() 45 | parameter.update( 46 | { 47 | "user_id": "", 48 | } 49 | ) 50 | 51 | def __init__(self, **kwargs): 52 | SessionInfo.__init__(self, **kwargs) 53 | self.type = "UserSessionInfo" 54 | self.user_id = kwargs.get("user_id", "") 55 | self.extra_args = {k: v for k, v in kwargs.items() if k not in self.parameter} 56 | 57 | 58 | class ClientSessionInfo(SessionInfo): 59 | parameter = SessionInfo.parameter.copy() 60 | parameter.update({"client_id": ""}) 61 | 62 | def __init__(self, **kwargs): 63 | SessionInfo.__init__(self, **kwargs) 64 | self.type = "ClientSessionInfo" 65 | self.client_id = kwargs.get("client_id", "") 66 | self.extra_args = {k: v for k, v in kwargs.items() if k not in self.parameter} 67 | 68 | def find_grant_and_token(self, val: str): 69 | for grant in self.subordinate: 70 | token = grant.get_token(val) 71 | if token: 72 | return grant, token 73 | -------------------------------------------------------------------------------- /src/oidcop/template_handler.py: -------------------------------------------------------------------------------- 1 | class TemplateHandler(object): 2 | def __init__(self): 3 | pass 4 | 5 | def render(self, template, **kwargs): 6 | raise NotImplementedError() 7 | 8 | 9 | class Jinja2TemplateHandler(TemplateHandler): 10 | def __init__(self, template_env): 11 | self.template_env = template_env 12 | 13 | def render(self, template, **kwargs): 14 | template = self.template_env.get_template(template) 15 | 16 | return template.render(**kwargs) 17 | -------------------------------------------------------------------------------- /src/oidcop/token/__init__.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | from typing import Optional 4 | 5 | from oidcmsg.time_util import utc_time_sans_frac 6 | 7 | from oidcop import rndstr 8 | from oidcop.token.exception import UnknownToken 9 | from oidcop.token.exception import WrongTokenClass 10 | from oidcop.util import Crypt 11 | from oidcop.util import lv_pack 12 | from oidcop.util import lv_unpack 13 | 14 | __author__ = "Roland Hedberg" 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | ALT_TOKEN_NAME = { 19 | "authorization_code": "A", 20 | "access_token": "T", 21 | "refresh_token": "R", 22 | "id_token": "I", 23 | } 24 | 25 | 26 | def is_expired(exp, when=0): 27 | if exp < 0: 28 | return False 29 | 30 | if not when: 31 | when = utc_time_sans_frac() 32 | return when > exp 33 | 34 | 35 | class Token(object): 36 | def __init__(self, token_class, lifetime=300, **kwargs): 37 | self.token_class = token_class 38 | try: 39 | self.alt_token_name = ALT_TOKEN_NAME[token_class] 40 | except KeyError: 41 | self.alt_token_name = "" 42 | 43 | self.lifetime = lifetime 44 | self.kwargs = kwargs 45 | 46 | def __call__(self, session_id: Optional[str] = "", ttype: Optional[str] = "", **payload) -> str: 47 | """ 48 | Return a token. 49 | 50 | :param payload: Information to place in the token if possible. 51 | :return: 52 | """ 53 | raise NotImplementedError() 54 | 55 | def info(self, token): 56 | """ 57 | Return dictionary with token information. 58 | 59 | :param token: A token 60 | :return: Dictionary with information about the token 61 | """ 62 | raise NotImplementedError() 63 | 64 | def is_expired(self, token, when=0): 65 | """ 66 | Evaluate whether the token has expired or not 67 | 68 | :param token: The token 69 | :param when: The time against which to check the expiration 70 | :return: True/False 71 | """ 72 | raise NotImplementedError() 73 | 74 | def gather_args(self, *args, **kwargs): 75 | return {} 76 | 77 | 78 | class DefaultToken(Token): 79 | def __init__(self, password, token_class="", token_type="Bearer", **kwargs): 80 | Token.__init__(self, token_class, **kwargs) 81 | self.crypt = Crypt(password) 82 | self.token_type = token_type 83 | 84 | def __call__( 85 | self, session_id: Optional[str] = "", token_class: Optional[str] = "", **payload 86 | ) -> str: 87 | """ 88 | Return a token. 89 | 90 | :param payload: Token information 91 | :return: 92 | """ 93 | if not token_class and self.token_class: 94 | token_class = self.token_class 95 | else: 96 | token_class = "authorization_code" 97 | 98 | if self.lifetime >= 0: 99 | exp = str(utc_time_sans_frac() + self.lifetime) 100 | else: 101 | exp = "-1" # Live for ever 102 | 103 | tmp = "" 104 | rnd = "" 105 | while rnd == tmp: # Don't use the same random value again 106 | rnd = rndstr(32) # Ultimate length multiple of 16 107 | 108 | return base64.b64encode( 109 | self.crypt.encrypt(lv_pack(rnd, token_class, session_id, exp).encode()) 110 | ).decode("utf-8") 111 | 112 | def split_token(self, token): 113 | try: 114 | plain = self.crypt.decrypt(base64.b64decode(token)) 115 | except Exception as err: 116 | raise UnknownToken(err) 117 | # order: rnd, type, sid 118 | return lv_unpack(plain) 119 | 120 | def info(self, token: str) -> dict: 121 | """ 122 | Return token information. 123 | 124 | :param token: A token 125 | :return: dictionary with info about the token 126 | """ 127 | _res = dict(zip(["_id", "token_class", "sid", "exp"], self.split_token(token))) 128 | if _res["token_class"] not in [self.token_class, self.alt_token_name]: 129 | raise WrongTokenClass(_res["token_class"]) 130 | else: 131 | _res["token_class"] = self.token_class 132 | _res["handler"] = self 133 | return _res 134 | 135 | def is_expired(self, token: str, when: int = 0): 136 | _exp = self.info(token)["exp"] 137 | if _exp == "-1": 138 | return False 139 | else: 140 | exp = int(_exp) 141 | return is_expired(exp, when) 142 | -------------------------------------------------------------------------------- /src/oidcop/token/exception.py: -------------------------------------------------------------------------------- 1 | from oidcop.exception import OidcOPError 2 | 3 | 4 | class TokenException(OidcOPError): 5 | pass 6 | 7 | 8 | class ExpiredToken(TokenException): 9 | pass 10 | 11 | 12 | class WrongTokenType(TokenException): 13 | pass 14 | 15 | 16 | class WrongTokenClass(TokenException): 17 | pass 18 | 19 | 20 | class AccessCodeUsed(TokenException): 21 | pass 22 | 23 | 24 | class UnknownToken(TokenException): 25 | pass 26 | 27 | 28 | class NotAllowed(TokenException): 29 | pass 30 | 31 | 32 | class InvalidToken(TokenException): 33 | pass 34 | -------------------------------------------------------------------------------- /src/oidcop/token/handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import Optional 4 | import warnings 5 | 6 | from cryptojwt.exception import Invalid 7 | from cryptojwt.key_jar import init_key_jar 8 | from cryptojwt.utils import as_unicode 9 | from oidcmsg.impexp import ImpExp 10 | from oidcmsg.item import DLDict 11 | 12 | from oidcop.token import DefaultToken 13 | from oidcop.token import Token 14 | from oidcop.token import UnknownToken 15 | from oidcop.token.exception import TokenException 16 | from oidcop.util import importer 17 | 18 | __author__ = "Roland Hedberg" 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class TokenHandler(ImpExp): 24 | parameter = {"handler": DLDict, "handler_order": [""]} 25 | 26 | def __init__( 27 | self, 28 | access_token: Optional[Token] = None, 29 | authorization_code: Optional[Token] = None, 30 | refresh_token: Optional[Token] = None, 31 | id_token: Optional[Token] = None, 32 | ): 33 | ImpExp.__init__(self) 34 | self.handler = {"authorization_code": authorization_code, "access_token": access_token} 35 | 36 | self.handler_order = ["authorization_code", "access_token"] 37 | 38 | if refresh_token: 39 | self.handler["refresh_token"] = refresh_token 40 | self.handler_order.append("refresh_token") 41 | 42 | if id_token: 43 | self.handler["id_token"] = id_token 44 | self.handler_order.append("id_token") 45 | 46 | def __getitem__(self, typ): 47 | return self.handler[typ] 48 | 49 | def __contains__(self, item): 50 | return item in self.handler 51 | 52 | def info(self, item, order=None): 53 | _handler, item_info = self.get_handler(item, order) 54 | 55 | if _handler is None: 56 | logger.info("Unknown token format") 57 | raise UnknownToken(item) 58 | else: 59 | return item_info 60 | 61 | def sid(self, token, order=None): 62 | return self.info(token, order)["sid"] 63 | 64 | def token_class(self, token, order=None): 65 | return self.info(token, order)["token_class"] 66 | 67 | def get_handler(self, token, order=None): 68 | if order is None: 69 | order = self.handler_order 70 | 71 | for typ in order: 72 | try: 73 | res = self.handler[typ].info(token) 74 | except (KeyError, TokenException, Invalid, AttributeError): 75 | pass 76 | else: 77 | return self.handler[typ], res 78 | 79 | return None, None 80 | 81 | def keys(self): 82 | return self.handler.keys() 83 | 84 | 85 | def init_token_handler(server_get, spec, token_class): 86 | _kwargs = spec.get("kwargs", {}) 87 | 88 | _lt = spec.get("lifetime") 89 | if _lt: 90 | _kwargs["lifetime"] = _lt 91 | 92 | try: 93 | _cls = spec["class"] 94 | except KeyError: 95 | cls = DefaultToken 96 | _pw = spec.get("password") 97 | if _pw is not None: 98 | _kwargs["password"] = _pw 99 | else: 100 | cls = importer(_cls) 101 | 102 | if _kwargs is None: 103 | if cls != DefaultToken: 104 | warnings.warn( 105 | "Token initialisation arguments should be grouped under 'kwargs'.", 106 | DeprecationWarning, 107 | stacklevel=2, 108 | ) 109 | _kwargs = spec 110 | 111 | return cls(token_class=token_class, server_get=server_get, **_kwargs) 112 | 113 | 114 | def _add_passwd(keyjar, conf, kid): 115 | if keyjar: 116 | _keys = keyjar.get_encrypt_key(key_type="oct", kid=kid) 117 | if _keys: 118 | pw = as_unicode(_keys[0].k) 119 | if "kwargs" in conf: 120 | conf["kwargs"]["password"] = pw 121 | else: 122 | conf["password"] = pw 123 | 124 | 125 | def is_defined(key_defs, kid): 126 | for _def in key_defs: 127 | if _def["kid"] == kid: 128 | return True 129 | 130 | return False 131 | 132 | 133 | def default_token(spec): 134 | if "class" not in spec or spec["class"] in ["oidcop.token.DefaultToken", DefaultToken]: 135 | return True 136 | else: 137 | return False 138 | 139 | 140 | JWKS_FILE = "private/token_jwks.json" 141 | 142 | 143 | def factory( 144 | server_get, 145 | code: Optional[dict] = None, 146 | token: Optional[dict] = None, 147 | refresh: Optional[dict] = None, 148 | id_token: Optional[dict] = None, 149 | jwks_file: Optional[str] = "", 150 | **kwargs 151 | ) -> TokenHandler: 152 | """ 153 | Create a token handler 154 | 155 | :param code: 156 | :param token: 157 | :param refresh: 158 | :param jwks_file: 159 | :return: TokenHandler instance 160 | """ 161 | 162 | token_class_map = { 163 | "code": "authorization_code", 164 | "token": "access_token", 165 | "refresh": "refresh_token", 166 | "idtoken": "id_token", 167 | } 168 | 169 | key_defs = [] 170 | read_only = False 171 | cwd = server_get("endpoint_context").cwd 172 | if kwargs.get("jwks_def"): 173 | defs = kwargs["jwks_def"] 174 | if not jwks_file: 175 | jwks_file = defs.get("private_path", os.path.join(cwd, JWKS_FILE)) 176 | read_only = defs.get("read_only", read_only) 177 | key_defs = defs.get("key_defs", []) 178 | 179 | if not jwks_file: 180 | jwks_file = os.path.join(cwd, JWKS_FILE) 181 | 182 | if not key_defs: 183 | for kid, cnf in [("code", code), ("refresh", refresh), ("token", token)]: 184 | if cnf is not None: 185 | if default_token(cnf): 186 | key_defs.append({"type": "oct", "bytes": 24, "use": ["enc"], "kid": kid}) 187 | 188 | kj = init_key_jar(key_defs=key_defs, private_path=jwks_file, read_only=read_only) 189 | 190 | args = {} 191 | for cls, cnf, attr in [ 192 | ("code", code, "authorization_code"), 193 | ("token", token, "access_token"), 194 | ("refresh", refresh, "refresh_token"), 195 | ]: 196 | if cnf is not None: 197 | if default_token(cnf): 198 | _add_passwd(kj, cnf, cls) 199 | args[attr] = init_token_handler(server_get, cnf, token_class_map[cls]) 200 | 201 | if id_token is not None: 202 | args["id_token"] = init_token_handler(server_get, id_token, token_class="") 203 | 204 | return TokenHandler(**args) 205 | -------------------------------------------------------------------------------- /src/oidcop/token/jwt_token.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from typing import Optional 3 | 4 | from cryptojwt import JWT 5 | from cryptojwt.jws.exception import JWSException 6 | 7 | from oidcop.exception import ToOld 8 | from oidcop.token import Crypt 9 | from oidcop.token.exception import WrongTokenClass 10 | from . import Token 11 | from . import is_expired 12 | from .exception import UnknownToken 13 | from ..constant import DEFAULT_TOKEN_LIFETIME 14 | 15 | 16 | class JWTToken(Token): 17 | def __init__( 18 | self, 19 | token_class, 20 | # keyjar: KeyJar = None, 21 | issuer: str = None, 22 | aud: Optional[list] = None, 23 | alg: str = "ES256", 24 | lifetime: int = DEFAULT_TOKEN_LIFETIME, 25 | server_get: Callable = None, 26 | token_type: str = "Bearer", 27 | password: str = "", 28 | **kwargs 29 | ): 30 | Token.__init__(self, token_class, **kwargs) 31 | self.token_type = token_type 32 | self.lifetime = lifetime 33 | self.crypt = Crypt(password) 34 | 35 | self.kwargs = kwargs 36 | _context = server_get("endpoint_context") 37 | # self.key_jar = keyjar or _context.keyjar 38 | self.issuer = issuer or _context.issuer 39 | self.cdb = _context.cdb 40 | self.server_get = server_get 41 | 42 | self.def_aud = aud or [] 43 | self.alg = alg 44 | 45 | def load_custom_claims(self, payload: dict = None): 46 | # inherit me and do your things here 47 | return payload 48 | 49 | def __call__( 50 | self, 51 | session_id: Optional[str] = "", 52 | token_class: Optional[str] = "", 53 | usage_rules: Optional[dict] = None, 54 | **payload 55 | ) -> str: 56 | """ 57 | Return a token. 58 | 59 | :param session_id: Session id 60 | :param token_class: Token class 61 | :param payload: A dictionary with information that is part of the payload of the JWT. 62 | :return: Signed JSON Web Token 63 | """ 64 | if not token_class: 65 | if self.token_class: 66 | token_class = self.token_class 67 | else: 68 | token_class = "authorization_code" 69 | 70 | payload.update({"sid": session_id, "token_class": token_class}) 71 | payload = self.load_custom_claims(payload) 72 | 73 | # payload.update(kwargs) 74 | _context = self.server_get("endpoint_context") 75 | if usage_rules and "expires_in" in usage_rules: 76 | lifetime = usage_rules.get("expires_in") 77 | else: 78 | lifetime = self.lifetime 79 | signer = JWT( 80 | key_jar=_context.keyjar, 81 | iss=self.issuer, 82 | lifetime=lifetime, 83 | sign_alg=self.alg, 84 | ) 85 | 86 | return signer.pack(payload) 87 | 88 | def get_payload(self, token): 89 | _context = self.server_get("endpoint_context") 90 | verifier = JWT(key_jar=_context.keyjar, allowed_sign_algs=[self.alg]) 91 | try: 92 | _payload = verifier.unpack(token) 93 | except JWSException: 94 | raise UnknownToken() 95 | 96 | return _payload 97 | 98 | def info(self, token): 99 | """ 100 | Return token information 101 | 102 | :param token: A token 103 | :return: dictionary with token information 104 | """ 105 | _payload = self.get_payload(token) 106 | 107 | _class = _payload.get("ttype") 108 | if _class is None: 109 | _class = _payload.get("token_class") 110 | 111 | if _class not in [self.token_class, self.alt_token_name]: 112 | raise WrongTokenClass(_payload["token_class"]) 113 | else: 114 | _payload["token_class"] = self.token_class 115 | 116 | if is_expired(_payload["exp"]): 117 | raise ToOld("Token has expired") 118 | # All the token metadata 119 | _res = { 120 | "sid": _payload["sid"], 121 | "token_class": _payload["token_class"], 122 | "exp": _payload["exp"], 123 | "handler": self, 124 | } 125 | return _res 126 | 127 | def is_expired(self, token, when=0): 128 | """ 129 | Evaluate whether the token has expired or not 130 | 131 | :param token: The token 132 | :param when: The time against which to check the expiration. 0 means now. 133 | :return: True/False 134 | """ 135 | _payload = self.get_payload(token) 136 | return is_expired(_payload["exp"], when) 137 | 138 | def gather_args(self, sid, sdb, udb): 139 | # sdb[sid] 140 | return {} 141 | -------------------------------------------------------------------------------- /src/oidcop/user_authn/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = "Roland Hedberg" 2 | -------------------------------------------------------------------------------- /src/oidcop/user_authn/authn_context.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from oidcmsg.oidc import verified_claim_name 4 | 5 | from oidcop.util import instantiate 6 | 7 | __author__ = "Roland Hedberg" 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | SAML_AC = "urn:oasis:names:tc:SAML:2.0:ac:classes" 12 | UNSPECIFIED = "{}:unspecified".format(SAML_AC) 13 | INTERNETPROTOCOLPASSWORD = "{}:InternetProtocolPassword".format(SAML_AC) 14 | MOBILETWOFACTORCONTRACT = "{}:MobileTwoFactorContract".format(SAML_AC) 15 | PASSWORDPROTECTEDTRANSPORT = "{}:PasswordProtectedTransport".format(SAML_AC) 16 | PASSWORD = "{}:Password".format(SAML_AC) 17 | TLSCLIENT = "{}:TLSClient".format(SAML_AC) 18 | TIMESYNCTOKEN = "{}:TimeSyncToken".format(SAML_AC) 19 | 20 | CMP_TYPE = ["exact", "minimum", "maximum", "better"] 21 | 22 | 23 | class AuthnBroker(object): 24 | def __init__(self): 25 | self.db = {} 26 | self.acr2id = {} 27 | 28 | def __setitem__(self, key, info): 29 | """ 30 | Adds a new authentication method. 31 | 32 | :param value: A dictionary with metadata and configuration information 33 | """ 34 | 35 | for attr in ["acr", "method"]: 36 | if attr not in info: 37 | raise ValueError('Required attribute "{}" missing'.format(attr)) 38 | 39 | self.db[key] = info 40 | try: 41 | self.acr2id[info["acr"]].append(key) 42 | except KeyError: 43 | self.acr2id[info["acr"]] = [key] 44 | 45 | def __delitem__(self, key): 46 | _acr = self.db[key]["acr"] 47 | del self.db[key] 48 | self.acr2id[_acr].remove(key) 49 | if not self.acr2id[_acr]: 50 | del self.acr2id[_acr] 51 | 52 | def __getitem__(self, key): 53 | return self.db[key] 54 | 55 | def _pick_by_class_ref(self, acr): 56 | try: 57 | _ids = self.acr2id[acr] 58 | except KeyError: 59 | return [] 60 | else: 61 | return [self.db[_i] for _i in _ids] 62 | 63 | def get_method(self, cls_name): 64 | """ 65 | Generator that returns all registered authenticators based on a 66 | specific authentication class. 67 | 68 | :param acr: Authentication Class 69 | :return: generator 70 | """ 71 | for id, spec in self.db.items(): 72 | if spec["method"].__class__.__name__ == cls_name: 73 | yield spec["method"] 74 | 75 | def get_method_by_id(self, id): 76 | return self[id]["method"] 77 | 78 | def pick(self, acr=None): 79 | """ 80 | Given the authentication context find zero or more authn methods 81 | that could be used. 82 | 83 | :param acr: The authentication class reference requested 84 | :return: An URL 85 | """ 86 | 87 | if acr is None: 88 | # Anything else doesn't make sense 89 | return self.db.values() 90 | else: 91 | return self._pick_by_class_ref(acr) 92 | 93 | def get_acr_values(self): 94 | """Return a list of acr values""" 95 | return [item["acr"] for item in self.db.values()] 96 | 97 | def __iter__(self): 98 | for item in self.db.values(): 99 | yield item["method"] 100 | 101 | def __len__(self): 102 | return len(self.db.keys()) 103 | 104 | def default(self): 105 | if len(self.db) >= 1: 106 | return list(self.db.values())[0] 107 | else: 108 | return None 109 | 110 | 111 | def _acr_claim(request): 112 | _claims = request.get("claims") 113 | if _claims: 114 | _id_token_claim = _claims.get("id_token") 115 | if _id_token_claim: 116 | _acr = _id_token_claim.get("acr") 117 | if "value" in _acr: 118 | return [_acr["value"]] 119 | elif "values" in _acr: 120 | return _acr["values"] 121 | return None 122 | 123 | 124 | def pick_auth(endpoint_context, areq, pick_all=False): 125 | """ 126 | Pick authentication method 127 | 128 | :param areq: AuthorizationRequest instance 129 | :return: A dictionary with the authentication method and its authn class ref 130 | """ 131 | acrs = [] 132 | if len(endpoint_context.authn_broker) == 1: 133 | return endpoint_context.authn_broker.default() 134 | 135 | if "acr_values" in areq: 136 | if not isinstance(areq["acr_values"], list): 137 | areq["acr_values"] = [areq["acr_values"]] 138 | acrs = areq["acr_values"] 139 | 140 | else: 141 | acrs = _acr_claim(areq) 142 | if not acrs: 143 | _ith = verified_claim_name("id_token_hint") 144 | if areq.get(_ith): 145 | _ith = areq[verified_claim_name("id_token_hint")] 146 | if _ith.get("acr"): 147 | acrs = [_ith["acr"]] 148 | else: 149 | if areq.get("login_hint") and endpoint_context.login_hint2acrs: 150 | acrs = endpoint_context.login_hint2acrs(areq["login_hint"]) 151 | 152 | if not acrs: 153 | return endpoint_context.authn_broker.default() 154 | 155 | for acr in acrs: 156 | res = endpoint_context.authn_broker.pick(acr) 157 | logger.debug(f"Picked AuthN broker for ACR {str(acr)}: {str(res)}") 158 | if res: 159 | return res if pick_all else res[0] 160 | 161 | return None 162 | 163 | 164 | def init_method(authn_spec, server_get, template_handler=None): 165 | try: 166 | _args = authn_spec["kwargs"] 167 | except KeyError: 168 | _args = {} 169 | 170 | if "template" in _args: 171 | _args["template_handler"] = template_handler 172 | 173 | _args["server_get"] = server_get 174 | 175 | args = {"method": instantiate(authn_spec["class"], **_args)} 176 | args.update({k: v for k, v in authn_spec.items() if k not in ["class", "kwargs"]}) 177 | return args 178 | 179 | 180 | def populate_authn_broker(methods, server_get, template_handler=None): 181 | """ 182 | 183 | :param methods: Authentication method specifications 184 | :param server_get: method that returns things from server 185 | :param template_handler: A class used to render templates 186 | :return: 187 | """ 188 | authn_broker = AuthnBroker() 189 | 190 | for id, authn_spec in methods.items(): 191 | args = init_method(authn_spec, server_get, template_handler) 192 | authn_broker[id] = args 193 | 194 | return authn_broker 195 | -------------------------------------------------------------------------------- /src/oidcop/user_info/__init__.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import json 3 | 4 | __author__ = "rolandh" 5 | 6 | 7 | def dict_subset(a, b): 8 | for attr, values in a.items(): 9 | try: 10 | _val = b[attr] 11 | except KeyError: 12 | return False 13 | else: 14 | if isinstance(_val, list): 15 | if isinstance(values, list): 16 | if not set(values).issubset(set(_val)): 17 | return False 18 | else: 19 | if values not in _val: 20 | return False 21 | else: 22 | if isinstance(values, list): 23 | return False 24 | else: 25 | if values != _val: 26 | return False 27 | return True 28 | 29 | 30 | class UserInfo(object): 31 | """ Read only interface to a user info store """ 32 | 33 | def __init__(self, db=None, db_file=""): 34 | if db is not None: 35 | self.db = db 36 | elif db_file: 37 | self.db = json.loads(open(db_file).read()) 38 | else: 39 | self.db = {} 40 | 41 | def filter(self, userinfo, user_info_claims=None): 42 | """ 43 | Return only those claims that are asked for. 44 | It's a best effort task; if essential claims are not present 45 | no error is flagged. 46 | 47 | :param userinfo: A dictionary containing the available info for one user 48 | :param user_info_claims: A dictionary specifying the asked for claims 49 | :return: A dictionary of filtered claims. 50 | """ 51 | 52 | if user_info_claims is None: 53 | return copy.copy(userinfo) 54 | else: 55 | result = {} 56 | missing = [] 57 | optional = [] 58 | for key, restr in user_info_claims.items(): 59 | try: 60 | result[key] = userinfo[key] 61 | except KeyError: 62 | if restr == {"essential": True}: 63 | missing.append(key) 64 | else: 65 | optional.append(key) 66 | return result 67 | 68 | def __call__(self, user_id, client_id, user_info_claims=None, **kwargs): 69 | try: 70 | return self.filter(self.db[user_id], user_info_claims) 71 | except KeyError: 72 | return {} 73 | 74 | def search(self, **kwargs): 75 | for uid, args in self.db.items(): 76 | if dict_subset(kwargs, args): 77 | return uid 78 | 79 | raise KeyError("No matching user") 80 | -------------------------------------------------------------------------------- /src/oidcop/utils.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import os 4 | import ssl 5 | import sys 6 | 7 | import yaml 8 | 9 | 10 | def load_json(file_name): # pragma: no cover 11 | with open(file_name) as fp: 12 | js = json.load(fp) 13 | return js 14 | 15 | 16 | def load_yaml_config(file_name): 17 | with open(file_name) as fp: 18 | c = yaml.safe_load(fp) 19 | return c 20 | 21 | 22 | def yaml_to_py_stream(file_name): # pragma: no cover 23 | d = load_yaml_config(file_name) 24 | fstream = io.StringIO() 25 | for i in d: 26 | section = "{} = {}\n\n".format(i, json.dumps(d[i], indent=2)) 27 | fstream.write(section) 28 | fstream.seek(0) 29 | return fstream 30 | 31 | 32 | def lower_or_upper(config, param, default=None): # pragma: no cover 33 | res = config.get(param.lower(), default) 34 | if not res: 35 | res = config.get(param.upper(), default) 36 | return res 37 | 38 | 39 | def create_context(dir_path, config, **kwargs): # pragma: no cover 40 | _fname = lower_or_upper(config, "server_cert") 41 | if _fname: 42 | if _fname.startswith("/"): 43 | _cert_file = _fname 44 | else: 45 | _cert_file = os.path.join(dir_path, _fname) 46 | else: 47 | return None 48 | 49 | _fname = lower_or_upper(config, "server_key") 50 | if _fname: 51 | if _fname.startswith("/"): 52 | _key_file = _fname 53 | else: 54 | _key_file = os.path.join(dir_path, _fname) 55 | else: 56 | return None 57 | 58 | context = ssl.SSLContext(**kwargs) # PROTOCOL_TLS by default 59 | 60 | _verify_user = lower_or_upper(config, "verify_user") 61 | if _verify_user: 62 | if _verify_user == "optional": 63 | context.verify_mode = ssl.CERT_OPTIONAL 64 | elif _verify_user == "required": 65 | context.verify_mode = ssl.CERT_REQUIRED 66 | else: 67 | sys.exit(f"Unknown verify_user specification: '{_verify_user}'") 68 | _ca_bundle = lower_or_upper(config, "ca_bundle") 69 | if _ca_bundle: 70 | context.load_verify_locations(_ca_bundle) 71 | else: 72 | context.verify_mode = ssl.CERT_NONE 73 | 74 | try: 75 | context.load_cert_chain(_cert_file, _key_file) 76 | except Exception as err: 77 | print(f"cert_file:{_cert_file}") 78 | print(f"key_file:{_key_file}") 79 | sys.exit(f"Error starting server. Missing cert or key. Details: {err}") 80 | 81 | return context 82 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASEDIR = os.path.abspath(os.path.dirname(__file__)) 4 | 5 | 6 | def full_path(local_file): 7 | return os.path.join(BASEDIR, local_file) 8 | -------------------------------------------------------------------------------- /tests/logging.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | root: 3 | handlers: 4 | - default 5 | - console 6 | level: DEBUG 7 | loggers: 8 | bobcat_idp: 9 | level: DEBUG 10 | handlers: 11 | default: 12 | class: logging.FileHandler 13 | filename: 'debug.log' 14 | formatter: default 15 | console: 16 | class: logging.StreamHandler 17 | stream: 'ext://sys.stdout' 18 | formatter: default 19 | formatters: 20 | default: 21 | format: '%(asctime)s %(name)s %(levelname)s %(message)s' 22 | -------------------------------------------------------------------------------- /tests/logging_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "root": { 4 | "handlers": [ 5 | "default", "console" 6 | ], 7 | "level": "DEBUG" 8 | }, 9 | "loggers": { 10 | "bobcat": { 11 | "level": "DEBUG" 12 | } 13 | }, 14 | "handlers": { 15 | "default": { 16 | "class": "logging.FileHandler", 17 | "filename": "debug.log", 18 | "formatter": "default" 19 | }, 20 | "console": { 21 | "class": "logging.StreamHandler", 22 | "stream": "ext//sys.stdout", 23 | "formatter": "default" 24 | } 25 | }, 26 | "formatters": { 27 | "default": { 28 | "format": "%(asctime)s %(name)s %(levelname)s %(message)s" 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /tests/op_config_defaults.py: -------------------------------------------------------------------------------- 1 | CONFIG = { 2 | "authentication": { 3 | "user": { 4 | "acr": "oidcop.user_authn.authn_context.INTERNETPROTOCOLPASSWORD", 5 | "class": "oidcop.user_authn.user.UserPassJinja2", 6 | "kwargs": { 7 | "verify_endpoint": "verify/user", 8 | "template": "user_pass.jinja2", 9 | "db": {"class": "oidcop.util.JSONDictDB", "kwargs": {"filename": "passwd.json"},}, 10 | "page_header": "Testing log in", 11 | "submit_btn": "Get me in!", 12 | "user_label": "Nickname", 13 | "passwd_label": "Secret sauce", 14 | }, 15 | } 16 | }, 17 | "capabilities": { 18 | "subject_types_supported": ["public", "pairwise"], 19 | "grant_types_supported": [ 20 | "authorization_code", 21 | "implicit", 22 | "urn:ietf:params:oauth:grant-type:jwt-bearer", 23 | "refresh_token", 24 | ], 25 | }, 26 | "endpoint": { 27 | "webfinger": { 28 | "path": ".well-known/webfinger", 29 | "class": "oidcop.oidc.discovery.Discovery", 30 | "kwargs": {"client_authn_method": None}, 31 | }, 32 | "provider_info": { 33 | "path": ".well-known/openid-configuration", 34 | "class": "oidcop.oidc.provider_config.ProviderConfiguration", 35 | "kwargs": {"client_authn_method": None}, 36 | }, 37 | "registration": { 38 | "path": "registration", 39 | "class": "oidcop.oidc.registration.Registration", 40 | "kwargs": {"client_authn_method": None, "client_secret_expiration_time": 432000,}, 41 | }, 42 | "registration_api": { 43 | "path": "registration_api", 44 | "class": "oidcop.oidc.read_registration.RegistrationRead", 45 | "kwargs": {"client_authn_method": ["bearer_header"]}, 46 | }, 47 | "introspection": { 48 | "path": "introspection", 49 | "class": "oidcop.oauth2.introspection.Introspection", 50 | "kwargs": {"client_authn_method": ["client_secret_post"], "release": ["username"],}, 51 | }, 52 | "authorization": { 53 | "path": "authorization", 54 | "class": "oidcop.oidc.authorization.Authorization", 55 | "kwargs": { 56 | "client_authn_method": None, 57 | "claims_parameter_supported": True, 58 | "request_parameter_supported": True, 59 | "request_uri_parameter_supported": True, 60 | "response_types_supported": [ 61 | "code", 62 | "token", 63 | "id_token", 64 | "code token", 65 | "code id_token", 66 | "id_token token", 67 | "code id_token token", 68 | "none", 69 | ], 70 | "response_modes_supported": ["query", "fragment", "form_post"], 71 | }, 72 | }, 73 | "token": { 74 | "path": "token", 75 | "class": "oidcop.oidc.token.Token", 76 | "kwargs": { 77 | "client_authn_method": [ 78 | "client_secret_post", 79 | "client_secret_basic", 80 | "client_secret_jwt", 81 | "private_key_jwt", 82 | ] 83 | }, 84 | }, 85 | "userinfo": { 86 | "path": "userinfo", 87 | "class": "oidcop.oidc.userinfo.UserInfo", 88 | "kwargs": {"claim_types_supported": ["normal", "aggregated", "distributed"]}, 89 | }, 90 | "end_session": { 91 | "path": "session", 92 | "class": "oidcop.oidc.session.Session", 93 | "kwargs": { 94 | "logout_verify_url": "verify_logout", 95 | "post_logout_uri_path": "post_logout", 96 | "signing_alg": "ES256", 97 | "frontchannel_logout_supported": True, 98 | "frontchannel_logout_session_supported": True, 99 | "backchannel_logout_supported": True, 100 | "backchannel_logout_session_supported": True, 101 | "check_session_iframe": "check_session_iframe", 102 | }, 103 | }, 104 | }, 105 | "keys": { 106 | "private_path": "private/jwks.json", 107 | "key_defs": [ 108 | {"type": "RSA", "use": ["sig"]}, 109 | {"type": "EC", "crv": "P-256", "use": ["sig"]}, 110 | ], 111 | "public_path": "static/jwks.json", 112 | "read_only": False, 113 | "uri_path": "static/jwks.json", 114 | }, 115 | "login_hint2acrs": { 116 | "class": "oidcop.login_hint.LoginHint2Acrs", 117 | "kwargs": { 118 | "scheme_map": {"email": ["oidcop.user_authn.authn_context.INTERNETPROTOCOLPASSWORD"]} 119 | }, 120 | }, 121 | "token_handler_args": { 122 | "jwks_def": { 123 | "private_path": "private/token_jwks.json", 124 | "read_only": False, 125 | "key_defs": [ 126 | {"type": "oct", "bytes": 24, "use": ["enc"], "kid": "code"}, 127 | {"type": "oct", "bytes": 24, "use": ["enc"], "kid": "refresh"}, 128 | ], 129 | }, 130 | "code": {"kwargs": {"lifetime": 600}}, 131 | "token": { 132 | "class": "oidcop.token.jwt_token.JWTToken", 133 | "kwargs": { 134 | "lifetime": 3600, 135 | "add_claims": ["email", "email_verified", "phone_number", "phone_number_verified",], 136 | "add_claims_by_scope": True, 137 | "aud": ["https://example.org/appl"], 138 | }, 139 | }, 140 | "refresh": {"kwargs": {"lifetime": 86400}}, 141 | }, 142 | "userinfo": {"class": "oidcop.user_info.UserInfo", "kwargs": {"filename": "users.json"},}, 143 | } 144 | -------------------------------------------------------------------------------- /tests/passwd.json: -------------------------------------------------------------------------------- 1 | { 2 | "diana": "krall", 3 | "babs": "howes", 4 | "upper": "crust" 5 | } -------------------------------------------------------------------------------- /tests/templates/user_pass.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Please login 7 | 8 | 9 | 10 |

{{ page_header }}

11 | 12 |
13 | 14 | 15 |

16 | 17 | 19 |

20 | 21 |

22 | 23 | 24 |

25 | 26 |

27 | {{ logo_label }} 28 |

29 |

30 | {{ tos_label }} 31 |

32 |

33 | {{ policy_label }} 34 |

35 | 36 | 37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /tests/test_00_configure.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from oidcmsg.configure import Configuration 5 | from oidcmsg.configure import create_from_config_file 6 | import pytest 7 | 8 | from oidcop.configure import OPConfiguration 9 | from oidcop.logging import configure_logging 10 | 11 | BASEDIR = os.path.abspath(os.path.dirname(__file__)) 12 | 13 | 14 | def full_path(local_file): 15 | return os.path.join(BASEDIR, local_file) 16 | 17 | 18 | def test_op_configure(): 19 | _str = open(full_path("op_config.json")).read() 20 | _conf = json.loads(_str) 21 | 22 | configuration = OPConfiguration(conf=_conf, base_path=BASEDIR, domain="127.0.0.1", port=443) 23 | assert configuration 24 | assert "add_on" in configuration 25 | authz_conf = configuration["authz"] 26 | assert set(authz_conf.keys()) == {"kwargs", "class"} 27 | id_token_conf = configuration.get("id_token") 28 | assert set(id_token_conf.keys()) == {"kwargs", "class"} 29 | 30 | with pytest.raises(KeyError): 31 | _ = configuration["foobar"] 32 | 33 | assert configuration.get("foobar", {}) == {} 34 | userinfo_conf = configuration.get("userinfo") 35 | assert userinfo_conf["kwargs"]["db_file"].startswith(BASEDIR) 36 | 37 | args = dict(configuration.items()) 38 | assert "add_on" in args 39 | 40 | assert "session_params" in configuration 41 | 42 | 43 | def test_op_configure_from_file(): 44 | configuration = create_from_config_file( 45 | OPConfiguration, 46 | filename=full_path("op_config.json"), 47 | base_path=BASEDIR, 48 | domain="127.0.0.1", 49 | port=443, 50 | ) 51 | 52 | assert configuration 53 | assert "add_on" in configuration 54 | assert "key_conf" in configuration 55 | authz_conf = configuration["authz"] 56 | assert set(authz_conf.keys()) == {"kwargs", "class"} 57 | id_token_conf = configuration.get("id_token") 58 | assert set(id_token_conf.keys()) == {"kwargs", "class"} 59 | 60 | with pytest.raises(KeyError): 61 | _ = configuration["foobar"] 62 | 63 | assert configuration.get("foobar", {}) == {} 64 | userinfo_conf = configuration.get("userinfo") 65 | assert userinfo_conf["kwargs"]["db_file"].startswith(BASEDIR) 66 | 67 | 68 | def test_op_configure_default(): 69 | _str = open(full_path("op_config.json")).read() 70 | _conf = json.loads(_str) 71 | 72 | configuration = OPConfiguration(conf=_conf, base_path=BASEDIR, domain="127.0.0.1", port=443) 73 | assert configuration 74 | assert "add_on" in configuration 75 | authz = configuration["authz"] 76 | assert set(authz.keys()) == {"kwargs", "class"} 77 | id_token_conf = configuration.get("id_token", {}) 78 | assert set(id_token_conf.keys()) == {"kwargs", "class"} 79 | assert id_token_conf["kwargs"] == { 80 | "base_claims": {"email": {"essential": True}, "email_verified": {"essential": True}, } 81 | } 82 | 83 | 84 | def test_op_configure_default_from_file(): 85 | configuration = create_from_config_file( 86 | OPConfiguration, 87 | filename=full_path("op_config.json"), 88 | base_path=BASEDIR, 89 | domain="127.0.0.1", 90 | port=443, 91 | ) 92 | assert configuration 93 | assert "add_on" in configuration 94 | authz = configuration["authz"] 95 | assert set(authz.keys()) == {"kwargs", "class"} 96 | id_token_conf = configuration.get("id_token", {}) 97 | assert set(id_token_conf.keys()) == {"kwargs", "class"} 98 | assert id_token_conf["kwargs"] == { 99 | "base_claims": {"email": {"essential": True}, "email_verified": {"essential": True}, } 100 | } 101 | 102 | 103 | def test_server_configure(): 104 | configuration = create_from_config_file( 105 | Configuration, 106 | entity_conf=[{"class": OPConfiguration, "attr": "op", "path": ["op", "server_info"]}], 107 | filename=full_path("srv_config.yaml"), 108 | base_path=BASEDIR, 109 | ) 110 | assert configuration 111 | assert "logger" in configuration 112 | assert "op" in configuration 113 | op_conf = configuration["op"] 114 | assert "add_on" in op_conf 115 | assert "key_conf" in op_conf 116 | authz = op_conf["authz"] 117 | assert set(authz.keys()) == {"kwargs", "class"} 118 | id_token_conf = op_conf.get("id_token", {}) 119 | assert set(id_token_conf.keys()) == {"kwargs", "class"} 120 | 121 | with pytest.raises(KeyError): 122 | _ = configuration["add_on"] 123 | 124 | assert configuration.get("add_on", {}) == {} 125 | 126 | userinfo_conf = op_conf.get("userinfo") 127 | assert userinfo_conf["kwargs"]["db_file"].startswith(BASEDIR) 128 | 129 | 130 | def test_loggin_conf_file(): 131 | logger = configure_logging(filename=full_path("logging.yaml")) 132 | assert logger 133 | 134 | 135 | def test_loggin_conf_default(): 136 | logger = configure_logging() 137 | assert logger 138 | 139 | 140 | CONF = { 141 | "version": 1, 142 | "root": {"handlers": ["default"], "level": "DEBUG"}, 143 | "loggers": {"bobcat": {"level": "DEBUG"}}, 144 | "handlers": { 145 | "default": { 146 | "class": "logging.FileHandler", 147 | "filename": "debug.log", 148 | "formatter": "default", 149 | }, 150 | }, 151 | "formatters": {"default": {"format": "%(asctime)s %(name)s %(levelname)s %(message)s"}}, 152 | } 153 | 154 | 155 | def test_loggin_conf_dict(): 156 | logger = configure_logging(config=CONF) 157 | assert logger 158 | -------------------------------------------------------------------------------- /tests/test_00_server.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from copy import deepcopy 3 | import io 4 | import json 5 | import os 6 | 7 | from cryptojwt.key_jar import build_keyjar 8 | from oidcmsg.storage.abfile import AbstractFileSystem 9 | import yaml 10 | 11 | from oidcop.configure import OPConfiguration 12 | import oidcop.login_hint 13 | from oidcop.oidc.add_on.pkce import add_pkce_support 14 | from oidcop.oidc.authorization import Authorization 15 | from oidcop.oidc.provider_config import ProviderConfiguration 16 | from oidcop.oidc.registration import Registration 17 | from oidcop.oidc.session import Session 18 | from oidcop.oidc.token import Token 19 | from oidcop.oidc.userinfo import UserInfo 20 | from oidcop.server import Server 21 | from oidcop.user_authn.authn_context import INTERNETPROTOCOLPASSWORD 22 | 23 | BASEDIR = os.path.abspath(os.path.dirname(__file__)) 24 | 25 | 26 | def full_path(local_file): 27 | return os.path.join(BASEDIR, local_file) 28 | 29 | 30 | KEYDEFS = [ 31 | {"type": "RSA", "key": "", "use": ["sig"]}, 32 | {"type": "EC", "crv": "P-256", "use": ["sig"]}, 33 | ] 34 | 35 | KEYJAR = build_keyjar(KEYDEFS) 36 | 37 | CONF = { 38 | "issuer": "https://example.com/", 39 | "httpc_params": {"verify": False, "timeout": 1}, 40 | "capabilities": {}, 41 | "keys": {"uri_path": "static/jwks.json", "key_defs": KEYDEFS, "read_only": True}, 42 | "endpoint": { 43 | "provider_config": { 44 | "path": ".well-known/openid-configuration", 45 | "class": ProviderConfiguration, 46 | "kwargs": {}, 47 | }, 48 | "registration_endpoint": {"path": "registration", "class": Registration, "kwargs": {}, }, 49 | "authorization_endpoint": {"path": "authorization", "class": Authorization, "kwargs": {}, }, 50 | "token_endpoint": {"path": "token", "class": Token, "kwargs": {}}, 51 | "userinfo_endpoint": { 52 | "path": "userinfo", 53 | "class": UserInfo, 54 | "kwargs": {"db_file": "users.json"}, 55 | }, 56 | "session": {"path": "end_session", "class": Session, "kwargs": {}}, 57 | }, 58 | "authentication": { 59 | "anon": { 60 | "acr": INTERNETPROTOCOLPASSWORD, 61 | "class": "oidcop.user_authn.user.NoAuthn", 62 | "kwargs": {"user": "diana"}, 63 | } 64 | }, 65 | "claims_interface": {"class": "oidcop.session.claims.ClaimsInterface", "kwargs": {}}, 66 | "add_on": {"pkce": {"function": add_pkce_support, "kwargs": {"essential": True}}}, 67 | "template_dir": "template", 68 | "login_hint_lookup": {"class": oidcop.login_hint.LoginHintLookup, "kwargs": {}}, 69 | } 70 | 71 | client_yaml = """ 72 | oidc_clients: 73 | client1: 74 | # client secret is "password" 75 | client_secret: "Namnam" 76 | redirect_uris: 77 | - ['https://openidconnect.net/callback', ''] 78 | response_types: 79 | - code 80 | client2: 81 | client_secret: "spraket" 82 | redirect_uris: 83 | - ['https://app1.example.net/foo', ''] 84 | - ['https://app2.example.net/bar', ''] 85 | response_types: 86 | - code 87 | client3: 88 | client_secret: '2222222222222222222222222222222222222222' 89 | redirect_uris: 90 | - ['https://127.0.0.1:8090/authz_cb/bobcat', ''] 91 | post_logout_redirect_uri: ['https://openidconnect.net/', ''] 92 | response_types: 93 | - code 94 | """ 95 | 96 | 97 | def test_capabilities_default(): 98 | _str = open(full_path("op_config.json")).read() 99 | _conf = json.loads(_str) 100 | 101 | configuration = OPConfiguration(conf=_conf, base_path=BASEDIR, domain="127.0.0.1", port=443) 102 | 103 | server = Server(configuration) 104 | assert set(server.endpoint_context.provider_info["response_types_supported"]) == { 105 | "code", 106 | "token", 107 | "id_token", 108 | "code token", 109 | "code id_token", 110 | "id_token token", 111 | "code id_token token", 112 | } 113 | assert server.endpoint_context.provider_info["request_uri_parameter_supported"] is True 114 | assert server.endpoint_context.jwks_uri == 'https://127.0.0.1:80/static/jwks.json' 115 | 116 | 117 | def test_capabilities_subset1(): 118 | _cnf = deepcopy(CONF) 119 | _cnf["capabilities"] = {"response_types_supported": ["code"]} 120 | server = Server(_cnf) 121 | assert server.endpoint_context.provider_info["response_types_supported"] == ["code"] 122 | 123 | 124 | def test_capabilities_subset2(): 125 | _cnf = deepcopy(CONF) 126 | _cnf["capabilities"] = {"response_types_supported": ["code", "id_token"]} 127 | server = Server(_cnf) 128 | assert set(server.endpoint_context.provider_info["response_types_supported"]) == { 129 | "code", 130 | "id_token", 131 | } 132 | 133 | 134 | def test_capabilities_bool(): 135 | _cnf = deepcopy(CONF) 136 | _cnf["capabilities"] = {"request_uri_parameter_supported": False} 137 | server = Server(_cnf) 138 | assert server.endpoint_context.provider_info["request_uri_parameter_supported"] is False 139 | 140 | 141 | def test_cdb(): 142 | _cnf = deepcopy(CONF) 143 | server = Server(_cnf) 144 | _clients = yaml.safe_load(io.StringIO(client_yaml)) 145 | server.endpoint_context.cdb = _clients["oidc_clients"] 146 | 147 | assert set(server.endpoint_context.cdb.keys()) == {"client1", "client2", "client3"} 148 | 149 | 150 | def test_cdb_afs(): 151 | _cnf = copy(CONF) 152 | _cnf["client_db"] = { 153 | "class": 'oidcmsg.storage.abfile.AbstractFileSystem', 154 | "kwargs": { 155 | 'fdir': full_path("afs"), 156 | 'value_conv': 'oidcmsg.util.JSON' 157 | } 158 | } 159 | server = Server(_cnf) 160 | assert isinstance(server.endpoint_context.cdb, AbstractFileSystem) 161 | -------------------------------------------------------------------------------- /tests/test_01_session_info.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from oidcmsg.oauth2 import AuthorizationRequest 3 | 4 | from oidcop.session.info import ClientSessionInfo 5 | from oidcop.session.info import SessionInfo 6 | from oidcop.session.info import UserSessionInfo 7 | 8 | AUTH_REQ = AuthorizationRequest( 9 | client_id="client_1", 10 | redirect_uri="https://example.com/cb", 11 | scope=["openid"], 12 | state="STATE", 13 | response_type=["code"], 14 | ) 15 | 16 | 17 | def test_session_info_subordinate(): 18 | si = SessionInfo() 19 | si.add_subordinate("subordinate_1") 20 | si.add_subordinate("subordinate_2") 21 | assert set(si.subordinate) == {"subordinate_1", "subordinate_2"} 22 | assert set(si.subordinate) == {"subordinate_1", "subordinate_2"} 23 | assert si.is_revoked() is False 24 | 25 | si.remove_subordinate("subordinate_1") 26 | assert si.subordinate == ["subordinate_2"] 27 | 28 | si.revoke() 29 | assert si.is_revoked() is True 30 | 31 | 32 | def test_session_info_no_subordinate(): 33 | si = SessionInfo() 34 | assert si.subordinate == [] 35 | 36 | 37 | def test_user_session_info_to_json(): 38 | usi = UserSessionInfo(user_id="uid") 39 | 40 | _jstr = usi.dump() 41 | 42 | usi2 = UserSessionInfo().load(_jstr) 43 | 44 | assert usi2.user_id == "uid" 45 | 46 | 47 | def test_user_session_info_to_json_with_sub(): 48 | usi = UserSessionInfo(uid="uid") 49 | usi.add_subordinate("client_id") 50 | 51 | _jstr = usi.dump() 52 | 53 | usi2 = UserSessionInfo().load(_jstr) 54 | 55 | assert usi2.subordinate == ["client_id"] 56 | 57 | 58 | def test_client_session_info(): 59 | csi = ClientSessionInfo(client_id="clientID") 60 | 61 | _jstr = csi.dump() 62 | 63 | _csi2 = ClientSessionInfo().load(_jstr) 64 | assert _csi2.client_id == "clientID" 65 | -------------------------------------------------------------------------------- /tests/test_01_session_token.py: -------------------------------------------------------------------------------- 1 | from oidcmsg.time_util import utc_time_sans_frac 2 | 3 | from oidcop.session.token import AccessToken 4 | from oidcop.session.token import AuthorizationCode 5 | from oidcop.session.token import IDToken 6 | 7 | 8 | def test_authorization_code_default(): 9 | code = AuthorizationCode(value="ABCD") 10 | assert code.usage_rules["max_usage"] == 1 11 | assert code.usage_rules["supports_minting"] == [ 12 | "access_token", 13 | "refresh_token", 14 | "id_token", 15 | ] 16 | 17 | 18 | def test_authorization_code_usage(): 19 | code = AuthorizationCode( 20 | value="ABCD", usage_rules={"supports_minting": ["access_token"], "max_usage": 1} 21 | ) 22 | 23 | assert code.usage_rules["max_usage"] == 1 24 | assert code.usage_rules["supports_minting"] == ["access_token"] 25 | 26 | 27 | def test_authorization_code_extras(): 28 | code = AuthorizationCode( 29 | value="ABCD", 30 | scope=["openid", "foo", "bar"], 31 | claims={"userinfo": {"given_name": None}}, 32 | resources=["https://api.example.com"], 33 | ) 34 | 35 | assert code.scope == ["openid", "foo", "bar"] 36 | assert code.claims == {"userinfo": {"given_name": None}} 37 | assert code.resources == ["https://api.example.com"] 38 | 39 | 40 | def test_dump_load( 41 | cls=AuthorizationCode, 42 | kwargs=dict( 43 | value="ABCD", 44 | scope=["openid", "foo", "bar"], 45 | claims={"userinfo": {"given_name": None}}, 46 | resources=["https://api.example.com"], 47 | ), 48 | ): 49 | code = cls(**kwargs) 50 | 51 | _item = code.dump() 52 | _new_code = cls().load(_item) 53 | for attr in cls.parameter.keys(): 54 | val = getattr(code, attr) 55 | if val: 56 | assert val == getattr(_new_code, attr) 57 | 58 | 59 | def test_dump_load_access_token(): 60 | test_dump_load(cls=AccessToken, kwargs={}) 61 | 62 | 63 | def test_dump_load_idtoken(): 64 | test_dump_load(cls=IDToken, kwargs={}) 65 | 66 | 67 | def test_supports_minting(): 68 | code = AuthorizationCode(value="ABCD") 69 | assert code.supports_minting("access_token") 70 | assert code.supports_minting("refresh_token") 71 | assert code.supports_minting("authorization_code") is False 72 | 73 | 74 | def test_usage(): 75 | token = AccessToken(usage_rules={"max_usage": 2}) 76 | 77 | token.register_usage() 78 | assert token.has_been_used() 79 | assert token.used == 1 80 | assert token.max_usage_reached() is False 81 | 82 | token.register_usage() 83 | assert token.max_usage_reached() 84 | 85 | token.register_usage() 86 | assert token.used == 3 87 | assert token.max_usage_reached() 88 | 89 | 90 | def test_is_active_usage(): 91 | token = AccessToken(usage_rules={"max_usage": 2}) 92 | 93 | token.register_usage() 94 | token.register_usage() 95 | assert token.is_active() is False 96 | 97 | 98 | def test_is_active_revoke(): 99 | token = AccessToken(usage_rules={"max_usage": 2}) 100 | token.revoke() 101 | assert token.is_active() is False 102 | 103 | 104 | def test_is_active_expired(): 105 | token = AccessToken(usage_rules={"max_usage": 2}) 106 | token.expires_at = utc_time_sans_frac() - 60 107 | assert token.is_active() is False 108 | -------------------------------------------------------------------------------- /tests/test_01_util.py: -------------------------------------------------------------------------------- 1 | from oidcop.oidc.authorization import Authorization 2 | from oidcop.oidc.provider_config import ProviderConfiguration 3 | from oidcop.oidc.registration import Registration 4 | from oidcop.oidc.token import Token 5 | from oidcop.oidc.userinfo import UserInfo 6 | from oidcop.user_authn.authn_context import INTERNETPROTOCOLPASSWORD 7 | 8 | KEYDEFS = [ 9 | {"type": "RSA", "key": "", "use": ["sig"]}, 10 | {"type": "EC", "crv": "P-256", "use": ["sig"]}, 11 | ] 12 | 13 | conf = { 14 | "issuer": "https://example.com/", 15 | "httpc_params": {"verify": False, "timeout": 1}, 16 | "token_expires_in": 600, 17 | "grant_expires_in": 300, 18 | "refresh_token_expires_in": 86400, 19 | "capabilities": {}, 20 | "jwks_uri": "https://example.com/jwks.json", 21 | "keys": {"private_path": "own/jwks.json", "key_defs": KEYDEFS, "uri_path": "static/jwks.json",}, 22 | "endpoint": { 23 | "provider_config": { 24 | "path": ".well-known/openid-configuration", 25 | "class": ProviderConfiguration, 26 | "kwargs": {}, 27 | }, 28 | "registration_endpoint": {"path": "registration", "class": Registration, "kwargs": {},}, 29 | "authorization_endpoint": {"path": "authorization", "class": Authorization, "kwargs": {},}, 30 | "token_endpoint": {"path": "token", "class": Token, "kwargs": {}}, 31 | "userinfo_endpoint": { 32 | "path": "userinfo", 33 | "class": UserInfo, 34 | "kwargs": {"db_file": "users.json"}, 35 | }, 36 | }, 37 | "authentication": { 38 | "anon": { 39 | "acr": INTERNETPROTOCOLPASSWORD, 40 | "class": "oidcop.user_authn.user.NoAuthn", 41 | "kwargs": {"user": "diana"}, 42 | } 43 | }, 44 | "template_dir": "template", 45 | } 46 | -------------------------------------------------------------------------------- /tests/test_06_session_manager_pairwise.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from oidcop.exception import ConfigurationError 4 | from oidcop.session.manager import PairWiseID 5 | from oidcop.session.manager import PublicID 6 | from oidcop.session.manager import SessionManager 7 | from oidcop.token.handler import TokenHandler 8 | 9 | 10 | class TestSessionManagerPairWiseID: 11 | def test_paiwise_id(self): 12 | # as param 13 | pw = PairWiseID(salt="salt") 14 | pw("diana", "that-sector") 15 | 16 | # as file 17 | pw = PairWiseID(filename="salt.txt") 18 | pw("diana", "that-sector") 19 | 20 | # prune 21 | os.remove("salt.txt") 22 | 23 | # again to test if a preexistent file going ot be used ... 24 | pw = PairWiseID(filename="salt.txt") 25 | 26 | try: 27 | pw = PairWiseID(filename="/tmp") 28 | except ConfigurationError: 29 | pass # that's ok 30 | 31 | # as random 32 | pw = PairWiseID() 33 | pw("diana", "that-sector") 34 | 35 | self.cleanup() 36 | 37 | def cleanup(self): 38 | if os.path.isfile("salt.txt"): 39 | os.remove("salt.txt") 40 | 41 | 42 | class TestSessionManagerPublicID: 43 | pw = PublicID() 44 | pw("diana", "that-sector") 45 | 46 | 47 | class TestSessionManagerConf: 48 | sman = SessionManager(handler=TokenHandler(), conf={"password": "hola!"}) 49 | -------------------------------------------------------------------------------- /tests/test_12_user_authn.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | 4 | import pytest 5 | 6 | from oidcop.configure import OPConfiguration 7 | from oidcop.server import Server 8 | from oidcop.user_authn.authn_context import INTERNETPROTOCOLPASSWORD 9 | from oidcop.user_authn.authn_context import UNSPECIFIED 10 | from oidcop.user_authn.user import BasicAuthn 11 | from oidcop.user_authn.user import NoAuthn 12 | from oidcop.user_authn.user import SymKeyAuthn 13 | from oidcop.user_authn.user import UserPassJinja2 14 | from oidcop.util import JSONDictDB 15 | 16 | KEYDEFS = [ 17 | {"type": "RSA", "key": "", "use": ["sig"]}, 18 | {"type": "EC", "crv": "P-256", "use": ["sig"]}, 19 | ] 20 | 21 | BASEDIR = os.path.abspath(os.path.dirname(__file__)) 22 | 23 | 24 | def full_path(local_file): 25 | return os.path.join(BASEDIR, local_file) 26 | 27 | 28 | class TestUserAuthn(object): 29 | @pytest.fixture(autouse=True) 30 | def create_endpoint_context(self): 31 | conf = { 32 | "issuer": "https://example.com/", 33 | "httpc_params": {"verify": False, "timeout": 1}, 34 | "grant_expires_in": 300, 35 | "endpoint": { 36 | "authorization": { 37 | "path": "{}/authorization", 38 | "class": 'oidcop.oidc.authorization.Authorization', 39 | "kwargs": {}, 40 | } 41 | }, 42 | "keys": {"uri_path": "static/jwks.json", "key_defs": KEYDEFS}, 43 | "authentication": { 44 | "user": { 45 | "acr": INTERNETPROTOCOLPASSWORD, 46 | "class": UserPassJinja2, 47 | "verify_endpoint": "verify/user", 48 | "kwargs": { 49 | "template": "user_pass.jinja2", 50 | "sym_key": "24AA/LR6HighEnergy", 51 | "db": { 52 | "class": JSONDictDB, 53 | "kwargs": {"filename": full_path("passwd.json")}, 54 | }, 55 | "page_header": "Testing log in", 56 | "submit_btn": "Get me in!", 57 | "user_label": "Nickname", 58 | "passwd_label": "Secret sauce", 59 | }, 60 | }, 61 | "anon": {"acr": UNSPECIFIED, "class": NoAuthn, "kwargs": {"user": "diana"}, }, 62 | }, 63 | "template_dir": "templates", 64 | "cookie_handler": { 65 | "class": "oidcop.cookie_handler.CookieHandler", 66 | "kwargs": { 67 | "sign_key": "ghsNKDDLshZTPn974nOsIGhedULrsqnsGoBFBLwUKuJhE2ch", 68 | "name": { 69 | "session": "oidc_op", 70 | "register": "oidc_op_reg", 71 | "session_management": "oidc_op_sman", 72 | }, 73 | }, 74 | }, 75 | } 76 | self.server = Server(OPConfiguration(conf=conf, base_path=BASEDIR), cwd=BASEDIR) 77 | self.endpoint_context = self.server.endpoint_context 78 | 79 | def test_authenticated_as_without_cookie(self): 80 | authn_item = self.endpoint_context.authn_broker.pick(INTERNETPROTOCOLPASSWORD) 81 | method = authn_item[0]["method"] 82 | 83 | _info, _time_stamp = method.authenticated_as(None) 84 | assert _info is None 85 | 86 | def test_authenticated_as_with_cookie(self): 87 | authn_item = self.endpoint_context.authn_broker.pick(INTERNETPROTOCOLPASSWORD) 88 | method = authn_item[0]["method"] 89 | 90 | authn_req = {"state": "state_identifier", "client_id": "client 12345"} 91 | _cookie = self.endpoint_context.new_cookie( 92 | name=self.endpoint_context.cookie_handler.name["session"], 93 | sub="diana", 94 | sid=self.endpoint_context.session_manager.encrypted_session_id( 95 | "diana", "client 12345", "abcdefgh" 96 | ), 97 | state=authn_req["state"], 98 | client_id=authn_req["client_id"], 99 | ) 100 | 101 | # Parsed once before authenticated_as 102 | kakor = self.endpoint_context.cookie_handler.parse_cookie( 103 | cookies=[_cookie], name=self.endpoint_context.cookie_handler.name["session"]) 104 | 105 | _info, _time_stamp = method.authenticated_as("client 12345", kakor) 106 | assert set(_info.keys()) == {'sub', 'uid', 'state', 'grant_id', 'timestamp', 'sid', 107 | 'client_id'} 108 | assert _info["sub"] == "diana" 109 | 110 | def test_userpassjinja2(self): 111 | db = { 112 | "class": JSONDictDB, 113 | "kwargs": {"filename": full_path("passwd.json")}, 114 | } 115 | template_handler = self.endpoint_context.template_handler 116 | res = UserPassJinja2(db, template_handler, 117 | server_get=self.server.server_get) 118 | res() 119 | assert "page_header" in res.kwargs 120 | 121 | def test_basic_auth(self): 122 | basic_auth = base64.b64encode(b"diana:krall").decode() 123 | ba = BasicAuthn(pwd={"diana": "krall"}, server_get=self.server.server_get) 124 | ba.authenticated_as(client_id="", authorization=f"Basic {basic_auth}") 125 | 126 | def test_no_auth(self): 127 | basic_auth = base64.b64encode( 128 | b"D\xfd\x8a\x85\xa6\xd1\x16\xe4\\6\x1e\x9ds~\xc3\t\x95\x99\x83\x91\x1f\xfb:iviviviv" 129 | ) 130 | ba = SymKeyAuthn(symkey=b"0" * 32, ttl=600, server_get=self.server.server_get) 131 | ba.authenticated_as(client_id="", authorization=basic_auth) 132 | -------------------------------------------------------------------------------- /tests/test_13_login_hint.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from oidcop.configure import OPConfiguration 5 | from oidcop.endpoint_context import init_service 6 | from oidcop.endpoint_context import init_user_info 7 | from oidcop.login_hint import LoginHint2Acrs 8 | from oidcop.server import Server 9 | 10 | BASEDIR = os.path.abspath(os.path.dirname(__file__)) 11 | 12 | 13 | def full_path(local_file): 14 | return os.path.join(BASEDIR, local_file) 15 | 16 | 17 | def test_login_hint(): 18 | userinfo = init_user_info( 19 | {"class": "oidcop.user_info.UserInfo", "kwargs": {"db_file": full_path("users.json")},}, "", 20 | ) 21 | login_hint_lookup = init_service({"class": "oidcop.login_hint.LoginHintLookup"}, None) 22 | login_hint_lookup.userinfo = userinfo 23 | 24 | assert login_hint_lookup("tel:0907865000") == "diana" 25 | 26 | 27 | def test_login_hint2acrs(): 28 | l2a = LoginHint2Acrs({"tel": ["http://www.swamid.se/policy/assurance/al1"]}) 29 | 30 | assert l2a("tel:+467865000") == ["http://www.swamid.se/policy/assurance/al1"] 31 | 32 | 33 | def test_login_hint2acrs_unmatched_schema(): 34 | l2a = LoginHint2Acrs({"tel": ["http://www.swamid.se/policy/assurance/al1"]}) 35 | 36 | assert l2a("mail:foobar@exaample.com") == [] 37 | 38 | 39 | def test_server_login_hint_lookup(): 40 | _str = open(full_path("op_config.json")).read() 41 | _conf = json.loads(_str) 42 | configuration = OPConfiguration(conf=_conf, base_path=BASEDIR, domain="127.0.0.1", port=443) 43 | 44 | server = Server(configuration) 45 | assert server.endpoint_context.login_hint_lookup("tel:0907865000") == "diana" 46 | -------------------------------------------------------------------------------- /tests/test_21_oidc_discovery_endpoint.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from oidcop.configure import OPConfiguration 5 | import pytest 6 | 7 | from oidcop.oidc.discovery import Discovery 8 | from oidcop.server import Server 9 | from oidcop.user_authn.authn_context import INTERNETPROTOCOLPASSWORD 10 | 11 | KEYDEFS = [ 12 | {"type": "RSA", "key": "", "use": ["sig"]}, 13 | {"type": "EC", "crv": "P-256", "use": ["sig"]}, 14 | ] 15 | 16 | BASEDIR = os.path.abspath(os.path.dirname(__file__)) 17 | 18 | 19 | class TestEndpoint(object): 20 | @pytest.fixture(autouse=True) 21 | def create_endpoint(self): 22 | conf = { 23 | "issuer": "https://example.com/", 24 | "token_expires_in": 600, 25 | "grant_expires_in": 300, 26 | "refresh_token_expires_in": 86400, 27 | "httpc_params": {"verify": False, "timeout": 1}, 28 | "endpoint": { 29 | "webfinger": { 30 | "path": ".well-known/webfinger", 31 | "class": Discovery, 32 | "kwargs": {"client_authn_method": None}, 33 | } 34 | }, 35 | "keys": {"uri_path": "static/jwks.json", "key_defs": KEYDEFS}, 36 | "authentication": { 37 | "anon": { 38 | "acr": INTERNETPROTOCOLPASSWORD, 39 | "class": "oidcop.user_authn.user.NoAuthn", 40 | "kwargs": {"user": "diana"}, 41 | } 42 | }, 43 | "template_dir": "template", 44 | } 45 | server = Server(OPConfiguration(conf=conf, base_path=BASEDIR), cwd=BASEDIR) 46 | self.endpoint = server.server_get("endpoint", "discovery") 47 | 48 | def test_do_response(self): 49 | args = self.endpoint.process_request({"resource": "acct:foo@example.com"}) 50 | msg = self.endpoint.do_response(**args) 51 | _resp = json.loads(msg["response"]) 52 | assert _resp == { 53 | "subject": "acct:foo@example.com", 54 | "links": [ 55 | { 56 | "href": "https://example.com/", 57 | "rel": "http://openid.net/specs/connect/1.0/issuer", 58 | } 59 | ], 60 | } 61 | -------------------------------------------------------------------------------- /tests/test_22_oidc_provider_config_endpoint.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import pytest 5 | 6 | from oidcop.configure import OPConfiguration 7 | from oidcop.oidc.provider_config import ProviderConfiguration 8 | from oidcop.oidc.token import Token 9 | from oidcop.server import Server 10 | 11 | BASEDIR = os.path.abspath(os.path.dirname(__file__)) 12 | 13 | KEYDEFS = [ 14 | {"type": "RSA", "key": "", "use": ["sig"]}, 15 | {"type": "EC", "crv": "P-256", "use": ["sig"]}, 16 | ] 17 | 18 | RESPONSE_TYPES_SUPPORTED = [ 19 | ["code"], 20 | ["token"], 21 | ["id_token"], 22 | ["code", "token"], 23 | ["code", "id_token"], 24 | ["id_token", "token"], 25 | ["code", "token", "id_token"], 26 | ["none"], 27 | ] 28 | 29 | CAPABILITIES = { 30 | "response_types_supported": [" ".join(x) for x in RESPONSE_TYPES_SUPPORTED], 31 | "token_endpoint_auth_methods_supported": [ 32 | "client_secret_post", 33 | "client_secret_basic", 34 | "client_secret_jwt", 35 | "private_key_jwt", 36 | ], 37 | "response_modes_supported": ["query", "fragment", "form_post"], 38 | "subject_types_supported": ["public", "pairwise" "ephemeral"], 39 | "grant_types_supported": [ 40 | "authorization_code", 41 | "implicit", 42 | "urn:ietf:params:oauth:grant-type:jwt-bearer", 43 | "refresh_token", 44 | ], 45 | "claim_types_supported": ["normal", "aggregated", "distributed"], 46 | "claims_parameter_supported": True, 47 | "request_parameter_supported": True, 48 | "request_uri_parameter_supported": True, 49 | } 50 | 51 | 52 | class TestEndpoint(object): 53 | @pytest.fixture 54 | def conf(self): 55 | return { 56 | "issuer": "https://example.com/", 57 | "httpc_params": { 58 | "verify": False 59 | }, 60 | "capabilities": CAPABILITIES, 61 | "keys": {"uri_path": "static/jwks.json", "key_defs": KEYDEFS}, 62 | "endpoint": { 63 | "provider_config": { 64 | "path": ".well-known/openid-configuration", 65 | "class": ProviderConfiguration, 66 | "kwargs": {}, 67 | }, 68 | "token": {"path": "token", "class": Token, "kwargs": {}}, 69 | }, 70 | "template_dir": "template", 71 | } 72 | 73 | @pytest.fixture(autouse=True) 74 | def create_endpoint(self, conf): 75 | server = Server(OPConfiguration(conf=conf, base_path=BASEDIR), cwd=BASEDIR) 76 | 77 | self.endpoint_context = server.endpoint_context 78 | self.endpoint = server.server_get("endpoint", "provider_config") 79 | 80 | def test_do_response(self): 81 | args = self.endpoint.process_request() 82 | msg = self.endpoint.do_response(args["response_args"]) 83 | assert isinstance(msg, dict) 84 | _msg = json.loads(msg["response"]) 85 | assert _msg 86 | assert _msg["token_endpoint"] == "https://example.com/token" 87 | assert _msg["jwks_uri"] == "https://example.com/static/jwks.json" 88 | assert set(_msg["claims_supported"]) == { 89 | "gender", 90 | "zoneinfo", 91 | "website", 92 | "phone_number_verified", 93 | "middle_name", 94 | "family_name", 95 | "nickname", 96 | "email", 97 | "preferred_username", 98 | "profile", 99 | "name", 100 | "phone_number", 101 | "given_name", 102 | "email_verified", 103 | "sub", 104 | "locale", 105 | "picture", 106 | "address", 107 | "updated_at", 108 | "birthdate", 109 | } 110 | assert ("Content-type", "application/json; charset=utf-8") in msg["http_headers"] 111 | 112 | def test_scopes_supported(self, conf): 113 | scopes_supported = ["openid", "random", "profile"] 114 | conf["capabilities"]["scopes_supported"] = scopes_supported 115 | 116 | server = Server(OPConfiguration(conf=conf, base_path=BASEDIR), cwd=BASEDIR) 117 | endpoint = server.server_get("endpoint", "provider_config") 118 | args = endpoint.process_request() 119 | msg = endpoint.do_response(args["response_args"]) 120 | assert isinstance(msg, dict) 121 | _msg = json.loads(msg["response"]) 122 | assert set(_msg["scopes_supported"]) == set(scopes_supported) 123 | assert set(_msg["claims_supported"]) == { 124 | "zoneinfo", 125 | "gender", 126 | "sub", 127 | "middle_name", 128 | "given_name", 129 | "nickname", 130 | "preferred_username", 131 | "name", 132 | "updated_at", 133 | "birthdate", 134 | "locale", 135 | "profile", 136 | "family_name", 137 | "picture", 138 | "website", 139 | } 140 | -------------------------------------------------------------------------------- /tests/test_32_oidc_read_registration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: latin-1 -*- 2 | import json 3 | import os 4 | 5 | from oidcop.configure import OPConfiguration 6 | import pytest 7 | from oidcmsg.oidc import RegistrationRequest 8 | 9 | from oidcop.cookie_handler import CookieHandler 10 | from oidcop.oidc.authorization import Authorization 11 | from oidcop.oidc.read_registration import RegistrationRead 12 | from oidcop.oidc.registration import Registration 13 | from oidcop.oidc.token import Token 14 | from oidcop.oidc.userinfo import UserInfo 15 | from oidcop.server import Server 16 | 17 | BASEDIR = os.path.abspath(os.path.dirname(__file__)) 18 | 19 | KEYDEFS = [ 20 | {"type": "RSA", "key": "", "use": ["sig"]}, 21 | {"type": "EC", "crv": "P-256", "use": ["sig"]}, 22 | ] 23 | 24 | COOKIE_KEY_DEFS = [ 25 | {"type": "oct", "kid": "sig", "use": ["sig"]}, 26 | {"type": "oct", "kid": "enc", "use": ["enc"]}, 27 | ] 28 | 29 | RESPONSE_TYPES_SUPPORTED = [ 30 | ["code"], 31 | ["token"], 32 | ["id_token"], 33 | ["code", "token"], 34 | ["code", "id_token"], 35 | ["id_token", "token"], 36 | ["code", "token", "id_token"], 37 | ["none"], 38 | ] 39 | 40 | CAPABILITIES = { 41 | "subject_types_supported": ["public", "pairwise", "ephemeral"], 42 | "grant_types_supported": [ 43 | "authorization_code", 44 | "implicit", 45 | "urn:ietf:params:oauth:grant-type:jwt-bearer", 46 | "refresh_token", 47 | ], 48 | } 49 | 50 | msg = { 51 | "application_type": "web", 52 | "redirect_uris": [ 53 | "https://client.example.org/callback", 54 | "https://client.example.org/callback2", 55 | ], 56 | "client_name": "My Example", 57 | "client_name#ja-Jpan-JP": "クライアント名", 58 | "subject_type": "pairwise", 59 | "token_endpoint_auth_method": "client_secret_basic", 60 | "jwks_uri": "https://client.example.org/my_public_keys.jwks", 61 | "userinfo_encrypted_response_alg": "RSA-OAEP", 62 | "userinfo_encrypted_response_enc": "A128CBC-HS256", 63 | "contacts": ["ve7jtb@example.org", "mary@example.org"], 64 | "request_uris": [ 65 | "https://client.example.org/rf.txt#qpXaRLh_n93TT", 66 | "https://client.example.org/rf.txt", 67 | ], 68 | "post_logout_redirect_uri": "https://rp.example.com/pl" 69 | } 70 | 71 | CLI_REQ = RegistrationRequest(**msg) 72 | 73 | 74 | class TestEndpoint(object): 75 | @pytest.fixture(autouse=True) 76 | def create_endpoint(self): 77 | conf = { 78 | "issuer": "https://example.com/", 79 | "httpc_params": {"verify": False, "timeout": 1}, 80 | "token_expires_in": 600, 81 | "grant_expires_in": 300, 82 | "refresh_token_expires_in": 86400, 83 | "capabilities": CAPABILITIES, 84 | "keys": {"key_defs": KEYDEFS, "uri_path": "static/jwks.json"}, 85 | "cookie_handler": { 86 | "class": CookieHandler, 87 | "kwargs": {"keys": {"key_defs": COOKIE_KEY_DEFS}}, 88 | }, 89 | "endpoint": { 90 | "registration": { 91 | "path": "registration", 92 | "class": Registration, 93 | "kwargs": {"client_auth_method": None}, 94 | }, 95 | "registration_api": { 96 | "path": "registration_api", 97 | "class": RegistrationRead, 98 | "kwargs": {"client_authn_method": ["bearer_header"]}, 99 | }, 100 | "authorization": {"path": "authorization", "class": Authorization, "kwargs": {},}, 101 | "token": { 102 | "path": "token", 103 | "class": Token, 104 | "kwargs": { 105 | "client_authn_method": [ 106 | "client_secret_post", 107 | "client_secret_basic", 108 | "client_secret_jwt", 109 | "private_key_jwt", 110 | ] 111 | }, 112 | }, 113 | "userinfo": {"path": "userinfo", "class": UserInfo, "kwargs": {}}, 114 | }, 115 | "template_dir": "template", 116 | } 117 | server = Server(OPConfiguration(conf=conf, base_path=BASEDIR), cwd=BASEDIR) 118 | self.registration_endpoint = server.server_get("endpoint", "registration") 119 | self.registration_api_endpoint = server.server_get("endpoint", "registration_read") 120 | 121 | def test_do_response(self): 122 | _req = self.registration_endpoint.parse_request(CLI_REQ.to_json()) 123 | _resp = self.registration_endpoint.process_request(request=_req) 124 | msg = self.registration_endpoint.do_response(**_resp) 125 | assert isinstance(msg, dict) 126 | _msg = json.loads(msg["response"]) 127 | assert _msg 128 | 129 | http_info = { 130 | "headers": { 131 | "authorization": "Bearer {}".format( 132 | _resp["response_args"]["registration_access_token"] 133 | ) 134 | } 135 | } 136 | 137 | _api_req = self.registration_api_endpoint.parse_request( 138 | "client_id={}".format(_resp["response_args"]["client_id"]), http_info=http_info, 139 | ) 140 | assert set(_api_req.keys()) == {"client_id"} 141 | 142 | _info = self.registration_api_endpoint.process_request(request=_api_req) 143 | assert set(_info.keys()) == {"response_args"} 144 | assert _info["response_args"] == _resp["response_args"] 145 | 146 | _endp_response = self.registration_api_endpoint.do_response(_info) 147 | assert set(_endp_response.keys()) == {"response", "http_headers"} 148 | assert ("Content-type", "application/json; charset=utf-8") in _endp_response["http_headers"] 149 | -------------------------------------------------------------------------------- /tests/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "diana": { 3 | "name": "Diana Krall", 4 | "given_name": "Diana", 5 | "family_name": "Krall", 6 | "nickname": "Dina", 7 | "email": "diana@example.org", 8 | "email_verified": false, 9 | "phone_number": "+46907865000", 10 | "address": { 11 | "street_address": "Umeå Universitet", 12 | "locality": "Umeå", 13 | "postal_code": "SE-90187", 14 | "country": "Sweden" 15 | }, 16 | "eduperson_scoped_affiliation": [ 17 | "staff@example.org" 18 | ], 19 | "webid": "http://bblfish.net/#hjs" 20 | }, 21 | "babs": { 22 | "name": "Barbara J Jensen", 23 | "given_name": "Barbara", 24 | "family_name": "Jensen", 25 | "nickname": "babs", 26 | "email": "babs@example.com", 27 | "email_verified": true, 28 | "address": { 29 | "street_address": "100 Universal City Plaza", 30 | "locality": "Hollywood", 31 | "region": "CA", 32 | "postal_code": "91608", 33 | "country": "USA" 34 | } 35 | }, 36 | "upper": { 37 | "name": "Upper Crust", 38 | "given_name": "Upper", 39 | "family_name": "Crust", 40 | "email": "uc@example.com", 41 | "email_verified": true 42 | } 43 | } --------------------------------------------------------------------------------