diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
new file mode 100644
index 00000000..206bd562
--- /dev/null
+++ b/.github/workflows/python-app.yml
@@ -0,0 +1,59 @@
+# This workflow will install Python dependencies, run tests and lint with a single version of Python
+# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
+
+name: oidc-op
+
+on:
+ push:
+ branches: [ master, develop ]
+ pull_request:
+ branches: [ master, develop ]
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version:
+ - '3.7'
+ - '3.8'
+ - '3.9'
+
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v2
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
+ if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+ python setup.py install
+ - name: Lint with flake8
+ run: |
+ # stop the build if there are Python syntax errors or undefined names
+ flake8 src/oidcop --count --select=E9,F63,F7,F82 --show-source --statistics
+ # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
+ flake8 src/oidcop --max-line-length 120 --count --exit-zero --statistics
+
+ - name: Test with pytest
+ run: |
+ pytest --cov=oidcop tests/
+ - name: Bandit Security Scan
+ run: |
+ bandit --skip B105,B106,B107 -r src/oidcop/
+ #- name: Upload coverage to Codecov
+ #uses: codecov/codecov-action@v1
+ #with:
+ #token: ${{ secrets.CODECOV_TOKEN }}
+ #file: example/coverage.xml
+ #flags: unittests
+ #env_vars: OS,PYTHON
+ #name: codecov-umbrella
+ #fail_ci_if_error: true
+ #path_to_write_report: ./codecov_report.txt
diff --git a/.gitignore b/.gitignore
index cc7f589b..831dbd0f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,3 @@
-django_op/db.sqlite3
-templates
static/
private/
conf.yaml
@@ -117,3 +115,5 @@ venv.bak/
src/oidcendpoint.egg-info/
.iframes/
+tests/pairwise.salt
+tests/public.salt
diff --git a/.isort.cfg b/.isort.cfg
new file mode 100644
index 00000000..54146535
--- /dev/null
+++ b/.isort.cfg
@@ -0,0 +1,7 @@
+[settings]
+force_single_line = 1
+known_first_party = oidcop
+known_third_party = cryptojwt, oidcmsg
+known_future_library = future,past
+default_section = THIRDPARTY
+line_length = 100
\ No newline at end of file
diff --git a/.readthedocs.yml b/.readthedocs.yml
new file mode 100644
index 00000000..274c940b
--- /dev/null
+++ b/.readthedocs.yml
@@ -0,0 +1,23 @@
+# .readthedocs.yml
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+# Build documentation in the docs/ directory with Sphinx
+sphinx:
+ configuration: docs/source/conf.py
+
+# Build documentation with MkDocs
+#mkdocs:
+# configuration: mkdocs.yml
+
+# Optionally build your docs in additional formats such as PDF and ePub
+formats: all
+
+# Optionally set the version of Python and requirements required to build your docs
+python:
+ version: 3.8
+ install:
+ - requirements: requirements-docs.txt
diff --git a/README.md b/README.md
index caeb2797..26c1da66 100644
--- a/README.md
+++ b/README.md
@@ -1,233 +1,39 @@
# oidc-op
-Examples of a OIDC OPs with CherryPy, Flask and Django.
-**NOT** something you should even image running in a production environment.
+This project is a Python implementation of an **OIDC Provider** on top of [jwtconnect.io](https://jwtconnect.io/) that shows to you how to 'build' an OP using the classes and functions provided by oidc-op.
-### Introduction
+If you want to add or replace functionality the official documentation should be able to tell you how.
+If you are just going to build a standard OP you only have to understand how to write your configuration file.
+In `example/` folder you'll find some complete examples based on flask and django.
-This project are here to show you how to 'build' an OP using the
-classes and functions provided by oidcendpoint.
+Idpy OIDC-op implements the following standards:
-If you are just going to build a standard OP you only have to write the
-configuration file. If you want to add or replace functionality this document
-should be able to tell you how.
+* [OpenID Connect Core 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-core-1_0.html)
+* [Web Finger](https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery)
+* [OpenID Connect Discovery 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-discovery-1_0.html)
+* [OpenID Connect Dynamic Client Registration 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-registration-1_0.html)
+* [OpenID Connect Session Management 1.0](https://openid.net/specs/openid-connect-session-1_0.html)
+* [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html)
+* [OpenID Connect Front-Channel Logout 1.0](https://openid.net/specs/openid-connect-frontchannel-1_0.html)
+* [OAuth2 Token introspection](https://tools.ietf.org/html/rfc7662)
-Setting up an OP means making a number if decisions. Like, should the OP support:
+It also comes with the following `add_on` modules.
-- [Web Finger](https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery)
-- [dynamic discovery](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig)
-- [dynamic client registration](https://openid.net/specs/openid-connect-registration-1_0.html)
+* Custom scopes, that extends [OIDC standard ScopeClaims](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims)
+* [Proof Key for Code Exchange by OAuth Public Clients (PKCE)](https://tools.ietf.org/html/rfc7636)
+* [OAuth2 RAR](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rar)
+* [OAuth2 DPoP](https://tools.ietf.org/id/draft-fett-oauth-dpop-04.html)
-All these are services you can access at endpoints. The total set of endpoints
-that this package supports are
+The entire project code is open sourced and therefore licensed under the [Apache 2.0](https://en.wikipedia.org/wiki/Apache_License)
-- webfinger
-- provider_info
-- registration
-- authorization
-- token
-- refresh_token
-- userinfo
-- end_session
+For any futher information please read the [Official Documentation](#TODO).
-### Configuration directives
+# Contribute
+[Join in](https://idpy.org/contribute/).
-_issuer_
-The issuer ID of the OP, unique value.
+# Authors
-_capabilities_
-
-This covers most of the basic functionality of the OP. The key words are the
-same as defined in
-https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata .
-A couple of things are defined else where. Like the endpoints, issuer id,
-jwks_uri and the authentication methods at the token endpoint.
-
-An example:
-
- response_types_supported:
- - code
- - token
- - id_token
- - "code token"
- - "code id_token"
- - "id_token token"
- - "code id_token token"
- - none
- response_modes_supported:
- - query
- - fragment
- - form_post
- subject_types_supported:
- - public
- - pairwise
- grant_types_supported:
- - authorization_code
- - implicit
- - urn:ietf:params:oauth:grant-type:jwt-bearer
- - refresh_token
- claim_types_supported:
- - normal
- - aggregated
- - distributed
-
- claims_parameter_supported: True
- request_parameter_supported: True
- request_uri_parameter_supported: True
- frontchannel_logout_supported: True
- frontchannel_logout_session_supported: True
- backchannel_logout_supported: True
- backchannel_logout_session_supported: True
- check_session_iframe: https://127.0.0.1:5000/check_session_iframe
-
-
-_id_token_
-
-Defines which class that handles creating an ID Token and possibly also
-arguments used when initiating that class.
-
-An example:
-
- id_token:
- class: oidcendpoint.id_token.IDToken
- kwargs:
- default_claims:
- email:
- essential: True
- email_verified:
- essential: True
-
-
-### OIDC Provider example setup
-
-Create an environment
-````
-virtualenv -ppython3 env
-source env/bin/activate
-````
-
-##### Install oidc-op
-````
-pip install git+https://github.com/rohe/oidc-op.git
-
-# get the usage examples
-git clone https://github.com/rohe/oidc-op.git
-````
-
-##### Configure a Django OP
-
-See
-
-https://github.com/peppelinux/django-oidc-op
-
-##### Configure a Flask OP
-
-````
-pip install flask
-cd oidc-op/
-
-# configuration: create a private folder
-cp -R flask_op/private .
-
-# copy required files
-cp flask_op/passwd.json private/
-cp flask_op/conf.yaml private/
-cp -R flask_op/templates .
-
-# create a JWK for cookie signing
-jwkgen --kty=SYM --kid cookie > private/cookie_sign_jwk.json
-````
-
-##### About JWK Set (JWKS) files
-see: https://cryptojwt.readthedocs.io/en/latest/keyhandling.html
-
-You can use `cryptojwt.key_jar.init_key_jar` to create JWKS file.
-An easy way can be to configure the auto creation of JWKS files directly in your conf.yaml file.
-Using `read_only: False` in `OIDC_KEYS` it will create the path within the JWKS files.
-Change it to `True` if you don't want to overwrite them on each execution.
-
-````
-# in conf.yaml
-#
-OIDC_KEYS:
- 'private_path': './private/jwks.json'
- 'key_defs': *keydef
- 'public_path': './static/jwks.json'
- # this will create the jwks files if they absent
- 'read_only': False
-````
-
-In the JWTConnect-Python-CryptoJWT distribution there is also a script you can use to construct a JWK.
-
-You can for instance do:
-````
-$ jwkgen --kty=RSA
-{
- "d": "b9ucfay9vxDvz_nRZMVSUR9eRvHNMo0tc8Bl7tWkwxTis7LBXxmbMH1yzLs8omUil_u2a-Z_6VlKENxacuejYYcOhs6bfaU3iOqJbGi2p4t2i1oxjuF-cX6BZ5aHB5Wfb1uTXXobHokjcjVVDmBr_fNYBEPtZsVYqyN9sR9KE_ZLHEPks3IER09aX9G3wiB_PgcxQDRAl72qucsBz9_W9KS-TVWs-qCEqtXLmx9AAN6P8SjUcHAzEb0ZCJAYCkVu34wgNjxVaGyYN1qMA-1iOOVz--wtMyBwc5atSDBDgUApxFyj_DHSeBl81IHedcPjS9azxqFhumP7oJJyfecfSQ",
- "e": "AQAB",
- "kid": "cHZQbWRrMzRZak53U1pfSUNjY0dKd2xXaXRKenktdUduUjVBVTl3VE5ndw",
- "kty": "RSA",
- "n": "73XCXV2iiubSCEaFe26OpVnsBFlXwXh_yDCDyBqFgAFi5WdZTpRMJZoK0nn_vv2MvrXqFnw6IfXkwdsRGlMsNldVy36003gKa584CNksxfenwJZcF-huASUrSJEFr-3c0fMT_pLyAc7yf3rNCdRegzbBXSvIGKQpaeIjIFYftAPd9tjGA_SuYWVQDsSh3MeGbB4wt0lArAyFZ4f5o7SSxSDRCUF3ng3CB_QKUAaDHHgXrcNG_gPpgqQZjsDJ0VwMXjFKxQmskbH-dfsQ05znQsYn3pjcd_TEZ-Yu765_L5uxUrkEy_KnQXe1iqaQHcnfBWKXt18NAuBfgmKsv8gnxQ",
- "p": "_RPgbiQcFu8Ekp-tC-Kschpag9iaLc9aDqrxE6GWuThEdExGngP_p1I7Qd7gXHHTMXLp1c4gH2cKx4AkfQyKny2RJGtV2onQButUU5r0gwnlqqycIA2Dc9JiH85PX2Z889TKJUlVETfYbezHbKhdsazjjsXCQ6p9JfkmgfBQOXM",
- "q": "8jmgnadtwjMt96iOaoL51irPRXONO82tLM2AAZAK5Obsj23bZ9LFiw2Joh5oCSFdoUcRhbbIhCIv2aT4T_XKnDGnddrkxpF5Xgu0-hPNYnJx5m4kuzerot4j79Tx6qO-bshaaGz50MHs1vHSeFaDVN4fvh_hDWpV1BCNI0PKK-c"
-}
-SHA-256: pvPmdk34YjNwSZ_ICccGJwlWitJzy-uGnR5AU9wTNgw
-````
-
-
-##### Run the server
-````
-python -m flask_op.server private/conf.yaml
-````
-
-Then open your browser to `https://127.0.0.1:5000/.well-known/openid-configuration` to get the OpenID Provider Configuration resource.
-
-
-##### Install OidcRP and configure flask-rp
-
-It uses `JWTConnect-Python-OidcRP` as Relaing Party for tests, see [related page](https://github.com/openid/JWTConnect-Python-OidcRP).
-You can run a working instance of `JWTConnect-Python-OidcRP.flask_rp` with:
-
-````
-pip install git+https://github.com/openid/JWTConnect-Python-OidcRP.git
-
-# get entire project to have examples files
-git clone https://github.com/openid/JWTConnect-Python-OidcRP.git
-cd JWTConnect-Python-OidcRP
-
-# run it as it come
-python3 -m flask_rp.wsgi flask_rp/conf.yaml
-
-# if you use django_op
-RP_LOGFILE_NAME="./flrp.django.log" python3 -m flask_rp.wsgi django_op/example/data/oidc_rp/conf.django.yaml
-````
-
-Now you can connect to `https://127.0.0.1:8090/` to see the RP landing page and select your authentication endpoint.
-
-
-### Authentication examples
-
-
-
-Get to the RP landing page to choose your authentication endpoint. The first option aims to use _Provider Discovery_.
-
-----------------------------------
-
-
-
-AS/OP accepted our authentication request and prompt to us the login form. Read passwd.json file to get credentials.
-
-----------------------------------
-
-
-
-The identity representation with the information fetched from the user info endpoint.
-
-----------------------------------
-
-
-
-We can even test the single logout
+- Roland Hedberg
diff --git a/chpy/private/jwks.json b/chpy/private/jwks.json
deleted file mode 100644
index 4d90bca4..00000000
--- a/chpy/private/jwks.json
+++ /dev/null
@@ -1,32 +0,0 @@
-{
- "keys": [
- {
- "kty": "RSA",
- "use": "sig",
- "kid": "S3ZNcGdDUnBPRGVjNXJISzg3N2RxMXdZVUJDekU2WTZ5NGRPdVJ4VlpEcw",
- "n": "xIja_Ne7PPmDpvSYJWp_-GSG3PCmhwNO98QctjwsBVOmyEOcJ7gGj8kC9wsC7_l0VuIsnQLzDp8ZaoPoow5_dnOR9mi_zuuPa-6v3qaIed7kD5LSlXWkJf-SNWtFPqZPM1eIbRAkpX8oB097x_mFtt-Hag2IR0FPGFmwo03NPwVjci0PMOQbgAUt1iTfuXTbssNQcT8iHu-B_BhAgsfFBQjZvU06EuQx9XqDmm8W-MtVVazjQ9_jyHVgIoxo13h_aIgbnJGTeYWVQgVaQTWRiJGuoRtR4FVffDh7Ntax7iNl3YtmVwHaAA_I2S77G_FE_yUfEdQU0smhLfgSMIFs1w",
- "e": "AQAB",
- "d": "K5_BFAyCuCceTOnP98Yq_6S4tsQIAsH-PkreoKi8kLYfuCYhvENB2ZqhuNpN3Zx-cWakxnlIID-6cYU0FzV5n35JsOtRYAmzfR6vFPncI0kRUE-jxJ8nP3P6LTdRWHWr8NaGJsdFiyKtbOn8rMe7IXt-YFD-pepeNyZ5adY0L7-LivJ-7P3DDfF2976PySBk388YJEnWNSYkJDs1mr_5C54tyQO1y-d5x4EkuIuqcK-e_8vunm8HghgAEofH2FW5IDDlfje_y666oL4qqHoPDQPb02pzRzYm3PtC4KgFYavgHcD1XPfw9z9ywecKFc94-9LRs-JDjYpjz7QQLZfpWQ",
- "p": "8zXgwSePXgV62ClQqE1twDkyIYI7vUWZk6F2PkrbipdaPqW6pkQDbfkWwQVNx6Wv6onmDcjt5Y5QQAycTlxoSaOsFzEw73iBBw-iNsvVuWwvMfLOJrxwm75ROruBooygukq0TUcigM3l2d5LA3Uzs9Mc_WqDjoByonEc_F5egls",
- "q": "zt6fXsgDIwZrNIQ9BYlp2bUDn_Wu7ol4Hn73gNZ0Q_BKmy06JmzH_jNrn1tHO-zg5C44ysYu-Lv-H_0Fg4WcuRZ2KjNbv4UVIJ9gR9KPvKyRWokhG7HrqWmkK5gf5T0hbTrdqPISzndwuw8zyu_nWUHtZAD1w3GB0p4YssjcUDU"
- },
- {
- "kty": "EC",
- "use": "sig",
- "kid": "WnNVcjd0NEJzYWJXN3dDZ1JpclZkWHMyU3Q5eGs0SG9qZFlsbENPUjkydw",
- "crv": "P-256",
- "x": "XqBi5FEAx3485Kw28qgy5vWuFE4OPfmq2eht6iuuo-Q",
- "y": "2MrrzBhkwOe4Q2Bzxmm_eyppE8Y7r2JFqQjAyK5C0w8",
- "d": "5xOhuurIpPXbYdD81QqI5BobOXKJ9IK0fD31koQUtwc"
- },
- {
- "kty": "EC",
- "use": "sig",
- "kid": "RWVYM1R2Y0RQRl9aWHhGSDYtVUdIQlNVTmZLWmNJTXY2N3NaZWlkOVRxTQ",
- "crv": "P-384",
- "x": "n2qe-DM4-dtN6M_lZR5rmgczHUoUgtttxv6T-mgUsqNa-Sd4Zr_vWp7v2pC1FO5f",
- "y": "kMHo2OVLF2TNXZRbJEjHfFFUiIaMyysXaC62PGd3Aw6Mkq1LdudLhVIJo00osn4h",
- "d": "NOydgMRJ5ofyI5sqJiCKN8kTKAKEIkPrFRrYF1FPpJ-36VECttQJxAMUdPkNPjJm"
- }
- ]
-}
\ No newline at end of file
diff --git a/chpy/static/jwks.json b/chpy/static/jwks.json
deleted file mode 100644
index 978b14cc..00000000
--- a/chpy/static/jwks.json
+++ /dev/null
@@ -1 +0,0 @@
-{"keys": [{"kty": "RSA", "use": "sig", "kid": "S3ZNcGdDUnBPRGVjNXJISzg3N2RxMXdZVUJDekU2WTZ5NGRPdVJ4VlpEcw", "e": "AQAB", "n": "xIja_Ne7PPmDpvSYJWp_-GSG3PCmhwNO98QctjwsBVOmyEOcJ7gGj8kC9wsC7_l0VuIsnQLzDp8ZaoPoow5_dnOR9mi_zuuPa-6v3qaIed7kD5LSlXWkJf-SNWtFPqZPM1eIbRAkpX8oB097x_mFtt-Hag2IR0FPGFmwo03NPwVjci0PMOQbgAUt1iTfuXTbssNQcT8iHu-B_BhAgsfFBQjZvU06EuQx9XqDmm8W-MtVVazjQ9_jyHVgIoxo13h_aIgbnJGTeYWVQgVaQTWRiJGuoRtR4FVffDh7Ntax7iNl3YtmVwHaAA_I2S77G_FE_yUfEdQU0smhLfgSMIFs1w"}, {"kty": "EC", "use": "sig", "kid": "WnNVcjd0NEJzYWJXN3dDZ1JpclZkWHMyU3Q5eGs0SG9qZFlsbENPUjkydw", "crv": "P-256", "x": "XqBi5FEAx3485Kw28qgy5vWuFE4OPfmq2eht6iuuo-Q", "y": "2MrrzBhkwOe4Q2Bzxmm_eyppE8Y7r2JFqQjAyK5C0w8"}, {"kty": "EC", "use": "sig", "kid": "RWVYM1R2Y0RQRl9aWHhGSDYtVUdIQlNVTmZLWmNJTXY2N3NaZWlkOVRxTQ", "crv": "P-384", "x": "n2qe-DM4-dtN6M_lZR5rmgczHUoUgtttxv6T-mgUsqNa-Sd4Zr_vWp7v2pC1FO5f", "y": "kMHo2OVLF2TNXZRbJEjHfFFUiIaMyysXaC62PGd3Aw6Mkq1LdudLhVIJo00osn4h"}]}
\ No newline at end of file
diff --git a/django_op/.gitignore b/django_op/.gitignore
deleted file mode 100644
index 302d5602..00000000
--- a/django_op/.gitignore
+++ /dev/null
@@ -1,7 +0,0 @@
-*/db.sqlite3
-example/db.sqlite3
-example/data/static/*
-!example/data/static/README.md
-__pycache__/*
-*.pyc
-
diff --git a/django_op/README.md b/django_op/README.md
deleted file mode 100644
index 3fd068d2..00000000
--- a/django_op/README.md
+++ /dev/null
@@ -1,6 +0,0 @@
-djangoioidc-op
---------------
-
-Moved permanently to:
-
-https://github.com/peppelinux/django-oidc-op
diff --git a/doc/source/howto.rst b/doc/source/howto.rst
deleted file mode 100644
index 2cd7cf92..00000000
--- a/doc/source/howto.rst
+++ /dev/null
@@ -1,110 +0,0 @@
-.. _oidcop:
-
-***************************
-The OpenID Connect Provider
-***************************
-
-============
-Introduction
-============
-
-This documentation are here to show you how to 'build' an OP using the
-classes and functions provided by oidcendpoint.
-
-If you are just going to build a standard OP you only have to write the
-configuration file. If you want to add or replace functionality this document
-should be able to tell you how.
-
-Setting up an OP means making a number if decisions. Like, should the OP support
-Web Finger (https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery),
-dynamic discovery (https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig)
-and dynamic client registration (https://openid.net/specs/openid-connect-registration-1_0.html).
-
-All these are services you can access at endpoints. The total set of endpoints
-that this package supports are
-
-- webfinger
-- provider_info
-- registration
-- authorization
-- token
-- refresh_token
-- userinfo
-- end_session
-
-
-========================
-Configuration directives
-========================
-
-------
-issuer
-------
-
-The issuer ID of the OP.
-
-------------
-capabilities
-------------
-
-This covers most of the basic functionality of the OP. The key words are the
-same as defined in
-https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata .
-A couple of things are defined else where. Like the endpoints, issuer id,
-jwks_uri and the authentication methods at the token endpoint.
-
-An example::
-
- response_types_supported:
- - code
- - token
- - id_token
- - "code token"
- - "code id_token"
- - "id_token token"
- - "code id_token token"
- - none
- response_modes_supported:
- - query
- - fragment
- - form_post
- subject_types_supported:
- - public
- - pairwise
- grant_types_supported:
- - authorization_code
- - implicit
- - urn:ietf:params:oauth:grant-type:jwt-bearer
- - refresh_token
- claim_types_supported:
- - normal
- - aggregated
- - distributed
- claims_parameter_supported: True
- request_parameter_supported: True
- request_uri_parameter_supported: True
- frontchannel_logout_supported: True
- frontchannel_logout_session_supported: True
- backchannel_logout_supported: True
- backchannel_logout_session_supported: True
- check_session_iframe: https://127.0.0.1:5000/check_session_iframe
-
-
---------
-id_token
---------
-
-Defines which class that handles creating an ID Token and possibly also
-arguments used when initiating that class.
-An example::
-
- id_token:
- class: oidcendpoint.id_token.IDToken
- kwargs:
- default_claims:
- email:
- essential: True
- email_verified:
- essential: True
-
-
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 00000000..d0c3cbf1
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS ?=
+SPHINXBUILD ?= sphinx-build
+SOURCEDIR = source
+BUILDDIR = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 00000000..6247f7e2
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.http://sphinx-doc.org/
+ exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/doc/images/1.png b/docs/source/_images/1.png
similarity index 100%
rename from doc/images/1.png
rename to docs/source/_images/1.png
diff --git a/doc/images/2.png b/docs/source/_images/2.png
similarity index 100%
rename from doc/images/2.png
rename to docs/source/_images/2.png
diff --git a/doc/images/3.png b/docs/source/_images/3.png
similarity index 100%
rename from doc/images/3.png
rename to docs/source/_images/3.png
diff --git a/doc/images/4.png b/docs/source/_images/4.png
similarity index 100%
rename from doc/images/4.png
rename to docs/source/_images/4.png
diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css
new file mode 100644
index 00000000..5584d1b7
--- /dev/null
+++ b/docs/source/_static/custom.css
@@ -0,0 +1,82 @@
+body,
+h1, h2,
+.rst-content .toctree-wrapper p.caption,
+h3, h4, h5, h6,
+legend{
+ font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif;
+}
+
+.wy-side-nav-search{
+ background: #ffffff;
+}
+
+.wy-side-nav-search>a,
+.wy-side-nav-search .wy-dropdown>a{
+ color: #9b9c9e;
+ font-weight: normal;
+}
+
+.wy-menu-vertical header,
+.wy-menu-vertical p.caption{
+ color: #fff;
+ font-size:85%;
+}
+
+.wy-nav-top{
+ background: #fff;
+ border-bottom: 1px solid #f7f5f5;
+}
+
+.wy-nav-top a{
+ display: block;
+ color: #9b9c9e;
+ font-weight: normal;
+}
+
+.wy-nav-top i{
+ color: #BE0417;
+}
+
+.wy-nav-top img{
+ border-radius: 0;
+ background: none;
+ width: 65%;
+}
+
+img{
+ height: auto !important;
+}
+
+.document{
+ text-align: justify;
+}
+
+h1{
+ text-align: left;
+}
+
+#logo_main{
+ margin-bottom: 0;
+}
+
+#title_under_logo{
+ margin-bottom: 1em;
+}
+
+.alert-danger {
+ color: #721c24;
+ background-color: #f8d7da;
+ border-color: #f5c6cb;
+}
+.alert-primary {
+ color: #004085;
+ background-color: #cce5ff;
+ border-color: #b8daff;
+}
+.alert {
+ position: relative;
+ padding: .75rem 1.25rem;
+ margin-bottom: 1rem;
+ border: 1px solid transparent;
+ border-radius: .25rem;
+}
diff --git a/docs/source/_static/logo.png b/docs/source/_static/logo.png
new file mode 100644
index 00000000..25756b6e
Binary files /dev/null and b/docs/source/_static/logo.png differ
diff --git a/docs/source/_templates/idpy_template/footer.html b/docs/source/_templates/idpy_template/footer.html
new file mode 100644
index 00000000..8cbf152b
--- /dev/null
+++ b/docs/source/_templates/idpy_template/footer.html
@@ -0,0 +1,53 @@
+
diff --git a/docs/source/_templates/idpy_template/layout.html b/docs/source/_templates/idpy_template/layout.html
new file mode 100644
index 00000000..1bca49b8
--- /dev/null
+++ b/docs/source/_templates/idpy_template/layout.html
@@ -0,0 +1,53 @@
+{% extends "!layout.html" %}
+
+{% block extrahead %}
+
+
+{% endblock %}
+
+
+
+{% block sidebartitle %}
+
+{% if logo %}
+ {# Not strictly valid HTML, but it's the only way to display/scale
+ it properly, without weird scripting or heaps of work
+ #}
+
+{% endif %}
+
+{% if logo and theme_logo_only %}
+
+{% else %}
+ {{ project }}
+{% endif %}
+
+
+{% if theme_display_version %}
+ {%- set nav_version = version %}
+ {% if READTHEDOCS and current_version %}
+ {%- set nav_version = current_version %}
+ {% endif %}
+ {% if nav_version %}
+
+ {{ nav_version }}
+
+ {% endif %}
+{% endif %}
+
+{% include "searchbox.html" %}
+
+{% endblock %}
+
+
+
+
+{% block mobile_nav %}
+
+
+
+
+
+ {{ project }}
+
+{% endblock %}
diff --git a/docs/source/conf.py b/docs/source/conf.py
new file mode 100644
index 00000000..34382356
--- /dev/null
+++ b/docs/source/conf.py
@@ -0,0 +1,56 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# This file only contains a selection of the most common options. For a full
+# list see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+# import os
+# import sys
+# sys.path.insert(0, os.path.abspath('.'))
+from recommonmark.parser import CommonMarkParser
+
+# -- Project information -----------------------------------------------------
+
+project = 'oidcop'
+copyright = '2021, Identity Python'
+author = 'Giuseppe De Marco, Roland Hedberg'
+
+# The full version, including alpha/beta/rc tags
+release = '0.1.0'
+
+
+# -- General configuration ---------------------------------------------------
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = ['sphinxcontrib.images', 'recommonmark']
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates/idpy_template']
+html_logo = "_static/logo.png"
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns = []
+
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'sphinx_rtd_theme'
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+source_suffix = ['.rst', '.md']
diff --git a/docs/source/contents/conf.rst b/docs/source/contents/conf.rst
new file mode 100644
index 00000000..d12ba6e1
--- /dev/null
+++ b/docs/source/contents/conf.rst
@@ -0,0 +1,602 @@
+========================
+Configuration directives
+========================
+
+------
+issuer
+------
+
+The issuer ID of the OP, a unique value in URI format.
+
+
+--------------
+session params
+--------------
+
+Configuration parameters used by session manager
+
+ "session_params": {
+ "password": "__password_used_to_encrypt_access_token_sid_value",
+ "salt": "salt involved in session sub hash ",
+ "sub_func": {
+ "public": {
+ "class": "oidcop.session.manager.PublicID",
+ "kwargs": {
+ "salt": "sdfsdfdsf"
+ }
+ },
+ "pairwise": {
+ "class": "oidcop.session.manager.PairWiseID",
+ "kwargs": {
+ "salt": "sdfsdfsdf"
+ }
+ }
+ }
+ },
+
+password
+########
+
+Encryption key used to encrypt the SessionID (sid) in access_token.
+If unset it will be random.
+
+
+salt
+####
+
+Salt, value or filename, used in sub_funcs (pairwise, public) for creating the opaque hash of *sub* claim.
+
+------
+add_on
+------
+
+An example::
+
+ "add_on": {
+ "pkce": {
+ "function": "oidcop.oidc.add_on.pkce.add_pkce_support",
+ "kwargs": {
+ "essential": false,
+ "code_challenge_method": "S256 S384 S512"
+ }
+ },
+ "claims": {
+ "function": "oidcop.oidc.add_on.custom_scopes.add_custom_scopes",
+ "kwargs": {
+ "research_and_scholarship": [
+ "name",
+ "given_name",
+ "family_name",
+ "email",
+ "email_verified",
+ "sub",
+ "iss",
+ "eduperson_scoped_affiliation"
+ ]
+ }
+ }
+ }
+
+--------------
+authentication
+--------------
+
+An example::
+
+ "authentication": {
+ "user": {
+ "acr": "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword",
+ "class": "oidcop.user_authn.user.UserPassJinja2",
+ "kwargs": {
+ "verify_endpoint": "verify/user",
+ "template": "user_pass.jinja2",
+ "db": {
+ "class": "oidcop.util.JSONDictDB",
+ "kwargs": {
+ "filename": "passwd.json"
+ }
+ },
+ "page_header": "Testing log in",
+ "submit_btn": "Get me in!",
+ "user_label": "Nickname",
+ "passwd_label": "Secret sauce"
+ }
+ }
+ },
+
+------------
+capabilities
+------------
+
+This covers most of the basic functionality of the OP. The key words are the
+same as defined in `OIDC Discovery `_.
+A couple of things are defined else where. Like the endpoints, issuer id,
+jwks_uri and the authentication methods at the token endpoint.
+
+An example::
+
+ response_types_supported:
+ - code
+ - token
+ - id_token
+ - "code token"
+ - "code id_token"
+ - "id_token token"
+ - "code id_token token"
+ - none
+ response_modes_supported:
+ - query
+ - fragment
+ - form_post
+ subject_types_supported:
+ - public
+ - pairwise
+ grant_types_supported:
+ - authorization_code
+ - implicit
+ - urn:ietf:params:oauth:grant-type:jwt-bearer
+ - refresh_token
+ claim_types_supported:
+ - normal
+ - aggregated
+ - distributed
+ claims_parameter_supported: True
+ request_parameter_supported: True
+ request_uri_parameter_supported: True
+ frontchannel_logout_supported: True
+ frontchannel_logout_session_supported: True
+ backchannel_logout_supported: True
+ backchannel_logout_session_supported: True
+ check_session_iframe: https://127.0.0.1:5000/check_session_iframe
+
+
+-----------
+cookie_name
+-----------
+
+An example::
+
+ "cookie_name": {
+ "session": "oidc_op",
+ "register": "oidc_op_rp",
+ "session_management": "sman"
+ },
+
+-------------
+cookie_dealer
+-------------
+
+An example::
+
+ "cookie_dealer": {
+ "class": "oidcop.cookie.CookieDealer",
+ "kwargs": {
+ "sign_jwk": {
+ "filename": "private/cookie_sign_jwk.json",
+ "type": "OCT",
+ "kid": "cookie_sign_key_id"
+ },
+ "enc_jwk": {
+ "filename": "private/cookie_enc_jwk.json",
+ "type": "OCT",
+ "kid": "cookie_enc_key_id"
+ },
+ "default_values": {
+ "name": "oidc_op",
+ "domain": "127.0.0.1",
+ "path": "/",
+ "max_age": 3600
+ }
+ }
+ },
+
+--------
+endpoint
+--------
+
+An example::
+
+ "endpoint": {
+ "webfinger": {
+ "path": ".well-known/webfinger",
+ "class": "oidcop.oidc.discovery.Discovery",
+ "kwargs": {
+ "client_authn_method": null
+ }
+ },
+ "provider_info": {
+ "path": ".well-known/openid-configuration",
+ "class": "oidcop.oidc.provider_config.ProviderConfiguration",
+ "kwargs": {
+ "client_authn_method": null
+ }
+ },
+ "registration": {
+ "path": "registration",
+ "class": "oidcop.oidc.registration.Registration",
+ "kwargs": {
+ "client_authn_method": None,
+ "client_secret_expiration_time": 432000,
+ "client_id_generator": {
+ "class": 'oidcop.oidc.registration.random_client_id',
+ "kwargs": {
+ "seed": "that-optional-random-value"
+ }
+ }
+ }
+ },
+ "registration_api": {
+ "path": "registration_api",
+ "class": "oidcop.oidc.read_registration.RegistrationRead",
+ "kwargs": {
+ "client_authn_method": [
+ "bearer_header"
+ ]
+ }
+ },
+ "introspection": {
+ "path": "introspection",
+ "class": "oidcop.oauth2.introspection.Introspection",
+ "kwargs": {
+ "client_authn_method": [
+ "client_secret_post",
+ "client_secret_basic",
+ "client_secret_jwt",
+ "private_key_jwt"
+ ]
+ "release": [
+ "username"
+ ]
+ }
+ },
+ "authorization": {
+ "path": "authorization",
+ "class": "oidcop.oidc.authorization.Authorization",
+ "kwargs": {
+ "client_authn_method": null,
+ "claims_parameter_supported": true,
+ "request_parameter_supported": true,
+ "request_uri_parameter_supported": true,
+ "response_types_supported": [
+ "code",
+ "token",
+ "id_token",
+ "code token",
+ "code id_token",
+ "id_token token",
+ "code id_token token",
+ "none"
+ ],
+ "response_modes_supported": [
+ "query",
+ "fragment",
+ "form_post"
+ ]
+ }
+ },
+ "token": {
+ "path": "token",
+ "class": "oidcop.oidc.token.Token",
+ "kwargs": {
+ "client_authn_method": [
+ "client_secret_post",
+ "client_secret_basic",
+ "client_secret_jwt",
+ "private_key_jwt"
+ ]
+ }
+ },
+ "userinfo": {
+ "path": "userinfo",
+ "class": "oidcop.oidc.userinfo.UserInfo",
+ "kwargs": {
+ "claim_types_supported": [
+ "normal",
+ "aggregated",
+ "distributed"
+ ]
+ }
+ },
+ "end_session": {
+ "path": "session",
+ "class": "oidcop.oidc.session.Session",
+ "kwargs": {
+ "logout_verify_url": "verify_logout",
+ "post_logout_uri_path": "post_logout",
+ "signing_alg": "ES256",
+ "frontchannel_logout_supported": true,
+ "frontchannel_logout_session_supported": true,
+ "backchannel_logout_supported": true,
+ "backchannel_logout_session_supported": true,
+ "check_session_iframe": "check_session_iframe"
+ }
+ }
+ }
+
+You can specify which algoritms are supported, for example in userinfo_endpoint::
+
+ "userinfo_signing_alg_values_supported": OIDC_SIGN_ALGS,
+ "userinfo_encryption_alg_values_supported": OIDC_ENC_ALGS,
+
+Or in authorization endpoint::
+
+ "request_object_encryption_alg_values_supported": OIDC_ENC_ALGS,
+
+------------
+httpc_params
+------------
+
+Parameters submitted to the web client (python requests).
+In this case the TLS certificate will not be verified, to be intended exclusively for development purposes
+
+Example ::
+
+ "httpc_params": {
+ "verify": false
+ },
+
+----
+keys
+----
+
+An example::
+
+ "keys": {
+ "private_path": "private/jwks.json",
+ "key_defs": [
+ {
+ "type": "RSA",
+ "use": [
+ "sig"
+ ]
+ },
+ {
+ "type": "EC",
+ "crv": "P-256",
+ "use": [
+ "sig"
+ ]
+ }
+ ],
+ "public_path": "static/jwks.json",
+ "read_only": false,
+ "uri_path": "static/jwks.json"
+ },
+
+*read_only* means that on each restart the keys will created and overwritten with new ones.
+This can be useful during the first time the project have been executed, then to keep them as they are *read_only* would be configured to *True*.
+
+---------------
+login_hint2acrs
+---------------
+
+OIDC Login hint support, it's optional.
+It matches the login_hint paramenter to one or more Authentication Contexts.
+
+An example::
+
+ "login_hint2acrs": {
+ "class": "oidcop.login_hint.LoginHint2Acrs",
+ "kwargs": {
+ "scheme_map": {
+ "email": [
+ "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword"
+ ]
+ }
+ }
+ },
+
+oidc-op supports the following authn contexts:
+
+- UNSPECIFIED, urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified
+- INTERNETPROTOCOLPASSWORD, urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword
+- MOBILETWOFACTORCONTRACT, urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract
+- PASSWORDPROTECTEDTRANSPORT, urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
+- PASSWORD, urn:oasis:names:tc:SAML:2.0:ac:classes:Password
+- TLSCLIENT, urn:oasis:names:tc:SAML:2.0:ac:classes:TLSClient
+- TIMESYNCTOKEN, urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken
+
+
+-----
+authz
+-----
+
+This configuration section refers to the authorization/authentication endpoint behaviour.
+Scopes bound to an access token are strictly related to grant management, as part of what that endpoint does.
+Regarding grant authorization we should have something like the following example.
+
+If you omit this section from the configuration (thus using some sort of default profile)
+you'll have an Implicit grant authorization that leads granting nothing.
+Add the below to your configuration and you'll see things changing.
+
+
+An example::
+
+ "authz": {
+ "class": "oidcop.authz.AuthzHandling",
+ "kwargs": {
+ "grant_config": {
+ "usage_rules": {
+ "authorization_code": {
+ "supports_minting": ["access_token", "refresh_token", "id_token"],
+ "max_usage": 1
+ },
+ "access_token": {},
+ "refresh_token": {
+ "supports_minting": ["access_token", "refresh_token"]
+ }
+ },
+ "expires_in": 43200
+ }
+ }
+ },
+
+------------
+template_dir
+------------
+
+The HTML Template directory used by Jinja2, used by endpoint context
+ template loader, as::
+
+ Environment(loader=FileSystemLoader(template_dir), autoescape=True)
+
+An example::
+
+ "template_dir": "templates"
+
+For any further customization of template here an example of what used in django-oidc-op
+
+ "authentication": {
+ "user": {
+ "acr": "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword",
+ "class": "oidc_provider.users.UserPassDjango",
+ "kwargs": {
+ "verify_endpoint": "verify/oidc_user_login/",
+ "template": "oidc_login.html",
+
+ "page_header": "Testing log in",
+ "submit_btn": "Get me in!",
+ "user_label": "Nickname",
+ "passwd_label": "Secret sauce"
+ }
+ }
+ },
+
+------------------
+token_handler_args
+------------------
+
+Token handler is an intermediate interface used by and endpoint to manage
+ the tokens' default behaviour, like lifetime and minting policies.
+ With it we can create a token that's linked to another, and keep relations between many tokens
+ in session and grants management.
+
+An example::
+
+ "token_handler_args": {
+ "jwks_def": {
+ "private_path": "private/token_jwks.json",
+ "read_only": false,
+ "key_defs": [
+ {
+ "type": "oct",
+ "bytes": 24,
+ "use": [
+ "enc"
+ ],
+ "kid": "code"
+ },
+ {
+ "type": "oct",
+ "bytes": 24,
+ "use": [
+ "enc"
+ ],
+ "kid": "refresh"
+ }
+ ]
+ },
+ "code": {
+ "kwargs": {
+ "lifetime": 600
+ }
+ },
+ "token": {
+ "class": "oidcop.token.jwt_token.JWTToken",
+ "kwargs": {
+ "lifetime": 3600,
+ "add_claims": [
+ "email",
+ "email_verified",
+ "phone_number",
+ "phone_number_verified"
+ ],
+ "add_claim_by_scope": true,
+ "aud": ["https://example.org/appl"]
+ }
+ },
+ "refresh": {
+ "kwargs": {
+ "lifetime": 86400
+ }
+ }
+ "id_token": {
+ "class": "oidcop.token.id_token.IDToken",
+ "kwargs": {
+ "base_claims": {
+ "email": None,
+ "email_verified": None,
+ },
+ }
+ }
+
+jwks_defs can be replaced eventually by `jwks_file`::
+
+ "jwks_file": f"{OIDC_JWKS_PRIVATE_PATH}/token_jwks.json",
+
+You can even select wich algorithms to support in id_token, eg::
+
+ "id_token": {
+ "class": "oidcop.token.id_token.IDToken",
+ "kwargs": {
+ "id_token_signing_alg_values_supported": [
+ "RS256",
+ "RS512",
+ "ES256",
+ "ES512",
+ "PS256",
+ "PS512",
+ ],
+ "id_token_encryption_alg_values_supported": [
+ "RSA-OAEP",
+ "RSA-OAEP-256",
+ "A192KW",
+ "A256KW",
+ "ECDH-ES",
+ "ECDH-ES+A128KW",
+ "ECDH-ES+A192KW",
+ "ECDH-ES+A256KW",
+ ],
+ "id_token_encryption_enc_values_supported": [
+ 'A128CBC-HS256',
+ 'A192CBC-HS384',
+ 'A256CBC-HS512',
+ 'A128GCM',
+ 'A192GCM',
+ 'A256GCM'
+ ],
+ }
+ }
+
+--------
+userinfo
+--------
+
+An example::
+
+ "userinfo": {
+ "class": "oidcop.user_info.UserInfo",
+ "kwargs": {
+ "db_file": "users.json"
+ }
+ }
+
+This is somethig that can be customized.
+For example in the django-oidc-op implementation is used something like
+the following::
+
+ "userinfo": {
+ "class": "oidc_provider.users.UserInfo",
+ "kwargs": {
+ "claims_map": {
+ "phone_number": "telephone",
+ "family_name": "last_name",
+ "given_name": "first_name",
+ "email": "email",
+ "verified_email": "email",
+ "gender": "gender",
+ "birthdate": "get_oidc_birthdate",
+ "updated_at": "get_oidc_lastlogin"
+ }
+ }
+ }
diff --git a/docs/source/contents/developers.md b/docs/source/contents/developers.md
new file mode 100644
index 00000000..cac13634
--- /dev/null
+++ b/docs/source/contents/developers.md
@@ -0,0 +1,7 @@
+Tests
+-----
+
+````
+pip install -r requirements-dev.txt
+pytest --cov=oidcop tests/
+````
diff --git a/docs/source/contents/faq.md b/docs/source/contents/faq.md
new file mode 100644
index 00000000..c38a5f5d
--- /dev/null
+++ b/docs/source/contents/faq.md
@@ -0,0 +1,4 @@
+FAQ
+-----
+
+*
diff --git a/docs/source/contents/intro.rst b/docs/source/contents/intro.rst
new file mode 100644
index 00000000..5486e076
--- /dev/null
+++ b/docs/source/contents/intro.rst
@@ -0,0 +1,116 @@
+***************************
+The OpenID Connect Provider
+***************************
+
+============
+Introduction
+============
+
+This documentation are here to show you how to 'build' an OP using the
+classes and functions provided by oidcop.
+
+OAuth2 and thereby OpenID Connect (OIDC) are built on a request-response paradigm.
+The RP issues a request and the OP returns a response.
+
+The OIDC core standard defines a set of such request-responses.
+This is a basic list of request-responses and the normal sequence in which they
+occur:
+
+1. Provider discovery (WebFinger)
+2. Provider Info Discovery
+3. Client registration
+4. Authorization/Authentication
+5. Access token
+6. User info
+
+If you are just going to build a standard OP you only have to write the
+configuration file and of course add authentication and user consent services.
+If you want to add or replace functionality this document should be able to
+tell you how.
+
+Setting up an OP means making a number if decisions. Like, should the OP support
+WebFinger_ , `dynamic discovery`_ and/or `dynamic client registration`_ .
+
+All these are services you can access at endpoints. The total set of endpoints
+that this package supports are
+
+- webfinger
+- provider_info
+- registration
+- authorization
+- token
+- refresh_token
+- userinfo
+- end_session
+
+.. _WebFinger: https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery
+.. _dynamic discovery: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
+.. _dynamic client registration: https://openid.net/specs/openid-connect-registration-1_0.html
+
+===============
+Endpoint layout
+===============
+
+When an endpoint receives a request it has to do a number of things:
+
+- Verify that the client can issue the request (client authentication/authorization)
+- Verify that the request is correct and that it contains the necessary information.
+- Process the request, which includes applying server policies and gathering information.
+- Construct the response
+
+I should note at this point that this package is expected to work within the
+confines of a web server framework such that the actual receiving and sending
+of the HTTP messages are dealt with by the framework.
+
+Based on the actions an endpoint has to perform a method call structure
+has been constructed. It looks like this:
+
+1. parse_request
+
+ - client_authentication (*)
+ - post_parse_request (*)
+
+2. process_request
+
+3. do_response
+
+ - response_info
+ - construct
+ - pre_construct (*)
+ - _parse_args
+ - post_construct (*)
+ - update_http_args
+
+Steps marked with '*' are places where extensions can be applied.
+
+*parse_request* expects as input the request itself in a number of formats and
+also, if available, information about client authentication. The later is
+normally the authorization element of the HTTP header.
+
+*do_response* returns a dictionary that can look like this::
+
+ {
+ 'response':
+ _response as a string or as a Message instance_
+ 'http_headers': [
+ ('Content-type', 'application/json'),
+ ('Pragma', 'no-cache'),
+ ('Cache-Control', 'no-store')
+ ],
+ 'cookie': _list of cookies_,
+ 'response_placement': 'body'
+ }
+
+cookie
+ MAY be present
+http_headers
+ MAY be present
+http_response
+ Already clear and formatted HTTP response
+response
+ MUST be present
+response_placement
+ If absent defaults to the endpoints response_placement parameter value or
+ if that is also missing 'url'
+redirect_location
+ Where to send a redirect
diff --git a/docs/source/contents/session_management.rst b/docs/source/contents/session_management.rst
new file mode 100644
index 00000000..26e99371
--- /dev/null
+++ b/docs/source/contents/session_management.rst
@@ -0,0 +1,557 @@
+Session Management
+==================
+
+- `About session management`_
+ - `Design criteria`_
+ - `Database layout`_
+- `The information structure`_
+ - `Session key`_
+ - `User session information`_
+ - `Client session information`_
+ - `Grant information`_
+ - `Token`_
+- `Session Info API`_
+- `Grant API`_
+- `Token API`_
+
+- `Session Manager API`_
+ - `create_session`_
+ - `add_grant`_
+ - `find_token`_
+ - `get_authentication_event`_
+ - `get_client_session_info`_
+ - `get_grant_by_response_type`_
+ - `get_session_info`_
+ - `get_session_info_by_token`_
+ - `get_sids_by_user_id`_
+ - `get_user_info`_
+ - `grants`_
+ - `revoke_client_session`_
+ - `revoke_grant`_
+ - `revoke_token`_
+
+
+About session management
+------------------------
+.. _`About session management`:
+
+The OIDC Session Management draft defines session to be:
+
+ Continuous period of time during which an End-User accesses a Relying
+ Party relying on the Authentication of the End-User performed by the
+ OpenID Provider.
+
+Note that we are dealing with a Single Sign On (SSO) context here.
+If for some reason the OP does not want to support SSO then the
+session management has to be done a bit differently. In that case each
+session (user_id,client_id) would have its own authentication event. Not one
+shared between the sessions.
+
+Design criteria
++++++++++++++++
+.. _`Design criteria`:
+
+So a session is defined by a user and a Relying Party. If one adds to that
+that a user can have several sessions active at the same time each one against
+a unique Relying Party we have the bases for session management.
+
+Furthermore the user may well decide on different rules for different
+relying parties for releasing user
+attributes, where and how issued access tokens could be used and whether
+refresh tokens should be issued or not.
+
+We also need to keep track on which tokens where used to mint new tokens
+such that we can easily revoked a suite of tokens all with a common ancestor.
+
+Database layout
++++++++++++++++
+.. _`Database layout`:
+
+The database is organized in 3 levels. The top one being the users.
+Below that the Relying Parties and at the bottom what is called grants.
+
+Grants organize authorization codes, access tokens and refresh tokens (and
+possibly other types of tokens) in a comprehensive way. More about that below.
+
+There may be many Relying Parties below a user and many grants below a
+Relying Party.
+
+The information structure
+-------------------------
+.. _`The information structure`:
+
+As stated above there are 3 layers: user session information, client session
+information and grants. But first the keys to the information.
+
+Session key
++++++++++++
+.. _`Session key`:
+
+A key to the session information is based on a list. The first item being the
+user identifier, the second the client identifier and the third the grant
+identifier.
+If you only want the user session information then the key is a list with one
+item the user id. If you want the client session information the key is a
+list with 2 items (user_id, client_id). And lastly if you want a grant then
+the key is a list with 3 elements (user_id, client_id, grant_id).
+
+Example::
+ "diana;;KtEST70jZx1x;;85544c9cace411ebab53559c5425fcc0"
+
+A *session identifier* is constructed using the **session_key** function.
+It takes as input the 3 elements list.::
+
+ session_id = session_key(user_id, client_id, grant_id)
+
+
+Using the function **unpack_session_key** you can get the elements from a
+session_id.::
+
+ user_id, client_id, grant_id = unpack_session_id(session_id)
+
+
+User session information
+++++++++++++++++++++++++
+.. _`User session information`:
+
+Houses the authentication event information which is the same for all session
+connected to a user.
+Here we also have a list of all the clients that this user has a session with.
+Expressed as a dictionary this can look like this::
+
+ {
+ 'authentication_event': {
+ 'uid': 'diana',
+ 'authn_info': "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword",
+ 'authn_time': 1605515787,
+ 'valid_until': 1605519387
+ },
+ 'subordinate': ['client_1']
+ }
+
+
+Client session information
+++++++++++++++++++++++++++
+.. _`Client session information`:
+
+The client specific information of the session information.
+Presently only the authorization request and the subject identifier (sub).
+The subordinates to this set of information are the grants::
+
+ {
+ 'authorization_request':{
+ 'client_id': 'client_1',
+ 'redirect_uri': 'https://example.com/cb',
+ 'scope': ['openid', 'research_and_scholarship'],
+ 'state': 'STATE',
+ 'response_type': ['code']
+ },
+ 'sub': '117afe8d7bb0ace8e7fb2706034ab2d3fbf17f0fd4c949aa9c23aedd051cc9e3',
+ 'subordinate': ['e996c61227e711eba173acde48001122'],
+ 'revoked': False
+ }
+
+Grant information
++++++++++++++++++
+.. _`Grant information`:
+
+Grants are created by an authorization subsystem in an OP. If the grant is
+created in connection with an user authentication the authorization system
+might normally ask the user for usage consent and then base the construction
+of the grant on that consent.
+
+If an authorization server can act as a Security Token Service (STS) as
+defined by `Token Exchange [RFC-8693] `_
+then no user is involved. In the context of session management the STS is
+equivalent to a user.
+
+Grant information contains information about user consent and issued tokens.::
+
+ {
+ "type": "grant",
+ "scope": ["openid", "research_and_scholarship"],
+ "authorization_details": null,
+ "claims": {
+ "userinfo": {
+ "sub": null,
+ "name": null,
+ "given_name": null,
+ "family_name": null,
+ "email": null,
+ "email_verified": null,
+ "eduperson_scoped_affiliation": null
+ }
+ },
+ "resources": ["client_1"],
+ "issued_at": 1605452123,
+ "not_before": 0,
+ "expires_at": 0,
+ "revoked": false,
+ "issued_token": [
+ {
+ "type": "authorization_code",
+ "issued_at": 1605452123,
+ "not_before": 0,
+ "expires_at": 1605452423,
+ "revoked": false,
+ "value": "Z0FBQUFBQmZzVUZieDFWZy1fbjE2ckxvZkFTVC1ZTHJIVlk0Z09tOVk1M0RsOVNDbkdfLTIxTUhILWs4T29kM1lmV015UEN1UGxrWkxLTkVXOEg1WVJLNjh3MGlhMVdSRWhYcUY4cGdBQkJEbzJUWUQ3UGxTUWlJVDNFUHFlb29PWUFKcjNXeHdRM1hDYzRIZnFrYjhVZnIyTFhvZ2Y0NUhjR1VBdzE0STVEWmJ3WkttTk1OYXQtTHNtdHJwYk1nWnl3MUJqSkdWZGFtdVNfY21VNXQxY3VzalpIczBWbGFueVk0TVZ2N2d2d0hVWTF4WG56TDJ6bz0=",
+ "usage_rules": {
+ "expires_in": 300,
+ "supports_minting": [
+ "access_token",
+ "refresh_token",
+ "id_token"
+ ],
+ "max_usage": 1
+ },
+ "used": 0,
+ "based_on": null,
+ "id": "96d19bea275211eba43bacde48001122"
+ },
+ {
+ "type": "access_token",
+ "issued_at": 1605452123,
+ "not_before": 0,
+ "expires_at": 1605452723,
+ "revoked": false,
+ "value": "Z0FBQUFBQmZzVUZiaWVRbi1IS2k0VW4wVDY1ZmJHeEVCR1hVODBaQXR6MWkzelNBRFpOS2tRM3p4WWY5Y1J6dk5IWWpnelRETGVpSG52b0d4RGhjOWphdWp4eW5xZEJwQzliaS16cXFCcmRFbVJqUldsR1Z3SHdTVVlWbkpHak54TmJaSTV2T3NEQ0Y1WFkxQkFyamZHbmd4V0RHQ3k1MVczYlYwakEyM010SGoyZk9tUVVxbWdYUzBvMmRRNVlZMUhRSnM4WFd2QzRkVmtWNVJ1aVdJSXQyWnpVTlRiZnMtcVhKTklGdzBzdDJ3RkRnc1A1UEw2Yz0=",
+ "usage_rules": {
+ "expires_in": 600,
+ },
+ "used": 0,
+ "based_on": "Z0FBQUFBQmZzVUZieDFWZy1fbjE2ckxvZkFTVC1ZTHJIVlk0Z09tOVk1M0RsOVNDbkdfLTIxTUhILWs4T29kM1lmV015UEN1UGxrWkxLTkVXOEg1WVJLNjh3MGlhMVdSRWhYcUY4cGdBQkJEbzJUWUQ3UGxTUWlJVDNFUHFlb29PWUFKcjNXeHdRM1hDYzRIZnFrYjhVZnIyTFhvZ2Y0NUhjR1VBdzE0STVEWmJ3WkttTk1OYXQtTHNtdHJwYk1nWnl3MUJqSkdWZGFtdVNfY21VNXQxY3VzalpIczBWbGFueVk0TVZ2N2d2d0hVWTF4WG56TDJ6bz0=",
+ "id": "96d1c840275211eba43bacde48001122"
+ }
+ ],
+ "id": "96d16d3c275211eba43bacde48001122"
+ }
+
+The parameters are described below
+
+scope
+:::::
+
+This is the scope that was chosen for this grant. Either by the user or by
+some rules that the Authorization Server runs by.
+
+authorization_details
+:::::::::::::::::::::
+
+Presently a place hold. But this is expected to be information on how the
+authorization was performed. What input was used and so on.
+
+claims
+::::::
+
+The set of claims that should be returned in different circumstances. The
+syntax that is defined in
+https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter
+is used. With one addition, beside *userinfo* and *id_token* we have added
+*introspection*.
+
+resources
+:::::::::
+
+This are the resource servers and other entities that should be accepted
+as users of issued access tokens.
+
+issued_at
+:::::::::
+
+When the grant was created. Its value is a JSON number representing the number
+of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time.
+
+not_before
+::::::::::
+If the usage of the grant should be delay, this is when it can start being used.
+Its value is a JSON number representing the number
+of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time.
+
+expires_at
+::::::::::
+When the grant expires.
+Its value is a JSON number representing the number
+of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time.
+
+revoked
+:::::::
+If the grant has been revoked.
+
+issued_token
+::::::::::::
+Tokens that has been issued based on this grant. There is no limitation
+as to which tokens can be issued. Though presently we only have:
+
+- authorization_code,
+- access_token and
+- refresh_token
+
+id
+::
+The grant identifier.
+
+Token
++++++
+.. _`Token`:
+
+As mention above there are presently only 3 token types that are defined:
+
+- authorization_code,
+- access_token and
+- refresh_token
+
+A token is described as follows::
+
+ {
+ "type": "authorization_code",
+ "issued_at": 1605452123,
+ "not_before": 0,
+ "expires_at": 1605452423,
+ "revoked": false,
+ "value": "Z0FBQUFBQmZzVUZieDFWZy1fbjE2ckxvZkFTVC1ZTHJIVlk0Z09tOVk1M0RsOVNDbkdfLTIxTUhILWs4T29kM1lmV015UEN1UGxrWkxLTkVXOEg1WVJLNjh3MGlhMVdSRWhYcUY4cGdBQkJEbzJUWUQ3UGxTUWlJVDNFUHFlb29PWUFKcjNXeHdRM1hDYzRIZnFrYjhVZnIyTFhvZ2Y0NUhjR1VBdzE0STVEWmJ3WkttTk1OYXQtTHNtdHJwYk1nWnl3MUJqSkdWZGFtdVNfY21VNXQxY3VzalpIczBWbGFueVk0TVZ2N2d2d0hVWTF4WG56TDJ6bz0=",
+ "usage_rules": {
+ "expires_in": 300,
+ "supports_minting": [
+ "access_token",
+ "refresh_token",
+ "id_token"
+ ],
+ "max_usage": 1
+ },
+ "used": 0,
+ "based_on": null,
+ "id": "96d19bea275211eba43bacde48001122"
+ }
+
+
+type
+::::
+The type of token.
+
+issued_at
+:::::::::
+When the token was created. Its value is a JSON number representing the number
+of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time.
+
+not_before
+::::::::::
+If the start of the usage of the token is to be delay, this is until when.
+Its value is a JSON number representing the number
+of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time.
+
+expires_at
+::::::::::
+When the token expires.
+Its value is a JSON number representing the number
+of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time.
+
+revoked
+:::::::
+If the token has been revoked.
+
+value
+:::::
+This is the value that appears in OIDC protocol exchanges.
+
+usage_rules
+:::::::::::
+Rules as to how this token can be used:
+
+expires_in
+ Used to calculate expires_at
+
+supports_minting
+ The tokens types that can be minted based on this token. Typically a code
+ can be used to mint ID tokens and access and refresh tokens.
+
+max_usage
+ How many times this token can be used (being used is presently defined as
+ used to mint other tokens). An authorization_code token can according to
+ the OIDC standard only be used once but then to, in the same session,
+ mint more then one token.
+
+used
+::::
+ How many times the token has been used
+
+based_on
+::::::::
+ Reference to the token that was used to mint this token. Might be empty if the
+ token was minted based on the grant it belongs to.
+
+id
+::
+ Token identifier
+
+Session Info API
+----------------
+.. _`Session Info API`:
+
+add_subordinate
++++++++++++++++
+.. _`add_subordinate`:
+
+ ...
+
+remove_subordinate
+++++++++++++++++++
+.. _`removed_subordinate`:
+
+ ...
+
+revoke
+++++++
+.. _`revoke`:
+
+ ...
+
+is_revoked
+++++++++++
+.. _`is_revoked`:
+
+ ...
+
+to_json
++++++++
+.. _`to_json`:
+
+ ...
+
+from_json
++++++++++
+.. _`from_json`:
+
+ ...
+
+Grant API
+---------
+.. _`Grant API`:
+
+ ...
+
+Token API
+---------
+.. _`Token API`:
+
+ ...
+
+Session Manager API
+-------------------
+.. _`Session Manager API`:
+
+ ...
+
+create_session
+++++++++++++++
+.. _create_session:
+
+Creating a new session is done by running the create_session method of
+the class SessionManager. The create_session methods takes the following
+arguments.
+
+authn_event
+ An AuthnEvent class instance that describes the authentication event.
+
+auth_req
+ The Authentication request
+
+client_id
+ The client Identifier
+
+user_id
+ The user identifier
+
+sector_identifier
+ A possible sector identifier to be used when constructing a pairwise
+ subject identifier
+
+sub_type
+ The type of subject identifier that should be constructed. It can either be
+ *pairwise* or *public*.
+
+So a typical command would look like this::
+
+
+ authn_event = create_authn_event(self.user_id)
+ session_manager.create_session(authn_event=authn_event, auth_req=auth_req,
+ user_id=self.user_id, client_id=client_id,
+ sub_type=sub_type, sector_identifier=sector_identifier)
+
+add_grant
++++++++++
+.. _add_grant:
+
+ add_grant(self, user_id, client_id, **kwargs)
+
+find_token
+++++++++++
+.. _find_token:
+
+ find_token(self, session_id, token_value)
+
+get_authentication_event
+++++++++++++++++++++++++
+.. _get_authentication_event:
+
+ get_authentication_event(self, session_id)
+
+
+get_client_session_info
++++++++++++++++++++++++
+.. _get_client_session_info:
+
+ get_client_session_info(self, session_id)
+
+get_grant_by_response_type
+++++++++++++++++++++++++++
+.. _get_grant_by_response_type:
+
+ get_grant_by_response_type(self, user_id, client_id)
+
+get_session_info
+++++++++++++++++
+.. _get_session_info:
+
+ get_session_info(self, session_id)
+
+get_session_info_by_token
++++++++++++++++++++++++++
+.. _get_session_info_by_token:
+
+ get_session_info_by_token(self, token_value)
+
+get_sids_by_user_id
++++++++++++++++++++
+.. _get_sids_by_user_id:
+
+ get_sids_by_user_id(self, user_id)
+
+get_user_info
++++++++++++++
+.. _get_user_info:
+
+ get_user_info(self, uid)
+
+grants
+++++++
+.. _grants:
+
+ grants(self, session_id)
+
+revoke_client_session
++++++++++++++++++++++
+.. _revoke_client_session:
+
+ revoke_client_session(self, session_id)
+
+revoke_grant
+++++++++++++
+.. _revoke_grant:
+
+ revoke_grant(self, session_id)
+
+revoke_token
+++++++++++++
+.. _revoke_token:
+
+ revoke_token(self, session_id, token_value, recursive=False)
diff --git a/docs/source/contents/setup.md b/docs/source/contents/setup.md
new file mode 100644
index 00000000..ad78606a
--- /dev/null
+++ b/docs/source/contents/setup.md
@@ -0,0 +1,100 @@
+Setup
+-----
+
+Create an environment
+
+ virtualenv -ppython3 env
+ source env/bin/activate
+
+Install
+
+ pip install oidcop
+
+Get the usage examples
+
+ git clone https://github.com/identitypython/oidc-op.git
+ cd oidc-op/example/flask_op/
+ bash run.sh
+
+
+To configure a standard OIDC Provider you have to edit the oidcop configuration file.
+See `example/flask_op/config.json` to get in.
+
+ ~/DEV/IdentityPython/OIDC/oidc-op/example/flask_op$ bash run.sh
+ 2021-05-02 14:57:44,727 root DEBUG Configured logging using dictionary
+ 2021-05-02 14:57:44,728 oidcop.configure DEBUG Set server password to {'kty': 'oct', 'use': 'sig', 'k': 'n4G9OjOixYMOotXvP15grwq0peN2zq9I'}
+ * Serving Flask app "oidc_op" (lazy loading)
+ * Environment: production
+ WARNING: This is a development server. Do not use it in a production deployment.
+ Use a production WSGI server instead.
+ * Debug mode: on
+ 2021-05-02 14:57:44,764 werkzeug INFO * Running on https://127.0.0.1:5000/ (Press CTRL+C to quit)
+ 2021-05-02 14:57:44,765 werkzeug INFO * Restarting with stat
+ 2021-05-02 14:57:45,011 root DEBUG Configured logging using dictionary
+ 2021-05-02 14:57:45,011 oidcop.configure DEBUG Set server password to {'kty': 'oct', 'use': 'sig', 'k': 'bceYal7bK9zvlBAA7-23lsi5crcv_8Cd'}
+ 2021-05-02 14:57:45,037 werkzeug WARNING * Debugger is active!
+ 2021-05-02 14:57:45,092 werkzeug INFO * Debugger PIN: 560-973-597
+
+
+Then open your browser to `https://127.0.0.1:5000/.well-known/openid-configuration` to get the OpenID Provider Configuration resource.
+
+
+--------------------
+JWK Set (JWKS) files
+--------------------
+see: [cryptojwt documentation](https://cryptojwt.readthedocs.io/en/latest/keyhandling.html private/cookie_sign_jwk.json
diff --git a/docs/source/contents/usage.md b/docs/source/contents/usage.md
new file mode 100644
index 00000000..6c51e765
--- /dev/null
+++ b/docs/source/contents/usage.md
@@ -0,0 +1,92 @@
+Usage
+-----
+
+Some examples, how to run flask_op and django_op, but also some typical configuration in relation to common use cases.
+
+
+
+Configure flask-rp
+------------------
+
+_JWTConnect-Python-OidcRP_ is Relaing Party for tests, see [related page](https://github.com/openid/JWTConnect-Python-OidcRP).
+You can run a working instance of `JWTConnect-Python-OidcRP.flask_rp` with:
+
+````
+pip install git+https://github.com/openid/JWTConnect-Python-OidcRP.git
+
+# get entire project to have examples files
+git clone https://github.com/openid/JWTConnect-Python-OidcRP.git
+cd JWTConnect-Python-OidcRP/example/flask_rp
+
+# run it as it come
+bash run.sh
+````
+
+Now you can connect to `https://127.0.0.1:8090/` to see the RP landing page and select your authentication endpoint.
+
+### Authentication examples
+
+
+
+Get to the RP landing page to choose your authentication endpoint. The first option aims to use _Provider Discovery_.
+
+----------------------------------
+
+
+
+AS/OP accepted our authentication request and prompt to us the login form. Read passwd.json file to get credentials.
+
+----------------------------------
+
+
+
+The identity representation with the information fetched from the user info endpoint.
+
+----------------------------------
+
+
+
+We can even test the single logout
+
+
+Introspection endpoint
+----------------------
+
+Here an example about how to consume oidc-op introspection endpoint.
+This example uses a client with an HTTP Basic Authentication::
+
+ import base64
+ import requests
+
+ TOKEN = "eyJhbGciOiJFUzI1NiIsImtpZCI6IlQwZGZTM1ZVYUcxS1ZubG9VVTQwUXpJMlMyMHpjSHBRYlMxdGIzZ3hZVWhCYzNGaFZWTlpTbWhMTUEifQ.eyJzY29wZSI6IFsib3BlbmlkIiwgInByb2ZpbGUiLCAiZW1haWwiLCAiYWRkcmVzcyIsICJwaG9uZSJdLCAiYXVkIjogWyJvTHlSajdzSkozWHZBWWplRENlOHJRIl0sICJqdGkiOiAiOWQzMjkzYjZiYmNjMTFlYmEzMmU5ODU0MWIwNzE1ZWQiLCAiY2xpZW50X2lkIjogIm9MeVJqN3NKSjNYdkFZamVEQ2U4clEiLCAic3ViIjogIm9MeVJqN3NKSjNYdkFZamVEQ2U4clEiLCAic2lkIjogIlowRkJRVUZCUW1keGJIVlpkRVJKYkZaUFkxQldaa0pQVUVGc1pHOUtWWFZ3VFdkZmVEY3diMVprYmpSamRrNXRMVzB4YTNnelExOHlRbHBHYTNRNVRHZEdUUzF1UW1sMlkzVnhjRE5sUm01dFRFSmxabGRXYVhJeFpFdHVSV2xtUzBKcExWTmFaRzV3VjJodU0yNXlSbTU0U1ZWVWRrWTRRM2x2UWs1TlpVUk9SazlGVlVsRWRteGhjWGx2UWxWRFdubG9WbTFvZGpORlVUSnBkaTFaUTFCcFptZFRabWRDVWt0YVNuaGtOalZCWVhkcGJFNXpaV2xOTTFCMk0yaE1jMDV0ZGxsUlRFc3dObWxsYUcxa1lrTkhkemhuU25OaWFWZE1kVUZzZDBwWFdWbzFiRWhEZFhGTFFXWTBPVzl5VjJOUk4zaGtPRDA9IiwgInR0eXBlIjogIlQiLCAiaXNzIjogImh0dHBzOi8vMTI3LjAuMC4xOjgwMDAiLCAiaWF0IjogMTYyMTc3NzMwNSwgImV4cCI6IDE2MjE3ODA5MDV9.pVqxUNznsoZu9ND18IEMJIHDOT6_HxzoFiTLsniNdbAdXTuOoiaKeRTqtDyjT9WuUPszdHkVjt5xxeFX8gQMuA"
+
+ data = {
+ 'token': TOKEN,
+ 'token_type_hint': 'access_token'
+ }
+
+ _basic_secret = base64.b64encode(
+ f'{"oLyRj7sJJ3XvAYjeDCe8rQ"}:{"53fb49f2a6501ec775355c89750dc416744a3253138d5a04e409b313"}'.encode()
+ )
+ headers = {
+ 'Authorization': f"Basic {_basic_secret.decode()}"
+ }
+
+ requests.post('https://127.0.0.1:8000/introspection', verify=False, data=data, headers=headers)
+
+
+oidc-op will return a json response like this::
+
+ {
+ "active": true,
+ "scope": "openid profile email address phone",
+ "client_id": "oLyRj7sJJ3XvAYjeDCe8rQ",
+ "token_type": "access_token",
+ "exp": 0,
+ "iat": 1621777305,
+ "sub": "a7b0dea2958aec275a789d7d7dc8e7d09c6316dd4fc6ae92742ed3297e14dded",
+ "iss": "https://127.0.0.1:8000",
+ "aud": [
+ "oLyRj7sJJ3XvAYjeDCe8rQ"
+ ]
+ }
diff --git a/docs/source/diagrams/session_relations.mermaid b/docs/source/diagrams/session_relations.mermaid
new file mode 100644
index 00000000..0673d9da
--- /dev/null
+++ b/docs/source/diagrams/session_relations.mermaid
@@ -0,0 +1,40 @@
+erDiagram
+ Relying-Party ||--|{ Grant : relying_party_id
+ Relying-Party {
+ String client_id
+ String client_salt
+ String registration_access_token
+ String registration_client_uri
+ String client_id_issued_at
+ String client_secret
+ datetime client_secret_expires_at
+ String application_type
+ List response_types
+ List contacts
+ String token_endpoint_auth_method
+ List response_types
+ List contacts
+ String token_endpoint_auth_method
+ List post_logout_redirect_uris
+ String jwks_uri
+ String frontchannel_logout_uri
+ String frontchannel_logout_session_required
+ String backchannel_logout_uri
+ String grant_types
+ List redirect_uris
+ }
+ User ||--o{ Grant : user_id
+ User {
+ string username
+ string firstName
+ string lastName
+ string email
+ }
+ Grant {
+ int user_id
+ int relying_party
+ String session_key
+ String token_type
+ JSon authentication_event
+ menthod1()
+ }
diff --git a/docs/source/index.rst b/docs/source/index.rst
new file mode 100644
index 00000000..99a12111
--- /dev/null
+++ b/docs/source/index.rst
@@ -0,0 +1,73 @@
+Welcome to Idpy OIDC-op Documentation
+======================================
+
+This project is a Python implementation of an **OIDC Provider** on top of `jwtconnect.io `_
+that shows you how to 'build' an OP using the classes and functions provided by oidc-op.
+
+If you are just going to build a standard OP you only have to write the configuration file. If you want to add or replace functionality this documentation
+should be able to tell you how.
+
+Idpy OIDC-op implements the following standards:
+
+* `OpenID Connect Core 1.0 incorporating errata set 1 `_
+* `Web Finger `_
+* `OpenID Connect Discovery 1.0 incorporating errata set 1 `_
+* `OpenID Connect Dynamic Client Registration 1.0 incorporating errata set 1 `_
+* `OpenID Connect Session Management 1.0 `_
+* `OpenID Connect Back-Channel Logout 1.0 `_
+* `OpenID Connect Front-Channel Logout 1.0 `_
+* `OAuth2 Token introspection `_
+
+
+It also comes with the following `add_on` modules.
+
+* Custom scopes, that extends `[OIDC standard ScopeClaims] `_
+* `Proof Key for Code Exchange by OAuth Public Clients (PKCE) `_
+* `OAuth2 RAR `_
+* `OAuth2 DPoP `_
+
+The entire project code is open sourced and therefore licensed
+under the `Apache 2.0 `_.
+
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Introduction
+
+ contents/intro.rst
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Setup
+
+ contents/setup.rst
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Configuration
+
+ contents/conf.rst
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Usage
+
+ contents/usage.md
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Session management
+
+ contents/session_management.rst
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Developer's
+
+ contents/developers.md
+
+.. toctree::
+ :maxdepth: 2
+ :caption: FAQ
+
+ contents/faq.md
diff --git a/example/django_op/README.md b/example/django_op/README.md
new file mode 100644
index 00000000..108e9bd2
--- /dev/null
+++ b/example/django_op/README.md
@@ -0,0 +1,3 @@
+# django-oidc-op
+
+The Django oidc-op implementation is available here [django-oidc-op github page](https://github.com/peppelinux/django-oidc-op/tree/develop).
diff --git a/flask_op/Dockerfile b/example/flask_op/Dockerfile
similarity index 100%
rename from flask_op/Dockerfile
rename to example/flask_op/Dockerfile
diff --git a/flask_op/README.md b/example/flask_op/README.md
similarity index 100%
rename from flask_op/README.md
rename to example/flask_op/README.md
diff --git a/flask_op/__init__.py b/example/flask_op/__init__.py
similarity index 100%
rename from flask_op/__init__.py
rename to example/flask_op/__init__.py
diff --git a/example/flask_op/application.py b/example/flask_op/application.py
new file mode 100644
index 00000000..9fa299cb
--- /dev/null
+++ b/example/flask_op/application.py
@@ -0,0 +1,42 @@
+import os
+from urllib.parse import urlparse
+
+from flask.app import Flask
+
+from oidcop.server import Server
+
+folder = os.path.dirname(os.path.realpath(__file__))
+
+
+def init_oidc_op(app):
+ _op_config = app.srv_config
+
+ server = Server(_op_config, cwd=folder)
+
+ for endp in server.endpoint.values():
+ p = urlparse(endp.endpoint_path)
+ _vpath = p.path.split('/')
+ if _vpath[0] == '':
+ endp.vpath = _vpath[1:]
+ else:
+ endp.vpath = _vpath
+
+ return server
+
+
+def oidc_provider_init_app(op_config, name=None, **kwargs):
+ name = name or __name__
+ app = Flask(name, static_url_path='', **kwargs)
+ app.srv_config = op_config
+
+ try:
+ from .views import oidc_op_views
+ except ImportError:
+ from views import oidc_op_views
+
+ app.register_blueprint(oidc_op_views)
+
+ # Initialize the oidc_provider after views to be able to set correct urls
+ app.server = init_oidc_op(app)
+
+ return app
diff --git a/chpy/certs/cert.pem b/example/flask_op/certs/cert.pem
similarity index 100%
rename from chpy/certs/cert.pem
rename to example/flask_op/certs/cert.pem
diff --git a/flask_op/certs/client.crt b/example/flask_op/certs/client.crt
similarity index 100%
rename from flask_op/certs/client.crt
rename to example/flask_op/certs/client.crt
diff --git a/flask_op/certs/client.key b/example/flask_op/certs/client.key
similarity index 100%
rename from flask_op/certs/client.key
rename to example/flask_op/certs/client.key
diff --git a/chpy/certs/key.pem b/example/flask_op/certs/key.pem
similarity index 100%
rename from chpy/certs/key.pem
rename to example/flask_op/certs/key.pem
diff --git a/example/flask_op/config.json b/example/flask_op/config.json
new file mode 100644
index 00000000..e5e42e55
--- /dev/null
+++ b/example/flask_op/config.json
@@ -0,0 +1,356 @@
+{
+ "logging": {
+ "version": 1,
+ "root": {
+ "handlers": [
+ "default",
+ "console"
+ ],
+ "level": "DEBUG"
+ },
+ "loggers": {
+ "bobcat_idp": {
+ "level": "DEBUG"
+ }
+ },
+ "handlers": {
+ "default": {
+ "class": "logging.FileHandler",
+ "filename": "debug.log",
+ "formatter": "default"
+ },
+ "console": {
+ "class": "logging.StreamHandler",
+ "stream": "ext://sys.stdout",
+ "formatter": "default"
+ }
+ },
+ "formatters": {
+ "default": {
+ "format": "%(asctime)s %(name)s %(levelname)s %(message)s"
+ }
+ }
+ },
+ "port": 5000,
+ "domain": "127.0.0.1",
+ "server_name": "{domain}:{port}",
+ "base_url": "https://{domain}:{port}",
+ "op": {
+ "server_info": {
+ "add_on": {
+ "pkce": {
+ "function": "oidcop.oidc.add_on.pkce.add_pkce_support",
+ "kwargs": {
+ "essential": false,
+ "code_challenge_method": "S256 S384 S512"
+ }
+ },
+ "claims": {
+ "function": "oidcop.oidc.add_on.custom_scopes.add_custom_scopes",
+ "kwargs": {
+ "research_and_scholarship": [
+ "name",
+ "given_name",
+ "family_name",
+ "email",
+ "email_verified",
+ "sub",
+ "iss",
+ "eduperson_scoped_affiliation"
+ ]
+ }
+ }
+ },
+ "authz": {
+ "class": "oidcop.authz.AuthzHandling",
+ "kwargs": {
+ "grant_config": {
+ "usage_rules": {
+ "authorization_code": {
+ "supports_minting": [
+ "access_token",
+ "refresh_token",
+ "id_token"
+ ],
+ "max_usage": 1
+ },
+ "access_token": {},
+ "refresh_token": {
+ "supports_minting": [
+ "access_token",
+ "refresh_token"
+ ]
+ }
+ },
+ "expires_in": 43200
+ }
+ }
+ },
+ "authentication": {
+ "user": {
+ "acr": "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword",
+ "class": "oidcop.user_authn.user.UserPassJinja2",
+ "kwargs": {
+ "verify_endpoint": "verify/user",
+ "template": "user_pass.jinja2",
+ "db": {
+ "class": "oidcop.util.JSONDictDB",
+ "kwargs": {
+ "filename": "passwd.json"
+ }
+ },
+ "page_header": "Testing log in",
+ "submit_btn": "Get me in!",
+ "user_label": "Nickname",
+ "passwd_label": "Secret sauce"
+ }
+ }
+ },
+ "capabilities": {
+ "subject_types_supported": [
+ "public",
+ "pairwise"
+ ],
+ "grant_types_supported": [
+ "authorization_code",
+ "implicit",
+ "urn:ietf:params:oauth:grant-type:jwt-bearer",
+ "refresh_token"
+ ]
+ },
+ "cookie_handler": {
+ "class": "oidcop.cookie_handler.CookieHandler",
+ "kwargs": {
+ "keys": {
+ "private_path": "private/cookie_jwks.json",
+ "key_defs": [
+ {
+ "type": "OCT",
+ "use": [
+ "enc"
+ ],
+ "kid": "enc"
+ },
+ {
+ "type": "OCT",
+ "use": [
+ "sig"
+ ],
+ "kid": "sig"
+ }
+ ],
+ "read_only": false
+ },
+ "name": {
+ "session": "oidc_op",
+ "register": "oidc_op_rp",
+ "session_management": "sman"
+ }
+ }
+ },
+ "endpoint": {
+ "webfinger": {
+ "path": ".well-known/webfinger",
+ "class": "oidcop.oidc.discovery.Discovery",
+ "kwargs": {
+ "client_authn_method": null
+ }
+ },
+ "provider_info": {
+ "path": ".well-known/openid-configuration",
+ "class": "oidcop.oidc.provider_config.ProviderConfiguration",
+ "kwargs": {
+ "client_authn_method": null
+ }
+ },
+ "registration": {
+ "path": "registration",
+ "class": "oidcop.oidc.registration.Registration",
+ "kwargs": {
+ "client_authn_method": null,
+ "client_secret_expiration_time": 432000
+ }
+ },
+ "registration_api": {
+ "path": "registration_api",
+ "class": "oidcop.oidc.read_registration.RegistrationRead",
+ "kwargs": {
+ "client_authn_method": [
+ "bearer_header"
+ ]
+ }
+ },
+ "introspection": {
+ "path": "introspection",
+ "class": "oidcop.oauth2.introspection.Introspection",
+ "kwargs": {
+ "client_authn_method": [
+ "client_secret_post",
+ "client_secret_basic",
+ "client_secret_jwt",
+ "private_key_jwt"
+ ],
+ "release": [
+ "username"
+ ]
+ }
+ },
+ "authorization": {
+ "path": "authorization",
+ "class": "oidcop.oidc.authorization.Authorization",
+ "kwargs": {
+ "client_authn_method": null,
+ "claims_parameter_supported": true,
+ "request_parameter_supported": true,
+ "request_uri_parameter_supported": true,
+ "response_types_supported": [
+ "code",
+ "token",
+ "id_token",
+ "code token",
+ "code id_token",
+ "id_token token",
+ "code id_token token",
+ "none"
+ ],
+ "response_modes_supported": [
+ "query",
+ "fragment",
+ "form_post"
+ ]
+ }
+ },
+ "token": {
+ "path": "token",
+ "class": "oidcop.oidc.token.Token",
+ "kwargs": {
+ "client_authn_method": [
+ "client_secret_post",
+ "client_secret_basic",
+ "client_secret_jwt",
+ "private_key_jwt"
+ ]
+ }
+ },
+ "userinfo": {
+ "path": "userinfo",
+ "class": "oidcop.oidc.userinfo.UserInfo",
+ "kwargs": {
+ "claim_types_supported": [
+ "normal",
+ "aggregated",
+ "distributed"
+ ]
+ }
+ },
+ "end_session": {
+ "path": "session",
+ "class": "oidcop.oidc.session.Session",
+ "kwargs": {
+ "logout_verify_url": "verify_logout",
+ "post_logout_uri_path": "post_logout",
+ "signing_alg": "ES256",
+ "frontchannel_logout_supported": true,
+ "frontchannel_logout_session_supported": true,
+ "backchannel_logout_supported": true,
+ "backchannel_logout_session_supported": true,
+ "check_session_iframe": "check_session_iframe"
+ }
+ }
+ },
+ "httpc_params": {
+ "verify": false
+ },
+ "issuer": "https://{domain}:{port}",
+ "keys": {
+ "private_path": "private/jwks.json",
+ "key_defs": [
+ {
+ "type": "RSA",
+ "use": [
+ "sig"
+ ]
+ },
+ {
+ "type": "EC",
+ "crv": "P-256",
+ "use": [
+ "sig"
+ ]
+ }
+ ],
+ "public_path": "static/jwks.json",
+ "read_only": false,
+ "uri_path": "static/jwks.json"
+ },
+ "login_hint2acrs": {
+ "class": "oidcop.login_hint.LoginHint2Acrs",
+ "kwargs": {
+ "scheme_map": {
+ "email": [
+ "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword"
+ ]
+ }
+ }
+ },
+ "template_dir": "templates",
+ "token_handler_args": {
+ "jwks_file": "private/token_jwks.json",
+ "code": {
+ "kwargs": {
+ "lifetime": 600
+ }
+ },
+ "token": {
+ "class": "oidcop.token.jwt_token.JWTToken",
+ "kwargs": {
+ "lifetime": 3600,
+ "add_claims": [
+ "email",
+ "email_verified",
+ "phone_number",
+ "phone_number_verified"
+ ],
+ "add_claim_by_scope": true,
+ "aud": [
+ "https://example.org/appl"
+ ]
+ }
+ },
+ "refresh": {
+ "kwargs": {
+ "lifetime": 86400
+ }
+ },
+ "id_token": {
+ "class": "oidcop.token.id_token.IDToken",
+ "kwargs": {
+ "base_claims": {
+ "email": {
+ "essential": true
+ },
+ "email_verified": {
+ "essential": true
+ }
+ }
+ }
+ }
+ },
+ "userinfo": {
+ "class": "oidcop.user_info.UserInfo",
+ "kwargs": {
+ "db_file": "users.json"
+ }
+ }
+ }
+ },
+ "webserver": {
+ "server_cert": "certs/client.crt",
+ "server_key": "certs/client.key",
+ "ca_bundle": null,
+ "verify_user": false,
+ "port": 5000,
+ "domain": "127.0.0.1",
+ "debug": true
+ }
+}
diff --git a/flask_op/conf_192.yaml b/example/flask_op/config.yaml
similarity index 67%
rename from flask_op/conf_192.yaml
rename to example/flask_op/config.yaml
index 64c8b0ac..00efd4b8 100644
--- a/flask_op/conf_192.yaml
+++ b/example/flask_op/config.yaml
@@ -37,23 +37,12 @@ key_def: &key_def
use:
- sig
-OIDC_KEYS: &oidc_keys
- 'private_path': "private/jwks.json"
- 'key_defs': *key_def
- 'public_path': 'static/jwks.json'
- 'read_only': False
- # otherwise OP metadata will have jwks_uri: https://127.0.0.1:5000/None!
- 'uri_path': 'static/jwks.json'
op:
server_info:
issuer: *base_url
- http_params:
- verify_ssl: False
- session_key:
- filename: private/session_jwk.json
- type: OCT
- use: sig
+ httpc_params:
+ verify: False
capabilities:
subject_types_supported:
- public
@@ -64,14 +53,6 @@ op:
- urn:ietf:params:oauth:grant-type:jwt-bearer
- refresh_token
template_dir: templates
- id_token:
- class: oidcendpoint.id_token.IDToken
- kwargs:
- default_claims:
- email:
- essential: True
- email_verified:
- essential: True
token_handler_args:
jwks_def:
private_path: 'private/token_jwks.json'
@@ -91,8 +72,16 @@ op:
kid: refresh
code:
lifetime: 600
+ id_token:
+ class: oidcop.token.id_token.IDToken
+ kwargs:
+ base_claims:
+ email:
+ essential: True
+ email_verified:
+ essential: True
token:
- class: oidcendpoint.jwt_token.JWTToken
+ class: oidcop.token.jwt_token.JWTToken
lifetime: 3600
add_claims:
- email
@@ -104,42 +93,47 @@ op:
- https://example.org/appl
refresh:
lifetime: 86400
- jwks:
- *oidc_keys
+ keys:
+ private_path: "private/jwks.json"
+ key_defs: *key_def
+ public_path: 'static/jwks.json'
+ read_only: False
+ # otherwise OP metadata will have jwks_uri: https://127.0.0.1:5000/None!
+ uri_path: 'static/jwks.json'
endpoint:
webfinger:
path: '.well-known/webfinger'
- class: oidcendpoint.oidc.discovery.Discovery
+ class: oidcop.oidc.discovery.Discovery
kwargs:
client_authn_method: null
provider_info:
path: ".well-known/openid-configuration"
- class: oidcendpoint.oidc.provider_config.ProviderConfiguration
+ class: oidcop.oidc.provider_config.ProviderConfiguration
kwargs:
client_authn_method: null
registration:
path: registration
- class: oidcendpoint.oidc.registration.Registration
+ class: oidcop.oidc.registration.Registration
kwargs:
client_authn_method: null
client_secret_expiration_time: 432000
registration_api:
path: registration_api
- class: oidcendpoint.oidc.read_registration.RegistrationRead
+ class: oidcop.oidc.read_registration.RegistrationRead
kwargs:
client_authn_method:
- bearer_header
introspection:
path: introspection
- class: oidcendpoint.oauth2.introspection.Introspection
+ class: oidcop.oauth2.introspection.Introspection
kwargs:
client_authn_method:
- client_secret_post: ClientSecretPost
+ - client_secret_post
release:
- username
authorization:
path: authorization
- class: oidcendpoint.oidc.authorization.Authorization
+ class: oidcop.oidc.authorization.Authorization
kwargs:
client_authn_method: null
claims_parameter_supported: True
@@ -160,7 +154,7 @@ op:
- form_post
token:
path: token
- class: oidcendpoint.oidc.token.AccessToken
+ class: oidcop.oidc.token.Token
kwargs:
client_authn_method:
- client_secret_post
@@ -169,7 +163,7 @@ op:
- private_key_jwt
userinfo:
path: userinfo
- class: oidcendpoint.oidc.userinfo.UserInfo
+ class: oidcop.oidc.userinfo.UserInfo
kwargs:
claim_types_supported:
- normal
@@ -177,7 +171,7 @@ op:
- distributed
end_session:
path: session
- class: oidcendpoint.oidc.session.Session
+ class: oidcop.oidc.session.Session
kwargs:
logout_verify_url: verify_logout
post_logout_uri_path: post_logout
@@ -188,59 +182,63 @@ op:
backchannel_logout_session_supported: True
check_session_iframe: 'check_session_iframe'
userinfo:
- class: oidcendpoint.user_info.UserInfo
+ class: oidcop.user_info.UserInfo
kwargs:
db_file: users.json
authentication:
user:
- acr: oidcendpoint.user_authn.authn_context.INTERNETPROTOCOLPASSWORD
- class: oidcendpoint.user_authn.user.UserPassJinja2
+ acr: urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword
+ class: oidcop.user_authn.user.UserPassJinja2
kwargs:
verify_endpoint: 'verify/user'
template: user_pass.jinja2
db:
- class: oidcendpoint.util.JSONDictDB
+ class: oidcop.util.JSONDictDB
kwargs:
- json_path: passwd.json
+ filename: passwd.json
page_header: "Testing log in"
submit_btn: "Get me in!"
user_label: "Nickname"
passwd_label: "Secret sauce"
#anon:
- #acr: oidcendpoint.user_authn.authn_context.UNSPECIFIED
- #class: oidcendpoint.user_authn.user.NoAuthn
+ #acr: oidcop.user_authn.authn_context.UNSPECIFIED
+ #class: oidcop.user_authn.user.NoAuthn
#kwargs:
#user: diana
- cookie_dealer:
- class: oidcendpoint.cookie.CookieDealer
- kwargs:
- sign_jwk:
- filename: 'private/cookie_sign_jwk.json'
- type: OCT
- kid: cookie_sign_key_id
- enc_jwk:
- filename: 'private/cookie_enc_jwk.json'
- type: OCT
- kid: cookie_enc_key_id
- # enc_jwk: 'private/cookie_enc_jwk.json'
- default_values:
- name: oidc_op
- domain: *domain
- path: /
- max_age: 3600
+ cookie_handler:
+ class: oidcop.cookie_handler.CookieHandler
+ kwargs":
+ keys:
+ private_path: "private/cookie_jwks.json"
+ key_defs:
+ -
+ type: OCT
+ kid: enc
+ use:
+ - enc
+ -
+ type: OCT
+ kid: sig
+ use:
+ - sig
+ read_only: false
+ name:
+ session: "oidc_op"
+ register: "oidc_op_rp"
+ session_management: "sman"
login_hint2acrs:
- class: oidcendpoint.login_hint.LoginHint2Acrs
+ class: oidcop.login_hint.LoginHint2Acrs
kwargs:
scheme_map:
email:
- - oidcendpoint.user_authn.authn_context.INTERNETPROTOCOLPASSWORD
+ - urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword
# this adds PKCE support as mandatory - disable it if needed (essential: False)
add_on:
pkce:
- function: oidcendpoint.oidc.add_on.pkce.add_pkce_support
+ function: oidcop.oidc.add_on.pkce.add_pkce_support
kwargs:
- essential: False
+ essential: false
code_challenge_method:
#plain
S256
@@ -248,7 +246,7 @@ op:
S512
claims:
- function: oidcendpoint.oidc.add_on.custom_scopes.add_custom_scopes
+ function: oidcop.oidc.add_on.custom_scopes.add_custom_scopes
kwargs:
research_and_scholarship:
- name
diff --git a/chpy/passwd.json b/example/flask_op/passwd.json
similarity index 100%
rename from chpy/passwd.json
rename to example/flask_op/passwd.json
diff --git a/flask_op/requirements.txt b/example/flask_op/requirements.txt
similarity index 100%
rename from flask_op/requirements.txt
rename to example/flask_op/requirements.txt
diff --git a/example/flask_op/run.sh b/example/flask_op/run.sh
new file mode 100644
index 00000000..ac121302
--- /dev/null
+++ b/example/flask_op/run.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+python3 server.py config.json
diff --git a/flask_op/seed.txt b/example/flask_op/seed.txt
similarity index 100%
rename from flask_op/seed.txt
rename to example/flask_op/seed.txt
diff --git a/flask_op/server.py b/example/flask_op/server.py
similarity index 81%
rename from flask_op/server.py
rename to example/flask_op/server.py
index e4def45d..caada009 100755
--- a/flask_op/server.py
+++ b/example/flask_op/server.py
@@ -4,10 +4,9 @@
import logging
import os
-import OpenSSL
-import werkzeug
-
from oidcop.configure import Configuration
+from oidcop.configure import OPConfiguration
+from oidcop.configure import create_from_config_file
from oidcop.utils import create_context
try:
@@ -53,8 +52,15 @@
def main(config_file, args):
logging.basicConfig(level=logging.DEBUG)
- config = Configuration.create_from_config_file(config_file, base_path=dir_path)
- app = oidc_provider_init_app(config, 'oidc_op')
+ config = create_from_config_file(Configuration,
+ entity_conf=[{
+ "class": OPConfiguration, "attr": "op",
+ "path": ["op", "server_info"]
+ }],
+ filename=config_file,
+ base_path=dir_path)
+ app = oidc_provider_init_app(config.op, 'oidc_op')
+ app.logger = config.logger
web_conf = config.webserver
diff --git a/flask_op/templates/check_session_iframe.html b/example/flask_op/templates/check_session_iframe.html
similarity index 100%
rename from flask_op/templates/check_session_iframe.html
rename to example/flask_op/templates/check_session_iframe.html
diff --git a/flask_op/templates/error.html b/example/flask_op/templates/error.html
similarity index 100%
rename from flask_op/templates/error.html
rename to example/flask_op/templates/error.html
diff --git a/flask_op/templates/frontchannel_logout.html b/example/flask_op/templates/frontchannel_logout.html
similarity index 100%
rename from flask_op/templates/frontchannel_logout.html
rename to example/flask_op/templates/frontchannel_logout.html
diff --git a/flask_op/templates/index.html b/example/flask_op/templates/index.html
similarity index 100%
rename from flask_op/templates/index.html
rename to example/flask_op/templates/index.html
diff --git a/flask_op/templates/logout.html b/example/flask_op/templates/logout.html
similarity index 100%
rename from flask_op/templates/logout.html
rename to example/flask_op/templates/logout.html
diff --git a/flask_op/templates/post_logout.html b/example/flask_op/templates/post_logout.html
similarity index 100%
rename from flask_op/templates/post_logout.html
rename to example/flask_op/templates/post_logout.html
diff --git a/chpy/templates/user_pass.jinja2 b/example/flask_op/templates/user_pass.jinja2
similarity index 100%
rename from chpy/templates/user_pass.jinja2
rename to example/flask_op/templates/user_pass.jinja2
diff --git a/chpy/users.json b/example/flask_op/users.json
similarity index 100%
rename from chpy/users.json
rename to example/flask_op/users.json
diff --git a/flask_op/views.py b/example/flask_op/views.py
similarity index 72%
rename from flask_op/views.py
rename to example/flask_op/views.py
index fdc19bf2..06a7f180 100644
--- a/flask_op/views.py
+++ b/example/flask_op/views.py
@@ -1,11 +1,9 @@
-import base64
import json
import os
import sys
import traceback
from urllib.parse import urlparse
-import werkzeug
from cryptojwt import as_unicode
from flask import Blueprint
from flask import current_app
@@ -14,14 +12,15 @@
from flask import request
from flask.helpers import make_response
from flask.helpers import send_from_directory
-from oidcendpoint.authn_event import create_authn_event
-from oidcendpoint.exception import FailedAuthentication
-from oidcendpoint.exception import InvalidClient
-from oidcendpoint.exception import UnknownClient
-from oidcendpoint.oidc.token import AccessToken
from oidcmsg.oauth2 import ResponseMessage
from oidcmsg.oidc import AccessTokenRequest
from oidcmsg.oidc import AuthorizationRequest
+import werkzeug
+
+from oidcop.exception import FailedAuthentication
+from oidcop.exception import InvalidClient
+from oidcop.exception import UnknownClient
+from oidcop.oidc.token import Token
# logger = logging.getLogger(__name__)
@@ -29,21 +28,20 @@
def _add_cookie(resp, cookie_spec):
- for key, _morsel in cookie_spec.items():
- kwargs = {'value': _morsel.value}
- for param in ['expires', 'path', 'comment', 'domain', 'max-age',
- 'secure',
- 'version']:
- if _morsel[param]:
- kwargs[param] = _morsel[param]
- resp.set_cookie(key, **kwargs)
+ kwargs = {'value': cookie_spec["value"]}
+ for param in ['expires', 'max-age']:
+ if param in cookie_spec:
+ kwargs[param] = cookie_spec[param]
+ kwargs["path"] = "/"
+ resp.set_cookie(cookie_spec["name"], **kwargs)
def add_cookie(resp, cookie_spec):
if isinstance(cookie_spec, list):
for _spec in cookie_spec:
_add_cookie(resp, _spec)
-
+ elif isinstance(cookie_spec, dict):
+ _add_cookie(resp, cookie_spec)
@oidc_op_views.route('/static/')
def send_js(path):
@@ -67,7 +65,7 @@ def add_headers_and_cookie(resp, info):
def do_response(endpoint, req_args, error='', **args):
info = endpoint.do_response(request=req_args, error=error, **args)
- _log = current_app.srv_config.logger
+ _log = current_app.logger
_log.debug('do_response: {}'.format(info))
try:
@@ -118,15 +116,11 @@ def verify(authn_method):
auth_args = authn_method.unpack_token(kwargs['token'])
authz_request = AuthorizationRequest().from_urlencoded(auth_args['query'])
- authn_event = create_authn_event(
- uid=username,
- salt=base64.b64encode(os.urandom(16)).decode(),
- authn_info=auth_args['authn_class_ref'],
- authn_time=auth_args['iat'])
+ endpoint = current_app.server.server_get("endpoint", 'authorization')
+ _session_id = endpoint.create_session(authz_request, username, auth_args['authn_class_ref'],
+ auth_args['iat'], authn_method)
- endpoint = current_app.endpoint_context.endpoint['authorization']
- args = endpoint.authz_part2(user=username, request=authz_request,
- authn_event=authn_event)
+ args = endpoint.authz_part2(request=authz_request, session_id=_session_id)
if isinstance(args, ResponseMessage) and 'error' in args:
return make_response(args.to_json(), 400)
@@ -136,8 +130,8 @@ def verify(authn_method):
@oidc_op_views.route('/verify/user', methods=['GET', 'POST'])
def verify_user():
- authn_method = current_app.endpoint_context.authn_broker.get_method_by_id(
- 'user')
+ authn_method = current_app.server.server_get(
+ "endpoint_context").authn_broker.get_method_by_id('user')
try:
return verify(authn_method)
except FailedAuthentication as exc:
@@ -146,8 +140,8 @@ def verify_user():
@oidc_op_views.route('/verify/user_pass_jinja', methods=['GET', 'POST'])
def verify_user_pass_jinja():
- authn_method = current_app.endpoint_context.authn_broker.get_method_by_id(
- 'user')
+ authn_method = current_app.server.server_get(
+ "endpoint_context").authn_broker.get_method_by_id('user')
try:
return verify(authn_method)
except FailedAuthentication as exc:
@@ -157,11 +151,9 @@ def verify_user_pass_jinja():
@oidc_op_views.route('/.well-known/')
def well_known(service):
if service == 'openid-configuration':
- _endpoint = current_app.endpoint_context.endpoint['provider_config']
- # if service == 'openid-federation':
- # _endpoint = current_app.endpoint_context.endpoint['provider_info']
+ _endpoint = current_app.server.server_get("endpoint", 'provider_config')
elif service == 'webfinger':
- _endpoint = current_app.endpoint_context.endpoint['discovery']
+ _endpoint = current_app.server.server_get("endpoint", 'discovery')
else:
return make_response('Not supported', 400)
@@ -171,65 +163,73 @@ def well_known(service):
@oidc_op_views.route('/registration', methods=['GET', 'POST'])
def registration():
return service_endpoint(
- current_app.endpoint_context.endpoint['registration'])
+ current_app.server.server_get("endpoint", 'registration'))
@oidc_op_views.route('/registration_api', methods=['GET'])
def registration_api():
return service_endpoint(
- current_app.endpoint_context.endpoint['registration_read'])
+ current_app.server.server_get("endpoint", 'registration_read'))
@oidc_op_views.route('/authorization')
def authorization():
return service_endpoint(
- current_app.endpoint_context.endpoint['authorization'])
+ current_app.server.server_get("endpoint", 'authorization'))
@oidc_op_views.route('/token', methods=['GET', 'POST'])
def token():
return service_endpoint(
- current_app.endpoint_context.endpoint['token'])
+ current_app.server.server_get("endpoint", 'token'))
+@oidc_op_views.route('/introspection', methods=['POST'])
+def introspection_endpoint():
+ return service_endpoint(
+ current_app.server.server_get("endpoint", 'introspection'))
@oidc_op_views.route('/userinfo', methods=['GET', 'POST'])
def userinfo():
return service_endpoint(
- current_app.endpoint_context.endpoint['userinfo'])
+ current_app.server.server_get("endpoint", 'userinfo'))
@oidc_op_views.route('/session', methods=['GET'])
def session_endpoint():
return service_endpoint(
- current_app.endpoint_context.endpoint['session'])
+ current_app.server.server_get("endpoint", 'session'))
+
+
+IGNORE = ["cookie", "user-agent"]
def service_endpoint(endpoint):
- _log = current_app.srv_config.logger
- _log.info('At the "{}" endpoint'.format(endpoint.endpoint_name))
+ _log = current_app.logger
+ _log.info('At the "{}" endpoint'.format(endpoint.name))
- try:
- authn = request.headers['Authorization']
- except KeyError:
- pr_args = {}
- else:
- pr_args = {'auth': authn}
+ http_info = {
+ "headers": {k: v for k, v in request.headers.items(lower=True) if k not in IGNORE},
+ "method": request.method,
+ "url": request.url,
+ # name is not unique
+ "cookie": [{"name": k, "value": v} for k, v in request.cookies.items()]
+ }
if request.method == 'GET':
try:
- req_args = endpoint.parse_request(request.args.to_dict(), **pr_args)
+ req_args = endpoint.parse_request(request.args.to_dict(), http_info=http_info)
except (InvalidClient, UnknownClient) as err:
_log.error(err)
return make_response(json.dumps({
'error': 'unauthorized_client',
'error_description': str(err)
- }), 400)
+ }), 400)
except Exception as err:
_log.error(err)
return make_response(json.dumps({
'error': 'invalid_request',
'error_description': str(err)
- }), 400)
+ }), 400)
else:
if request.data:
if isinstance(request.data, str):
@@ -239,7 +239,7 @@ def service_endpoint(endpoint):
else:
req_args = dict([(k, v) for k, v in request.form.items()])
try:
- req_args = endpoint.parse_request(req_args, **pr_args)
+ req_args = endpoint.parse_request(req_args, http_info=http_info)
except Exception as err:
_log.error(err)
err_msg = ResponseMessage(error='invalid_request', error_description=str(err))
@@ -248,18 +248,11 @@ def service_endpoint(endpoint):
_log.info('request: {}'.format(req_args))
if isinstance(req_args, ResponseMessage) and 'error' in req_args:
return make_response(req_args.to_json(), 400)
-
try:
- if request.cookies:
- _log.debug(request.cookies)
- kwargs = {'cookie': request.cookies}
+ if isinstance(endpoint, Token):
+ args = endpoint.process_request(AccessTokenRequest(**req_args), http_info=http_info)
else:
- kwargs = {}
-
- if isinstance(endpoint, AccessToken):
- args = endpoint.process_request(AccessTokenRequest(**req_args), **kwargs)
- else:
- args = endpoint.process_request(req_args, **kwargs)
+ args = endpoint.process_request(req_args, http_info=http_info)
except Exception as err:
message = traceback.format_exception(*sys.exc_info())
_log.error(message)
@@ -273,7 +266,8 @@ def service_endpoint(endpoint):
if 'http_response' in args:
return make_response(args['http_response'], 200)
- return do_response(endpoint, req_args, **args)
+ response = do_response(endpoint, req_args, **args)
+ return response
@oidc_op_views.errorhandler(werkzeug.exceptions.BadRequest)
@@ -292,14 +286,15 @@ def check_session_iframe():
req_args = dict([(k, v) for k, v in request.form.items()])
if req_args:
+ _context = current_app.server.server_get("endpoint_context")
# will contain client_id and origin
- if req_args['origin'] != current_app.endpoint_context.issuer:
+ if req_args['origin'] != _context.issuer:
return 'error'
- if req_args['client_id'] != current_app.endpoint_context.cdb:
+ if req_args['client_id'] != _context.cdb:
return 'error'
return 'OK'
- current_app.srv_config.logger.debug(
+ current_app.logger.debug(
'check_session_iframe: {}'.format(req_args))
doc = open('templates/check_session_iframe.html').read()
return doc
@@ -307,7 +302,7 @@ def check_session_iframe():
@oidc_op_views.route('/verify_logout', methods=['GET', 'POST'])
def verify_logout():
- part = urlparse(current_app.endpoint_context.issuer)
+ part = urlparse(current_app.server.server_get("endpoint_context").issuer)
page = render_template('logout.html', op=part.hostname,
do_logout='rp_logout', sjwt=request.args['sjwt'])
return page
@@ -315,7 +310,7 @@ def verify_logout():
@oidc_op_views.route('/rp_logout', methods=['GET', 'POST'])
def rp_logout():
- _endp = current_app.endpoint_context.endpoint['session']
+ _endp = current_app.server.server_get("endpoint", 'session')
_info = _endp.unpack_signed_jwt(request.form['sjwt'])
try:
request.form['logout']
@@ -333,8 +328,11 @@ def rp_logout():
postLogoutRedirectUri=_info['redirect_uri'])
else:
res = redirect(_info['redirect_uri'])
+
+ # rohe are you sure that _kakor is the right word? :)
_kakor = _endp.kill_cookies()
- _add_cookie(res, _kakor)
+ for cookie in _kakor:
+ _add_cookie(res, cookie)
return res
diff --git a/flask_op/application.py b/flask_op/application.py
deleted file mode 100644
index b8f17fca..00000000
--- a/flask_op/application.py
+++ /dev/null
@@ -1,49 +0,0 @@
-import os
-from urllib.parse import urlparse
-
-from cryptojwt.key_jar import init_key_jar
-from flask.app import Flask
-from oidcendpoint.endpoint_context import EndpointContext
-
-folder = os.path.dirname(os.path.realpath(__file__))
-
-
-def init_oidc_op_endpoints(app):
- _config = app.srv_config.op
- _server_info_config = _config['server_info']
-
- iss = _server_info_config['issuer']
- if '{domain}' in iss:
- iss = iss.format(domain=app.srv_config.domain,
- port=app.srv_config.port)
- _server_info_config['issuer'] = iss
-
- endpoint_context = EndpointContext(_server_info_config, cwd=folder)
-
- for endp in endpoint_context.endpoint.values():
- p = urlparse(endp.endpoint_path)
- _vpath = p.path.split('/')
- if _vpath[0] == '':
- endp.vpath = _vpath[1:]
- else:
- endp.vpath = _vpath
-
- return endpoint_context
-
-
-def oidc_provider_init_app(config, name=None, **kwargs):
- name = name or __name__
- app = Flask(name, static_url_path='', **kwargs)
- app.srv_config = config
-
- try:
- from .views import oidc_op_views
- except ImportError:
- from views import oidc_op_views
-
- app.register_blueprint(oidc_op_views)
-
- # Initialize the oidc_provider after views to be able to set correct urls
- app.endpoint_context = init_oidc_op_endpoints(app)
-
- return app
diff --git a/flask_op/conf.py b/flask_op/conf.py
deleted file mode 100644
index d9e81ce6..00000000
--- a/flask_op/conf.py
+++ /dev/null
@@ -1,177 +0,0 @@
-from oidcendpoint import user_info
-from oidcendpoint.oidc.authorization import Authorization
-from oidcendpoint.oidc.discovery import Discovery
-from oidcendpoint.oidc.provider_config import ProviderConfiguration
-from oidcendpoint.oidc.registration import Registration
-from oidcendpoint.oidc.session import Session
-from oidcendpoint.oidc.token import AccessToken
-from oidcendpoint.oidc.userinfo import UserInfo
-from oidcendpoint.user_authn.authn_context import INTERNETPROTOCOLPASSWORD
-from oidcendpoint.user_authn.authn_context import UNSPECIFIED
-from oidcendpoint.user_authn.user import NoAuthn
-from oidcendpoint.user_authn.user import UserPassJinja2
-from oidcendpoint.util import JSONDictDB
-
-SESSION_COOKIE_NAME = 'flop'
-
-RESPONSE_TYPES_SUPPORTED = [
- ["code"], ["token"], ["id_token"], ["code", "token"], ["code", "id_token"],
- ["id_token", "token"], ["code", "token", "id_token"], ['none']]
-
-CAPABILITIES = {
- "subject_types_supported": ["public", "pairwise"],
- "grant_types_supported": [
- "authorization_code", "implicit",
- "urn:ietf:params:oauth:grant-type:jwt-bearer", "refresh_token"],
-}
-
-KEY_DEF = [
- {"type": "RSA", "use": ["sig"]},
- {"type": "EC", "crv": "P-256", "use": ["sig"]}
-]
-
-PORT = 5000
-DOMAIN = '127.0.0.1'
-SERVER_NAME = '{}:{}'.format(DOMAIN, str(PORT))
-BASE_URL = 'https://{}'.format(SERVER_NAME)
-
-# If we support session management
-CAPABILITIES["check_session_iframe"] = "{}/check_session_iframe".format(BASE_URL)
-
-PATH = {
- 'userinfo:kwargs:db_file': '{}/users.json',
- 'authentication:0:kwargs:db:kwargs:json_path': '{}/passwd.json'
-}
-
-CONFIG = {
- 'provider': {
- 'key_defs': [
- {"type": "RSA", "use": ["sig"]},
- {"type": "EC", "crv": "P-256", "use": ["sig"]}
- ],
- },
- 'server_info': {
- "issuer": BASE_URL,
- "password": "mycket hemlig information",
- "token_expires_in": 600,
- "grant_expires_in": 300,
- "refresh_token_expires_in": 86400,
- "verify_ssl": False,
- "capabilities": CAPABILITIES,
- 'template_dir': 'templates',
- "jwks": {
- 'private_path': 'own/jwks.json',
- 'key_defs': KEY_DEF,
- 'url_path': 'static/jwks.json'
- },
- 'endpoint': {
- 'webfinger': {
- 'path': '.well-known/webfinger',
- 'class': Discovery,
- 'kwargs': {'client_authn_method': None}
- },
- 'provider_info': {
- 'path': '.well-known/openid-configuration',
- 'class': ProviderConfiguration,
- 'kwargs': {'client_authn_method': None}
- },
- 'registration': {
- 'path': 'registration',
- 'class': Registration,
- 'kwargs': {
- 'client_authn_method': None,
- 'client_secret_expiration_time': 5 * 86400
- }
- },
- 'authorization': {
- 'path': 'authorization',
- 'class': Authorization,
- 'kwargs': {
- 'client_authn_method': None,
- "response_types_supported": [" ".join(x) for x in RESPONSE_TYPES_SUPPORTED],
- "response_modes_supported": ['query', 'fragment', 'form_post'],
- "claims_parameter_supported": True,
- "request_parameter_supported": True,
- "request_uri_parameter_supported": True
- }
- },
- 'token': {
- 'path': 'token',
- 'class': AccessToken,
- 'kwargs': {
- "client_authn_method": [
- "client_secret_post", "client_secret_basic",
- "client_secret_jwt", "private_key_jwt"],
-
- }
- },
- 'userinfo': {
- 'path': 'userinfo',
- 'class': UserInfo,
- "kwargs": {
- "claim_types_supported": ["normal", "aggregated", "distributed"],
- }
- },
- 'end_session': {
- 'path': 'session',
- 'class': Session,
- 'kwargs': {
- 'logout_uri': "{}/verify_logout".format(BASE_URL),
- 'post_logout_uri': "{}/post_logout".format(BASE_URL),
- 'signing_alg': "ES256",
- "frontchannel_logout_supported": True,
- "frontchannel_logout_session_supported": True,
- "backchannel_logout_supported": True,
- "backchannel_logout_session_supported": True,
- "check_session_iframe": "{}/check_session_iframe".format(BASE_URL)
- }
- }
- },
- 'userinfo': {
- 'class': user_info.UserInfo,
- 'kwargs': {'db_file': 'users.json'}
- },
- 'authentication': {
- 'user':
- {
- 'acr': INTERNETPROTOCOLPASSWORD,
- 'class': UserPassJinja2,
- 'verify_endpoint': 'verify/user',
- 'kwargs': {
- 'template': 'user_pass.jinja2',
- 'sym_key': '24AA/LR6HighEnergy',
- 'db': {
- 'class': JSONDictDB,
- 'kwargs':
- {'json_path': 'passwd.json'}
- },
- 'page_header': "Testing log in",
- 'submit_btn': "Get me in!",
- 'user_label': "Nickname",
- 'passwd_label': "Secret sauce"
- }
- },
- 'anon':
- {
- 'acr': UNSPECIFIED,
- 'class': NoAuthn,
- 'kwargs': {'user': 'diana'}
- }
- },
- 'cookie_dealer': {
- 'symkey': 'ghsNKDDLshZTPn974nOsIGhedULrsqnsGoBFBLwUKuJhE2ch',
- 'default_values': {
- 'name': 'oidc_op',
- 'domain': DOMAIN,
- 'path': '/',
- 'max_age': 3600
- }
- }
- },
- 'webserver': {
- 'cert': '{}/certs/cert.pem',
- 'key': '{}/certs/key.pem',
- 'cert_chain': '',
- 'port': PORT,
- }
-}
diff --git a/flask_op/config_persistent.yaml b/flask_op/config_persistent.yaml
deleted file mode 100644
index 6ff797ca..00000000
--- a/flask_op/config_persistent.yaml
+++ /dev/null
@@ -1,293 +0,0 @@
-logging:
- version: 1
- root:
- handlers:
- - default
- - console
- level: DEBUG
- loggers:
- bobcat_idp:
- level: DEBUG
- handlers:
- default:
- class: logging.FileHandler
- filename: 'debug.log'
- formatter: default
- console:
- class: logging.StreamHandler
- stream: 'ext://sys.stdout'
- formatter: default
- formatters:
- default:
- format: '%(asctime)s %(name)s %(levelname)s %(message)s'
-
-port: &port 5000
-domain: &domain 127.0.0.1
-server_name: '{domain}:{port}'
-base_url: &base_url 'https://{domain}:{port}'
-
-key_def: &key_def
- -
- type: RSA
- use:
- - sig
- -
- type: EC
- crv: "P-256"
- use:
- - sig
-
-OIDC_KEYS: &oidc_keys
- 'private_path': "private/jwks.json"
- 'key_defs': *key_def
- 'public_path': 'static/jwks.json'
- 'read_only': False
- # otherwise OP metadata will have jwks_uri: https://127.0.0.1:5000/None!
- 'uri_path': 'static/jwks.json'
-
-
-op:
- server_info:
- issuer: *base_url
- http_params:
- verify_ssl: False
- session_key:
- filename: private/session_jwk.json
- type: OCT
- use: sig
- capabilities:
- subject_types_supported:
- - public
- - pairwise
- grant_types_supported:
- - authorization_code
- - implicit
- - urn:ietf:params:oauth:grant-type:jwt-bearer
- - refresh_token
- template_dir: templates
- id_token:
- class: oidcendpoint.id_token.IDToken
- kwargs:
- default_claims:
- email:
- essential: True
- email_verified:
- essential: True
- token_handler_args:
- jwks_def:
- private_path: 'private/token_jwks.json'
- read_only: False
- key_defs:
- -
- type: oct
- bytes: 24
- use:
- - enc
- kid: code
- -
- type: oct
- bytes: 24
- use:
- - enc
- kid: refresh
- code:
- lifetime: 600
- token:
- class: oidcendpoint.jwt_token.JWTToken
- lifetime: 3600
- add_claims:
- - email
- - email_verified
- - phone_number
- - phone_number_verified
- add_claim_by_scope: True
- aud:
- - https://example.org/appl
- refresh:
- lifetime: 86400
- keys:
- *oidc_keys
- endpoint:
- webfinger:
- path: '.well-known/webfinger'
- class: oidcendpoint.oidc.discovery.Discovery
- kwargs:
- client_authn_method: null
- provider_info:
- path: ".well-known/openid-configuration"
- class: oidcendpoint.oidc.provider_config.ProviderConfiguration
- kwargs:
- client_authn_method: null
- registration:
- path: registration
- class: oidcendpoint.oidc.registration.Registration
- kwargs:
- client_authn_method: null
- client_secret_expiration_time: 432000
- registration_api:
- path: registration_api
- class: oidcendpoint.oidc.read_registration.RegistrationRead
- kwargs:
- client_authn_method:
- - bearer_header
- introspection:
- path: introspection
- class: oidcendpoint.oauth2.introspection.Introspection
- kwargs:
- client_authn_method:
- - client_secret_post
- release:
- - username
- authorization:
- path: authorization
- class: oidcendpoint.oidc.authorization.Authorization
- kwargs:
- client_authn_method: null
- claims_parameter_supported: True
- request_parameter_supported: True
- request_uri_parameter_supported: True
- response_types_supported:
- - code
- - token
- - id_token
- - "code token"
- - "code id_token"
- - "id_token token"
- - "code id_token token"
- - none
- response_modes_supported:
- - query
- - fragment
- - form_post
- token:
- path: token
- class: oidcendpoint.oidc.token.AccessToken
- kwargs:
- client_authn_method:
- - client_secret_post
- - client_secret_basic
- - client_secret_jwt
- - private_key_jwt
- userinfo:
- path: userinfo
- class: oidcendpoint.oidc.userinfo.UserInfo
- kwargs:
- claim_types_supported:
- - normal
- - aggregated
- - distributed
- end_session:
- path: session
- class: oidcendpoint.oidc.session.Session
- kwargs:
- logout_verify_url: verify_logout
- post_logout_uri_path: post_logout
- signing_alg: "ES256"
- frontchannel_logout_supported: True
- frontchannel_logout_session_supported: True
- backchannel_logout_supported: True
- backchannel_logout_session_supported: True
- check_session_iframe: 'check_session_iframe'
- userinfo:
- class: oidcendpoint.user_info.UserInfo
- kwargs:
- db_file: users.json
- authentication:
- user:
- acr: oidcendpoint.user_authn.authn_context.INTERNETPROTOCOLPASSWORD
- class: oidcendpoint.user_authn.user.UserPassJinja2
- kwargs:
- verify_endpoint: 'verify/user'
- template: user_pass.jinja2
- db:
- class: oidcendpoint.util.JSONDictDB
- kwargs:
- json_path: passwd.json
- page_header: "Testing log in"
- submit_btn: "Get me in!"
- user_label: "Nickname"
- passwd_label: "Secret sauce"
- #anon:
- #acr: oidcendpoint.user_authn.authn_context.UNSPECIFIED
- #class: oidcendpoint.user_authn.user.NoAuthn
- #kwargs:
- #user: diana
- cookie_dealer:
- class: oidcendpoint.cookie.CookieDealer
- kwargs:
- sign_jwk:
- filename: 'private/cookie_sign_jwk.json'
- type: OCT
- kid: cookie_sign_key_id
- enc_jwk:
- filename: 'private/cookie_enc_jwk.json'
- type: OCT
- kid: cookie_enc_key_id
- # enc_jwk: 'private/cookie_enc_jwk.json'
- default_values:
- name: oidc_op
- domain: *domain
- path: /
- max_age: 3600
- login_hint2acrs:
- class: oidcendpoint.login_hint.LoginHint2Acrs
- kwargs:
- scheme_map:
- email:
- - oidcendpoint.user_authn.authn_context.INTERNETPROTOCOLPASSWORD
-
- # this adds PKCE support as mandatory - disable it if needed (essential: False)
- add_on:
- pkce:
- function: oidcendpoint.oidc.add_on.pkce.add_pkce_support
- kwargs:
- essential: false
- code_challenge_method:
- #plain
- S256
- S384
- S512
-
- claims:
- function: oidcendpoint.oidc.add_on.custom_scopes.add_custom_scopes
- kwargs:
- research_and_scholarship:
- - name
- - given_name
- - family_name
- - email
- - email_verified
- - sub
- - iss
- - eduperson_scoped_affiliation
- db_conf:
- keyjar:
- handler: abstorage.storages.abfile.LabeledAbstractFileSystem
- fdir: storage/keyjar
- key_conv: abstorage.converter.QPKey
- value_conv: cryptojwt.serialize.item.KeyIssuer
- label: 'x'
- default:
- handler: abstorage.storages.abfile.AbstractFileSystem
- fdir: storage
- key_conv: abstorage.converter.QPKey
- value_conv: abstorage.converter.JSON
- session:
- handler: abstorage.storages.abfile.AbstractFileSystem
- fdir: storage/session
- key_conv: abstorage.converter.QPKey
- value_conv: abstorage.converter.JSON
- sso:
- handler: abstorage.storages.abfile.AbstractFileSystem
- fdir: storage/sso
- key_conv: abstorage.converter.QPKey
- value_conv: abstorage.converter.JSON
-
-webserver:
- server_cert: 'certs/89296913_127.0.0.1.cert'
- server_key: 'certs/89296913_127.0.0.1.key'
- ca_bundle: null
- verify_user: false
- port: *port
- domain: *domain
- debug: true
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..cff54529
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,47 @@
+[build-system]
+requires = [
+ "setuptools>=42",
+ "wheel"
+]
+build-backend = "setuptools.build_meta"
+
+[metadata]
+name = "oidcop"
+version = "2.0.0"
+author = "Roland Hedberg"
+author_email = "roland@catalogix.se"
+description = "Python implementation of an OAuth2 AS and an OIDC Provider"
+long_description = "file: README.md"
+long_description_content_type = "text/markdown"
+url = "https://github.com/IdentityPython/oidc-op"
+license = "Apache-2.0"
+classifiers =[
+ "Programming Language :: Python :: 3",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent"
+]
+
+[options]
+package_dir = "src"
+packages = "find:"
+python= "^3.6"
+
+[tool.black]
+line-length = 100
+
+[tool.isort]
+force_single_line = true
+known_first_party = "oidcop"
+include_trailing_comma = true
+force_grid_wrap = 0
+use_parentheses = true
+line_length = 100
+
+[tool.coverage.run]
+branch = true
+
+[tool.coverage.report]
+exclude_lines = [
+ "pragma: no cover",
+ "raise NotImplementedError",
+]
diff --git a/requirements-docs.txt b/requirements-docs.txt
new file mode 100644
index 00000000..3069ee0d
--- /dev/null
+++ b/requirements-docs.txt
@@ -0,0 +1,4 @@
+sphinx
+recommonmark
+sphinx_rtd_theme
+sphinxcontrib-images
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 00000000..9126daf7
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,4 @@
+oidcmsg>=1.3.0
+pyyaml
+jinja2>=2.11.3
+responses>=0.13.0
diff --git a/setup.py b/setup.py
index 94d824ef..8b03c90b 100644
--- a/setup.py
+++ b/setup.py
@@ -51,8 +51,11 @@ def run_tests(self):
author_email="roland@catalogix.se",
license="Apache 2.0",
url='https://github.com/IdentityPython/oidcop',
- packages=["oidcop"],
package_dir={"": "src"},
+ packages=["oidcop", 'oidcop/oidc', 'oidcop/authz',
+ 'oidcop/user_authn', 'oidcop/user_info',
+ 'oidcop/oauth2', 'oidcop/oidc/add_on', 'oidcop/oauth2/add_on',
+ 'oidcop/session', 'oidcop/token'],
classifiers=[
"Development Status :: 4 - Beta",
"License :: OSI Approved :: Apache Software License",
@@ -61,8 +64,11 @@ def run_tests(self):
"Programming Language :: Python :: 3.8",
"Topic :: Software Development :: Libraries :: Python Modules"],
install_requires=[
+ "oidcmsg==1.3.2",
+ "cryptojwt==1.5.2",
"pyyaml",
- 'oidcendpoint>=1.1.0'
+ "jinja2>=2.11.3",
+ "responses>=0.13.0"
],
zip_safe=False,
cmdclass={'test': PyTest},
diff --git a/src/oidcop/__init__.py b/src/oidcop/__init__.py
index 39cae215..1335e726 100644
--- a/src/oidcop/__init__.py
+++ b/src/oidcop/__init__.py
@@ -1 +1,35 @@
-__version__ = '0.6.2'
\ No newline at end of file
+import secrets
+
+__version__ = "2.0.0"
+
+DEF_SIGN_ALG = {
+ "id_token": "RS256",
+ "userinfo": "RS256",
+ "request_object": "RS256",
+ "client_secret_jwt": "HS256",
+ "private_key_jwt": "RS256",
+}
+
+HTTP_ARGS = ["headers", "redirections", "connection_type"]
+
+JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
+
+URL_ENCODED = "application/x-www-form-urlencoded"
+JSON_ENCODED = "application/json"
+JOSE_ENCODED = "application/jose"
+
+
+def sanitize(txt):
+ return txt
+
+
+def rndstr(size=16):
+ """
+ Returns a string of random url safe characters
+
+ :param size: The length of the string
+ :return: string
+ """
+ return secrets.token_urlsafe(size)
+ # chars = string.ascii_letters + string.digits
+ # return "".join(choice(chars) for i in range(size))
diff --git a/src/oidcop/authn_event.py b/src/oidcop/authn_event.py
new file mode 100644
index 00000000..0b5bf0e8
--- /dev/null
+++ b/src/oidcop/authn_event.py
@@ -0,0 +1,72 @@
+from oidcmsg.message import SINGLE_OPTIONAL_INT
+from oidcmsg.message import SINGLE_OPTIONAL_STRING
+from oidcmsg.message import SINGLE_REQUIRED_STRING
+from oidcmsg.message import Message
+from oidcmsg.time_util import time_sans_frac
+
+DEFAULT_AUTHN_EXPIRES_IN = 3600
+
+
+class AuthnEvent(Message):
+ c_param = {
+ "uid": SINGLE_REQUIRED_STRING,
+ "authn_info": SINGLE_REQUIRED_STRING,
+ "authn_time": SINGLE_OPTIONAL_INT,
+ "valid_until": SINGLE_OPTIONAL_INT,
+ "sub": SINGLE_OPTIONAL_STRING,
+ }
+
+ def is_valid(self, now=0):
+ if now:
+ return self["valid_until"] > now
+ else:
+ return self["valid_until"] > time_sans_frac()
+
+ def expires_in(self):
+ return self["valid_until"] - time_sans_frac()
+
+
+def create_authn_event(
+ uid,
+ authn_info=None,
+ authn_time: int = 0,
+ valid_until: int = 0,
+ expires_in: int = 0,
+ sub: str = "",
+ **kwargs
+):
+ """
+
+ :param uid: User ID. This is the identifier used by the user DB
+ :param authn_time: When the authentication took place
+ :param authn_info: Information about the authentication
+ :param valid_until: Until when the authentication is valid
+ :param expires_in: How long before the authentication expires
+ :param sub: Subject identifier. The identifier for the user used between
+ the AS and the RP.
+ :param kwargs:
+ :return:
+ """
+ args = {"uid": uid, "authn_info": authn_info}
+
+ if sub:
+ args["sub"] = sub
+
+ if authn_time:
+ args["authn_time"] = authn_time
+ else:
+ _ts = kwargs.get("timestamp")
+ if _ts:
+ args["authn_time"] = _ts
+ else:
+ args["authn_time"] = time_sans_frac()
+
+ if valid_until:
+ args["valid_until"] = valid_until
+ else:
+ if expires_in:
+ args["valid_until"] = args["authn_time"] + expires_in
+ else:
+ args["valid_until"] = args["authn_time"] + DEFAULT_AUTHN_EXPIRES_IN
+
+ return AuthnEvent(**args)
diff --git a/src/oidcop/authz/__init__.py b/src/oidcop/authz/__init__.py
new file mode 100755
index 00000000..04203cff
--- /dev/null
+++ b/src/oidcop/authz/__init__.py
@@ -0,0 +1,118 @@
+import copy
+import inspect
+import logging
+import sys
+from typing import Optional
+from typing import Union
+
+from oidcmsg.message import Message
+
+from oidcop.session.grant import Grant
+
+logger = logging.getLogger(__name__)
+
+
+class AuthzHandling(object):
+ """ Class that allow an entity to manage authorization """
+
+ def __init__(self, server_get, grant_config=None, **kwargs):
+ self.server_get = server_get
+ self.grant_config = grant_config or {}
+ self.kwargs = kwargs
+
+ def usage_rules(self, client_id: Optional[str] = ""):
+ if "usage_rules" in self.grant_config:
+ _usage_rules = copy.deepcopy(self.grant_config["usage_rules"])
+ else:
+ _usage_rules = {}
+
+ if not client_id:
+ return _usage_rules
+
+ try:
+ _per_client = self.server_get("endpoint_context").cdb[client_id]["token_usage_rules"]
+ except KeyError:
+ pass
+ else:
+ if _usage_rules:
+ for _token_type, _rule in _usage_rules.items():
+ _pc = _per_client.get(_token_type)
+ if _pc:
+ _rule.update(_pc)
+ elif _pc == {}:
+ _usage_rules[_token_type] = {}
+ for _token_type, _rule in _per_client.items():
+ if _token_type not in _usage_rules:
+ _usage_rules[_token_type] = _rule
+ else:
+ _usage_rules = _per_client
+
+ return _usage_rules
+
+ def usage_rules_for(self, client_id, token_type):
+ _token_usage = self.usage_rules(client_id=client_id)
+ try:
+ return _token_usage[token_type]
+ except KeyError:
+ return {}
+
+ def __call__(
+ self, session_id: str, request: Union[dict, Message], resources: Optional[list] = None,
+ ) -> Grant:
+ session_info = self.server_get("endpoint_context").session_manager.get_session_info(
+ session_id=session_id, grant=True
+ )
+ grant = session_info["grant"]
+
+ args = self.grant_config.copy()
+
+ for key, val in args.items():
+ if key == "expires_in":
+ grant.set_expires_at(val)
+ elif key == "usage_rules":
+ setattr(grant, key, self.usage_rules(request.get("client_id")))
+ else:
+ setattr(grant, key, val)
+
+ if resources is None:
+ grant.resources = [session_info["client_id"]]
+ else:
+ grant.resources = resources
+
+ # After this is where user consent should be handled
+ scopes = request.get("scope", [])
+ grant.scope = scopes
+ grant.claims = self.server_get("endpoint_context").claims_interface.get_claims_all_usage(
+ session_id=session_id, scopes=scopes
+ )
+
+ return grant
+
+
+class Implicit(AuthzHandling):
+ def __call__(
+ self, session_id: str, request: Union[dict, Message], resources: Optional[list] = None,
+ ) -> Grant:
+ args = self.grant_config.copy()
+ grant = self.server_get("endpoint_context").session_manager.get_grant(session_id=session_id)
+ for arg, val in args:
+ setattr(grant, arg, val)
+ return grant
+
+
+def factory(msgtype, server_get, **kwargs):
+ """
+ Factory method that can be used to easily instantiate a class instance
+
+ :param msgtype: The name of the class
+ :param kwargs: Keyword arguments
+ :return: An instance of the class or None if the name doesn't match any
+ known class.
+ """
+ for name, obj in inspect.getmembers(sys.modules[__name__]):
+ if inspect.isclass(obj) and issubclass(obj, AuthzHandling):
+ try:
+ if obj.__name__ == msgtype:
+ return obj(server_get, **kwargs)
+ except AttributeError:
+ pass
diff --git a/src/oidcop/client_authn.py b/src/oidcop/client_authn.py
new file mode 100755
index 00000000..f02868d9
--- /dev/null
+++ b/src/oidcop/client_authn.py
@@ -0,0 +1,431 @@
+import base64
+import logging
+from typing import Callable
+from typing import Optional
+from typing import Union
+from urllib.parse import unquote_plus
+
+from cryptojwt.exception import BadSignature
+from cryptojwt.exception import Invalid
+from cryptojwt.exception import MissingKey
+from cryptojwt.jwt import JWT
+from cryptojwt.jwt import utc_time_sans_frac
+from cryptojwt.utils import as_bytes
+from cryptojwt.utils import as_unicode
+from oidcmsg.message import Message
+from oidcmsg.oidc import JsonWebToken
+from oidcmsg.oidc import verified_claim_name
+
+from oidcop import JWT_BEARER
+from oidcop import sanitize
+from oidcop.endpoint_context import EndpointContext
+from oidcop.exception import InvalidClient
+from oidcop.exception import MultipleUsage
+from oidcop.exception import NotForMe
+from oidcop.exception import UnknownClient
+from oidcop.util import importer
+
+logger = logging.getLogger(__name__)
+
+__author__ = "roland hedberg"
+
+
+class AuthnFailure(Exception):
+ pass
+
+
+class NoMatchingKey(Exception):
+ pass
+
+
+class UnknownOrNoAuthnMethod(Exception):
+ pass
+
+
+class WrongAuthnMethod(Exception):
+ pass
+
+
+class ClientAuthnMethod(object):
+ def __init__(self, server_get):
+ """
+ :param server_get: A method that can be used to get general server information.
+ """
+ self.server_get = server_get
+
+ def verify(self, **kwargs):
+ """
+ Verify authentication information in a request
+ :param kwargs:
+ :return:
+ """
+ raise NotImplementedError()
+
+ def is_usable(self, request=None, authorization_info=None):
+ """
+ Verify that this authentication method is applicable.
+
+ :param request: The request
+ :param authorization_info: Other authorization information
+ :return: True/False
+ """
+ raise NotImplementedError()
+
+
+def basic_authn(authorization_token):
+ if not authorization_token.startswith("Basic "):
+ raise AuthnFailure("Wrong type of authorization token")
+
+ _tok = as_bytes(authorization_token[6:])
+ # Will raise ValueError type exception if not base64 encoded
+ _tok = base64.b64decode(_tok)
+ part = [unquote_plus(p) for p in as_unicode(_tok).split(":")]
+ if len(part) == 2:
+ return dict(zip(["id", "secret"], part))
+ else:
+ raise ValueError("Illegal token")
+
+
+class ClientSecretBasic(ClientAuthnMethod):
+ """
+ Clients that have received a client_secret value from the Authorization
+ Server, authenticate with the Authorization Server in accordance with
+ Section 3.2.1 of OAuth 2.0 [RFC6749] using HTTP Basic authentication scheme.
+ """
+
+ tag = "client_secret_basic"
+
+ def is_usable(self, request=None, authorization_token=None):
+ if authorization_token is not None and authorization_token.startswith("Basic "):
+ return True
+ return False
+
+ def verify(self, authorization_token, **kwargs):
+ client_info = basic_authn(authorization_token)
+
+ if (
+ self.server_get("endpoint_context").cdb[client_info["id"]]["client_secret"]
+ == client_info["secret"]
+ ):
+ return {"client_id": client_info["id"]}
+ else:
+ raise AuthnFailure()
+
+
+class ClientSecretPost(ClientSecretBasic):
+ """
+ Clients that have received a client_secret value from the Authorization
+ Server, authenticate with the Authorization Server in accordance with
+ Section 3.2.1 of OAuth 2.0 [RFC6749] by including the Client Credentials in
+ the request body.
+ """
+
+ tag = "client_secret_post"
+
+ def is_usable(self, request=None, authorization_token=None):
+ if request is None:
+ return False
+ if "client_id" in request and "client_secret" in request:
+ return True
+ return False
+
+ def verify(self, request, **kwargs):
+ if (
+ self.server_get("endpoint_context").cdb[request["client_id"]]["client_secret"]
+ == request["client_secret"]
+ ):
+ return {"client_id": request["client_id"]}
+ else:
+ raise AuthnFailure("secrets doesn't match")
+
+
+class BearerHeader(ClientSecretBasic):
+ """
+ """
+
+ tag = "bearer_header"
+
+ def is_usable(self, request=None, authorization_token=None):
+ if authorization_token is not None and authorization_token.startswith("Bearer "):
+ return True
+ return False
+
+ def verify(self, authorization_token, **kwargs):
+ return {"token": authorization_token.split(" ", 1)[1]}
+
+
+class BearerBody(ClientSecretPost):
+ """
+ Same as Client Secret Post
+ """
+
+ tag = "bearer_body"
+
+ def is_usable(self, request=None, authorization_token=None):
+ if request is not None and "access_token" in request:
+ return True
+ return False
+
+ def verify(self, request, **kwargs):
+ _token = request.get("access_token")
+ if _token is None:
+ raise AuthnFailure("No access token")
+
+ res = {"token": _token}
+ _client_id = request.get("client_id")
+ if _client_id:
+ res["client_id"] = _client_id
+ return res
+
+
+class JWSAuthnMethod(ClientAuthnMethod):
+ def is_usable(self, request=None, authorization_token=None):
+ if request is None:
+ return False
+ if "client_assertion" in request:
+ return True
+ return False
+
+ def verify(self, request, key_type, **kwargs):
+ _context = self.server_get("endpoint_context")
+ _jwt = JWT(_context.keyjar, msg_cls=JsonWebToken)
+ try:
+ ca_jwt = _jwt.unpack(request["client_assertion"])
+ except (Invalid, MissingKey, BadSignature) as err:
+ logger.info("%s" % sanitize(err))
+ raise AuthnFailure("Could not verify client_assertion.")
+
+ _sign_alg = ca_jwt.jws_header.get("alg")
+ if _sign_alg and _sign_alg.startswith("HS"):
+ if key_type == "private_key":
+ raise AttributeError("Wrong key type")
+ keys = _context.keyjar.get("sig", "oct", ca_jwt["iss"], ca_jwt.jws_header.get("kid"))
+ _secret = _context.cdb[ca_jwt["iss"]].get("client_secret")
+ if _secret and keys[0].key != as_bytes(_secret):
+ raise AttributeError("Oct key used for signing not client_secret")
+ else:
+ if key_type == "client_secret":
+ raise AttributeError("Wrong key type")
+
+ authtoken = sanitize(ca_jwt.to_dict())
+ logger.debug("authntoken: {}".format(authtoken))
+
+ _endpoint = kwargs.get("endpoint")
+ if _endpoint is None or not _endpoint:
+ if _context.issuer in ca_jwt["aud"]:
+ pass
+ else:
+ raise NotForMe("Not for me!")
+ else:
+ if set(ca_jwt["aud"]).intersection(_endpoint.allowed_target_uris()):
+ pass
+ else:
+ raise NotForMe("Not for me!")
+
+ # If there is a jti use it to make sure one-time usage is true
+ _jti = ca_jwt.get("jti")
+ if _jti:
+ _key = "{}:{}".format(ca_jwt["iss"], _jti)
+ if _key in _context.jti_db:
+ raise MultipleUsage("Have seen this token once before")
+ else:
+ _context.jti_db[_key] = utc_time_sans_frac()
+
+ request[verified_claim_name("client_assertion")] = ca_jwt
+ client_id = kwargs.get("client_id") or ca_jwt["iss"]
+
+ return {"client_id": client_id, "jwt": ca_jwt}
+
+
+class ClientSecretJWT(JWSAuthnMethod):
+ """
+ Clients that have received a client_secret value from the Authorization
+ Server create a JWT using an HMAC SHA algorithm, such as HMAC SHA-256.
+ The HMAC (Hash-based Message Authentication Code) is calculated using the
+ bytes of the UTF-8 representation of the client_secret as the shared key.
+ """
+
+ tag = "client_secret_jwt"
+
+ def verify(self, request=None, **kwargs):
+ res = JWSAuthnMethod.verify(self, request, key_type="client_secret", **kwargs)
+ # Verify that a HS alg was used
+ res["method"] = self.tag
+ return res
+
+
+class PrivateKeyJWT(JWSAuthnMethod):
+ """
+ Clients that have registered a public key sign a JWT using that key.
+ """
+
+ tag = "private_key_jwt"
+
+ def verify(self, request=None, **kwargs):
+ res = JWSAuthnMethod.verify(self, request, key_type="private_key", **kwargs)
+ # Verify that an RS or ES alg was used ?
+ res["method"] = self.tag
+ return res
+
+
+class RequestParam(ClientAuthnMethod):
+ tag = "request_param"
+
+ def is_usable(self, request=None, authorization_info=None):
+ if request and "request" in request:
+ return True
+
+ def verify(self, request=None, **kwargs):
+ _context = self.server_get("endpoint_context")
+ _jwt = JWT(_context.keyjar, msg_cls=JsonWebToken)
+ try:
+ _jwt = _jwt.unpack(request["request"])
+ except (Invalid, MissingKey, BadSignature) as err:
+ logger.info("%s" % sanitize(err))
+ raise AuthnFailure("Could not verify client_assertion.")
+
+ # If there is a jti use it to make sure one-time usage is true
+ _jti = _jwt.get("jti")
+ if _jti:
+ _key = "{}:{}".format(_jwt["iss"], _jti)
+ if _key in _context.jti_db:
+ raise MultipleUsage("Have seen this token once before")
+ else:
+ _context.jti_db[_key] = utc_time_sans_frac()
+
+ request[verified_claim_name("client_assertion")] = _jwt
+ client_id = kwargs.get("client_id") or _jwt["iss"]
+
+ return {"client_id": client_id, "jwt": _jwt}
+
+
+CLIENT_AUTHN_METHOD = {
+ "client_secret_basic": ClientSecretBasic,
+ "client_secret_post": ClientSecretPost,
+ "bearer_header": BearerHeader,
+ "bearer_body": BearerBody,
+ "client_secret_jwt": ClientSecretJWT,
+ "private_key_jwt": PrivateKeyJWT,
+ "request_param": RequestParam,
+ "none": None,
+}
+
+TYPE_METHOD = [(JWT_BEARER, JWSAuthnMethod)]
+
+
+def valid_client_info(cinfo):
+ eta = cinfo.get("client_secret_expires_at", 0)
+ if eta != 0 and eta < utc_time_sans_frac():
+ return False
+ return True
+
+
+def verify_client(
+ endpoint_context: EndpointContext,
+ request: Union[dict, Message],
+ http_info: Optional[dict] = None,
+ get_client_id_from_token: Optional[Callable] = None,
+ endpoint=None, # Optional[Endpoint]
+ also_known_as: Optional[str] = None,
+):
+ """
+ Initiated Guessing !
+
+ :param also_known_as:
+ :param endpoint: Endpoint instance
+ :param endpoint_context: EndpointContext instance
+ :param request: The request
+ :param http_info: Client authentication information
+ :param get_client_id_from_token: Function that based on a token returns a client id.
+ :return: dictionary containing client id, client authentication method and
+ possibly access token.
+ """
+
+ if http_info and "headers" in http_info:
+ authorization_token = http_info["headers"].get("authorization")
+ else:
+ authorization_token = None
+
+ auth_info = {}
+ _methods = getattr(endpoint, 'client_authn_method', [])
+
+ for _method in _methods:
+ if _method is None:
+ continue
+ if _method.is_usable(request, authorization_token):
+ try:
+ auth_info = _method.verify(
+ request=request, authorization_token=authorization_token, endpoint=endpoint,
+ )
+ except Exception as err:
+ logger.warning("Verifying auth using {} failed: {}".format(_method.tag, err))
+ else:
+ if "method" not in auth_info:
+ auth_info["method"] = _method.tag
+ break
+
+ if not auth_info:
+ if None in _methods:
+ auth_info = {"method": "none", "client_id": request.get("client_id")}
+ else:
+ return auth_info
+
+ if also_known_as:
+ client_id = also_known_as[auth_info.get("client_id")]
+ auth_info["client_id"] = client_id
+ else:
+ client_id = auth_info.get("client_id")
+
+ _token = auth_info.get("token")
+
+ if client_id:
+ if client_id not in endpoint_context.cdb:
+ raise UnknownClient("Unknown Client ID")
+
+ _cinfo = endpoint_context.cdb[client_id]
+ if isinstance(_cinfo, str):
+ if _cinfo not in endpoint_context.cdb:
+ raise UnknownClient("Unknown Client ID")
+
+ if not valid_client_info(_cinfo):
+ logger.warning("Client registration has timed out or " "client secret is expired.")
+ raise InvalidClient("Not valid client")
+
+ # store what authn method was used
+ if auth_info.get("method"):
+ _request_type = request.__class__.__name__
+ _used_authn_method = endpoint_context.cdb[client_id].get("auth_method")
+ if _used_authn_method:
+ endpoint_context.cdb[client_id]["auth_method"][_request_type] = auth_info["method"]
+ else:
+ endpoint_context.cdb[client_id]["auth_method"] = {
+ _request_type: auth_info["method"]
+ }
+ elif not client_id and get_client_id_from_token:
+ if not _token:
+ logger.warning("No token")
+ raise ValueError("No token")
+
+ try:
+ # get_client_id_from_token is a callback... Do not abuse for code readability.
+ auth_info["client_id"] = get_client_id_from_token(endpoint_context, _token, request)
+ except KeyError:
+ raise ValueError("Unknown token")
+
+ return auth_info
+
+
+def client_auth_setup(auth_set, server_get):
+ res = []
+
+ for item in auth_set:
+ if item is None or item.lower() == "none":
+ res.append(None)
+ else:
+ _cls = CLIENT_AUTHN_METHOD.get(item)
+ if _cls:
+ res.append(_cls(server_get))
+ else:
+ res.append(importer(item)(server_get))
+
+ return res
diff --git a/src/oidcop/configure.py b/src/oidcop/configure.py
index ac4a05d8..f1bd5c80 100755
--- a/src/oidcop/configure.py
+++ b/src/oidcop/configure.py
@@ -1,81 +1,548 @@
"""Configuration management for IDP"""
-
+import copy
+import importlib
+import json
+import logging
import os
from typing import Dict
+from typing import List
from typing import Optional
-
-from cryptojwt.key_bundle import init_key
-from oidcmsg import add_base_path
+from typing import Union
from oidcop.logging import configure_logging
from oidcop.utils import load_yaml_config
-try:
- from secrets import token_urlsafe as rnd_token
-except ImportError:
- from oidcendpoint import rndstr as rnd_token
-
-DEFAULT_ITEM_PATHS = {
- "webserver": ['server_key', 'server_cert'],
- "op": {
- "server_info": {
- "session_key": ["filename"],
- "template_dir": None,
- "token_handler_args": {
- "jwks_def": ["private_path", "public_path"]
+DEFAULT_FILE_ATTRIBUTE_NAMES = [
+ "server_key",
+ "server_cert",
+ "filename",
+ "template_dir",
+ "private_path",
+ "public_path",
+ "db_file",
+ "jwks_file"
+]
+
+OP_DEFAULT_CONFIG = {
+ "capabilities": {
+ "subject_types_supported": ["public", "pairwise"],
+ "grant_types_supported": [
+ "authorization_code",
+ "implicit",
+ "urn:ietf:params:oauth:grant-type:jwt-bearer",
+ "refresh_token",
+ ],
+ },
+ "cookie_handler": {
+ "class": "oidcop.cookie_handler.CookieHandler",
+ "kwargs": {
+ "keys": {
+ "private_path": "private/cookie_jwks.json",
+ "key_defs": [
+ {"type": "OCT", "use": ["enc"], "kid": "enc"},
+ {"type": "OCT", "use": ["sig"], "kid": "sig"},
+ ],
+ "read_only": False,
},
- "keys": ["private_path", "public_path"],
- "cookie_dealer": {
- "kwargs": {
- "sign_jwk": ["private_path", "public_path"],
- "enc_jwk": ["private_path", "public_path"]
- }
+ "name": {"session": "oidc_op", "register": "oidc_op_rp",
+ "session_management": "sman", },
+ },
+ },
+ "claims_interface": {"class": "oidcop.session.claims.ClaimsInterface", "kwargs": {}},
+ "authz": {
+ "class": "oidcop.authz.AuthzHandling",
+ "kwargs": {
+ "grant_config": {
+ "usage_rules": {
+ "authorization_code": {
+ "supports_minting": ["access_token", "refresh_token", "id_token", ],
+ "max_usage": 1,
+ },
+ "access_token": {},
+ "refresh_token": {"supports_minting": ["access_token", "refresh_token"]},
+ },
+ "expires_in": 43200,
}
- }
- }
+ },
+ },
+ "httpc_params": {"verify": False},
+ "issuer": "https://{domain}:{port}",
+ "template_dir": "templates",
+ "token_handler_args": {
+ "jwks_file": "private/token_jwks.json",
+ "code": {"kwargs": {"lifetime": 600}},
+ "token": {"class": "oidcop.token.jwt_token.JWTToken", "kwargs": {"lifetime": 3600}, },
+ "refresh": {"class": "oidcop.token.jwt_token.JWTToken", "kwargs": {"lifetime": 86400}, },
+ "id_token": {"class": "oidcop.token.id_token.IDToken", "kwargs": {}},
+ },
}
+AS_DEFAULT_CONFIG = copy.deepcopy(OP_DEFAULT_CONFIG)
+AS_DEFAULT_CONFIG["claims_interface"] = {
+ "class": "oidcop.session.claims.OAuth2ClaimsInterface", "kwargs": {}}
+
+
+def add_base_path(conf: Union[dict, str], base_path: str, file_attributes: List[str]):
+ if isinstance(conf, str):
+ if conf.startswith("/"):
+ pass
+ elif conf == "":
+ conf = "./" + conf
+ else:
+ conf = os.path.join(base_path, conf)
+ elif isinstance(conf, dict):
+ for key, val in conf.items():
+ if key in file_attributes:
+ if val.startswith("/"):
+ continue
+ elif val == "":
+ conf[key] = "./" + val
+ else:
+ conf[key] = os.path.join(base_path, val)
+ if isinstance(val, dict):
+ conf[key] = add_base_path(val, base_path, file_attributes)
+
+ return conf
+
+
+URIS = ["issuer", "base_url"]
+
+
+def set_domain_and_port(conf: dict, uris: List[str], domain: str, port: int):
+ for key, val in conf.items():
+ if key in uris:
+ if isinstance(val, list):
+ _new = [v.format(domain=domain, port=port) for v in val]
+ else:
+ _new = val.format(domain=domain, port=port)
+ conf[key] = _new
+ elif isinstance(val, dict):
+ conf[key] = set_domain_and_port(val, uris, domain, port)
+ return conf
+
+
+def create_from_config_file(
+ cls,
+ filename: str,
+ base_path: str = "",
+ entity_conf: Optional[List[dict]] = None,
+ file_attributes: Optional[List[str]] = None,
+ domain: Optional[str] = "",
+ port: Optional[int] = 0,
+):
+ if filename.endswith(".yaml"):
+ """Load configuration as YAML"""
+ _conf = load_yaml_config(filename)
+ elif filename.endswith(".json"):
+ _str = open(filename).read()
+ _conf = json.loads(_str)
+ elif filename.endswith(".py"):
+ head, tail = os.path.split(filename)
+ tail = tail[:-3]
+ module = importlib.import_module(tail)
+ _conf = getattr(module, "OIDCOP_CONFIG")
+ else:
+ raise ValueError("Unknown file type")
+
+ return cls(
+ _conf,
+ entity_conf=entity_conf,
+ base_path=base_path,
+ file_attributes=file_attributes,
+ domain=domain,
+ port=port,
+ )
+
-class Configuration:
- """OP Configuration"""
+class Base:
+ """ Configuration base class """
- def __init__(self, conf: Dict, base_path: str = '', item_paths: Optional[dict] = None) -> None:
- self.logger = configure_logging(config=conf.get('logging')).getChild(__name__)
- self.op = {}
- if item_paths is None:
- item_paths = DEFAULT_ITEM_PATHS
+ parameter = {}
- if base_path and item_paths:
+ def __init__(
+ self, conf: Dict, base_path: str = "", file_attributes: Optional[List[str]] = None,
+ ):
+ if file_attributes is None:
+ file_attributes = DEFAULT_FILE_ATTRIBUTE_NAMES
+
+ if base_path and file_attributes:
# this adds a base path to all paths in the configuration
- add_base_path(conf, item_paths, base_path)
-
- # OIDC provider configuration
- for section in ['op', 'webserver', 'httpc_params', 'jinja_env']:
- setattr(self, section, conf.get(section, {}))
-
- # set OP session key
- _key_args = self.op['server_info'].get('session_key')
- if _key_args is not None:
- self.session_key = init_key(**_key_args)
- # self.op['server_info']['password'] = self.session_key
- self.logger.debug("Set server password to %s", self.session_key)
-
- # templates and Jinja environment
- self.template_dir = os.path.abspath(conf.get('template_dir', 'templates'))
-
- # server info
- self.domain = conf.get("domain")
- self.port = conf.get("port")
- for param in ["server_name", "base_url"]:
- _pre = conf.get(param)
- if _pre:
- if '{domain}' in _pre:
- setattr(self, param, _pre.format(domain=self.domain, port=self.port))
+ add_base_path(conf, base_path, file_attributes)
+
+ def __getitem__(self, item):
+ if item in self.__dict__:
+ return self.__dict__[item]
+ else:
+ raise KeyError
+
+ def get(self, item, default=None):
+ return getattr(self, item, default)
+
+ def __contains__(self, item):
+ return item in self.__dict__
+
+ def items(self):
+ for key in self.__dict__:
+ if key.startswith("__") and key.endswith("__"):
+ continue
+ yield key, getattr(self, key)
+
+
+class EntityConfiguration(Base):
+ default_config = AS_DEFAULT_CONFIG
+
+ def __init__(
+ self,
+ conf: Dict,
+ base_path: Optional[str] = "",
+ entity_conf: Optional[List[dict]] = None,
+ domain: Optional[str] = "",
+ port: Optional[int] = 0,
+ file_attributes: Optional[List[str]] = None,
+ ):
+
+ conf = copy.deepcopy(conf)
+ Base.__init__(self, conf, base_path, file_attributes)
+
+ self.add_on = None
+ self.authz = None
+ self.authentication = None
+ self.base_url = ""
+ self.capabilities = None
+ self.claims_interface = None
+ self.cookie_handler = None
+ self.endpoint = {}
+ self.httpc_params = {}
+ self.issuer = ""
+ self.keys = None
+ self.template_dir = None
+ self.token_handler_args = {}
+ self.userinfo = None
+ self.session_params = None
+
+ if file_attributes is None:
+ file_attributes = DEFAULT_FILE_ATTRIBUTE_NAMES
+
+ for key in self.__dict__.keys():
+ _val = conf.get(key)
+ if not _val:
+ if key in self.default_config:
+ _dc = copy.deepcopy(self.default_config[key])
+ add_base_path(_dc, base_path, file_attributes)
+ _val = _dc
else:
- setattr(self, param, _pre)
+ continue
+ setattr(self, key, _val)
- @classmethod
- def create_from_config_file(cls, filename: str, base_path: str = '',
- item_paths: Optional[dict] = None):
- """Load configuration as YAML"""
- return cls(load_yaml_config(filename), base_path, item_paths)
+ if self.template_dir is None:
+ self.template_dir = os.path.abspath("templates")
+ else:
+ self.template_dir = os.path.abspath(self.template_dir)
+
+ if not domain:
+ domain = conf.get("domain", "127.0.0.1")
+
+ if not port:
+ port = conf.get("port", 80)
+
+ set_domain_and_port(conf, URIS, domain=domain, port=port)
+
+
+class OPConfiguration(EntityConfiguration):
+ "Provider configuration"
+ default_config = OP_DEFAULT_CONFIG
+
+ def __init__(
+ self,
+ conf: Dict,
+ base_path: Optional[str] = "",
+ entity_conf: Optional[List[dict]] = None,
+ domain: Optional[str] = "",
+ port: Optional[int] = 0,
+ file_attributes: Optional[List[str]] = None,
+ ):
+ # OP special
+ self.id_token = None
+ self.login_hint2acrs = {}
+ self.login_hint_lookup = None
+
+ EntityConfiguration.__init__(self, conf=conf, base_path=base_path,
+ entity_conf=entity_conf, domain=domain, port=port,
+ file_attributes=file_attributes)
+
+
+class ASConfiguration(EntityConfiguration):
+ "Authorization server configuration"
+
+ def __init__(
+ self,
+ conf: Dict,
+ base_path: Optional[str] = "",
+ entity_conf: Optional[List[dict]] = None,
+ domain: Optional[str] = "",
+ port: Optional[int] = 0,
+ file_attributes: Optional[List[str]] = None,
+ ):
+ EntityConfiguration.__init__(self, conf=conf, base_path=base_path,
+ entity_conf=entity_conf, domain=domain, port=port,
+ file_attributes=file_attributes)
+
+
+class Configuration(Base):
+ """Server Configuration"""
+
+ def __init__(
+ self,
+ conf: Dict,
+ entity_conf: Optional[List[dict]] = None,
+ base_path: str = "",
+ file_attributes: Optional[List[str]] = None,
+ domain: Optional[str] = "",
+ port: Optional[int] = 0,
+ ):
+ Base.__init__(self, conf, base_path, file_attributes)
+
+ log_conf = conf.get("logging")
+ if log_conf:
+ self.logger = configure_logging(config=log_conf).getChild(__name__)
+ else:
+ self.logger = logging.getLogger("oidcop")
+
+ self.webserver = conf.get("webserver", {})
+
+ if not domain:
+ domain = conf.get("domain", "127.0.0.1")
+
+ if not port:
+ port = conf.get("port", 80)
+
+ set_domain_and_port(conf, URIS, domain=domain, port=port)
+
+ if entity_conf:
+ for econf in entity_conf:
+ _path = econf.get("path")
+ _cnf = conf
+ if _path:
+ for step in _path:
+ _cnf = _cnf[step]
+ _attr = econf["attr"]
+ _cls = econf["class"]
+ setattr(
+ self,
+ _attr,
+ _cls(
+ _cnf,
+ base_path=base_path,
+ file_attributes=file_attributes,
+ domain=domain,
+ port=port,
+ ),
+ )
+
+
+DEFAULT_EXTENDED_CONF = {
+ "add_on": {
+ "pkce": {
+ "function": "oidcop.oidc.add_on.pkce.add_pkce_support",
+ "kwargs": {"essential": False, "code_challenge_method": "S256 S384 S512"},
+ },
+ "claims": {
+ "function": "oidcop.oidc.add_on.custom_scopes.add_custom_scopes",
+ "kwargs": {
+ "research_and_scholarship": [
+ "name",
+ "given_name",
+ "family_name",
+ "email",
+ "email_verified",
+ "sub",
+ "iss",
+ "eduperson_scoped_affiliation",
+ ]
+ },
+ },
+ },
+ "authz": {
+ "class": "oidcop.authz.AuthzHandling",
+ "kwargs": {
+ "grant_config": {
+ "usage_rules": {
+ "authorization_code": {
+ "supports_minting": ["access_token", "refresh_token", "id_token", ],
+ "max_usage": 1,
+ },
+ "access_token": {},
+ "refresh_token": {"supports_minting": ["access_token", "refresh_token"]},
+ },
+ "expires_in": 43200,
+ }
+ },
+ },
+ "authentication": {
+ "user": {
+ "acr": "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword",
+ "class": "oidcop.user_authn.user.UserPassJinja2",
+ "kwargs": {
+ "verify_endpoint": "verify/user",
+ "template": "user_pass.jinja2",
+ "db": {"class": "oidcop.util.JSONDictDB", "kwargs": {"filename": "passwd.json"}, },
+ "page_header": "Testing log in",
+ "submit_btn": "Get me in!",
+ "user_label": "Nickname",
+ "passwd_label": "Secret sauce",
+ },
+ }
+ },
+ "capabilities": {
+ "subject_types_supported": ["public", "pairwise"],
+ "grant_types_supported": [
+ "authorization_code",
+ "implicit",
+ "urn:ietf:params:oauth:grant-type:jwt-bearer",
+ "refresh_token",
+ ],
+ },
+ "cookie_handler": {
+ "class": "oidcop.cookie_handler.CookieHandler",
+ "kwargs": {
+ "keys": {
+ "private_path": "private/cookie_jwks.json",
+ "key_defs": [
+ {"type": "OCT", "use": ["enc"], "kid": "enc"},
+ {"type": "OCT", "use": ["sig"], "kid": "sig"},
+ ],
+ "read_only": False,
+ },
+ "name": {"session": "oidc_op", "register": "oidc_op_rp",
+ "session_management": "sman", },
+ },
+ },
+ "endpoint": {
+ "webfinger": {
+ "path": ".well-known/webfinger",
+ "class": "oidcop.oidc.discovery.Discovery",
+ "kwargs": {"client_authn_method": None},
+ },
+ "provider_info": {
+ "path": ".well-known/openid-configuration",
+ "class": "oidcop.oidc.provider_config.ProviderConfiguration",
+ "kwargs": {"client_authn_method": None},
+ },
+ "registration": {
+ "path": "registration",
+ "class": "oidcop.oidc.registration.Registration",
+ "kwargs": {"client_authn_method": None, "client_secret_expiration_time": 432000, },
+ },
+ "registration_api": {
+ "path": "registration_api",
+ "class": "oidcop.oidc.read_registration.RegistrationRead",
+ "kwargs": {"client_authn_method": ["bearer_header"]},
+ },
+ "introspection": {
+ "path": "introspection",
+ "class": "oidcop.oauth2.introspection.Introspection",
+ "kwargs": {"client_authn_method": ["client_secret_post"], "release": ["username"], },
+ },
+ "authorization": {
+ "path": "authorization",
+ "class": "oidcop.oidc.authorization.Authorization",
+ "kwargs": {
+ "client_authn_method": None,
+ "claims_parameter_supported": True,
+ "request_parameter_supported": True,
+ "request_uri_parameter_supported": True,
+ "response_types_supported": [
+ "code",
+ "token",
+ "id_token",
+ "code token",
+ "code id_token",
+ "id_token token",
+ "code id_token token",
+ # "none"
+ ],
+ "response_modes_supported": ["query", "fragment", "form_post"],
+ },
+ },
+ "token": {
+ "path": "token",
+ "class": "oidcop.oidc.token.Token",
+ "kwargs": {
+ "client_authn_method": [
+ "client_secret_post",
+ "client_secret_basic",
+ "client_secret_jwt",
+ "private_key_jwt",
+ ]
+ },
+ },
+ "userinfo": {
+ "path": "userinfo",
+ "class": "oidcop.oidc.userinfo.UserInfo",
+ "kwargs": {"claim_types_supported": ["normal", "aggregated", "distributed"]},
+ },
+ "end_session": {
+ "path": "session",
+ "class": "oidcop.oidc.session.Session",
+ "kwargs": {
+ "logout_verify_url": "verify_logout",
+ "post_logout_uri_path": "post_logout",
+ "signing_alg": "ES256",
+ "frontchannel_logout_supported": True,
+ "frontchannel_logout_session_supported": True,
+ "backchannel_logout_supported": True,
+ "backchannel_logout_session_supported": True,
+ "check_session_iframe": "check_session_iframe",
+ },
+ },
+ },
+ "httpc_params": {"verify": False},
+ "issuer": "https://{domain}:{port}",
+ "keys": {
+ "private_path": "private/jwks.json",
+ "key_defs": [
+ {"type": "RSA", "use": ["sig"]},
+ {"type": "EC", "crv": "P-256", "use": ["sig"]},
+ ],
+ "public_path": "static/jwks.json",
+ "read_only": False,
+ "uri_path": "static/jwks.json",
+ },
+ "login_hint2acrs": {
+ "class": "oidcop.login_hint.LoginHint2Acrs",
+ "kwargs": {
+ "scheme_map": {"email": ["urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword"]}
+ },
+ },
+ "template_dir": "templates",
+ "token_handler_args": {
+ "jwks_def": {
+ "private_path": "private/token_jwks.json",
+ "read_only": False,
+ "key_defs": [{"type": "oct", "bytes": "24", "use": ["enc"], "kid": "code"}],
+ },
+ "code": {"kwargs": {"lifetime": 600}},
+ "token": {
+ "class": "oidcop.token.jwt_token.JWTToken",
+ "kwargs": {
+ "lifetime": 3600,
+ "add_claims_by_scope": True,
+ "aud": ["https://example.org/appl"],
+ },
+ },
+ "refresh": {
+ "class": "oidcop.token.jwt_token.JWTToken",
+ "kwargs": {"lifetime": 3600, "aud": ["https://example.org/appl"], },
+ },
+ "id_token": {
+ "class": "oidcop.token.id_token.IDToken",
+ "kwargs": {
+ "base_claims": {
+ "email": {"essential": True},
+ "email_verified": {"essential": True},
+ }
+ },
+ },
+ },
+ "userinfo": {"class": "oidcop.user_info.UserInfo", "kwargs": {"db_file": "users.json"}, },
+}
diff --git a/src/oidcop/constant.py b/src/oidcop/constant.py
new file mode 100644
index 00000000..c0527b74
--- /dev/null
+++ b/src/oidcop/constant.py
@@ -0,0 +1 @@
+DIVIDER = ";;"
diff --git a/src/oidcop/construct.py b/src/oidcop/construct.py
new file mode 100644
index 00000000..3ef728f7
--- /dev/null
+++ b/src/oidcop/construct.py
@@ -0,0 +1,86 @@
+import logging
+import re
+
+from functools import cmp_to_key
+
+from cryptojwt import jwe
+from cryptojwt.jws.jws import SIGNER_ALGS
+
+ALG_SORT_ORDER = {"RS": 0, "ES": 1, "HS": 2, "PS": 3, "no": 4}
+WEAK_ALGS = ["RSA1_5", "none"]
+
+logger = logging.getLogger(__name__)
+
+
+def sort_sign_alg(alg1, alg2):
+ if ALG_SORT_ORDER[alg1[0:2]] < ALG_SORT_ORDER[alg2[0:2]]:
+ return -1
+
+ if ALG_SORT_ORDER[alg1[0:2]] > ALG_SORT_ORDER[alg2[0:2]]:
+ return 1
+
+ if alg1 < alg2:
+ return -1
+
+ if alg1 > alg2:
+ return 1
+
+ return 0
+
+
+def assign_algorithms(typ):
+ if typ == "signing_alg":
+ # Pick supported signing algorithms from crypto library
+ # Sort order RS, ES, HS, PS
+ sign_algs = list(SIGNER_ALGS.keys())
+ return sorted(sign_algs, key=cmp_to_key(sort_sign_alg))
+ elif typ == "encryption_alg":
+ return jwe.SUPPORTED["alg"]
+ elif typ == "encryption_enc":
+ return jwe.SUPPORTED["enc"]
+
+
+def construct_endpoint_info(default_capabilities, **kwargs):
+ if default_capabilities is None:
+ return default_capabilities
+
+ _info = {}
+ for attr, default_val in default_capabilities.items():
+ if attr in kwargs:
+ _proposal = kwargs[attr]
+ _permitted = None
+
+ if "signing_alg_values_supported" in attr:
+ _permitted = set(assign_algorithms("signing_alg"))
+ elif "encryption_alg_values_supported" in attr:
+ _permitted = set(assign_algorithms("encryption_alg"))
+ elif "encryption_enc_values_supported" in attr:
+ _permitted = set(assign_algorithms("encryption_enc"))
+
+ if _permitted and not _permitted.issuperset(set(_proposal)):
+ raise ValueError(
+ "Proposed set of values outside set of permitted, "
+ f"'{attr}' sould be {_permitted} it's instead {_proposal}"
+ )
+ _info[attr] = _proposal
+ else:
+ if default_val is not None:
+ _info[attr] = default_val
+ elif "signing_alg_values_supported" in attr:
+ _info[attr] = assign_algorithms("signing_alg")
+ if "none" in _info[attr]:
+ _info[attr].remove("none")
+ elif "encryption_alg_values_supported" in attr:
+ # RSA1_5 not among defaults
+ _info[attr] = [s for s in assign_algorithms("encryption_alg") if s not in WEAK_ALGS]
+ elif "encryption_enc_values_supported" in attr:
+ _info[attr] = assign_algorithms("encryption_enc")
+
+ if re.match(r".*(alg|enc).*_values_supported", attr):
+ for i in _info[attr]:
+ if i in WEAK_ALGS:
+ logger.warning(
+ f"Found {i} in {attr}. This is a weak algorithm "
+ "that MUST not be used in production!"
+ )
+ return _info
diff --git a/src/oidcop/cookie_handler.py b/src/oidcop/cookie_handler.py
new file mode 100755
index 00000000..4524c94f
--- /dev/null
+++ b/src/oidcop/cookie_handler.py
@@ -0,0 +1,261 @@
+import base64
+import hashlib
+import logging
+import os
+import time
+from typing import List
+from typing import Optional
+from typing import Union
+from urllib.parse import urlparse
+
+from cryptography.exceptions import InvalidTag
+from cryptojwt.exception import VerificationError
+from cryptojwt.jwe.aes import AES_GCMEncrypter
+from cryptojwt.jwe.utils import split_ctx_and_tag
+from cryptojwt.jwk.hmac import SYMKey
+from cryptojwt.jws.hmac import HMACSigner
+from cryptojwt.key_jar import init_key_jar
+from oidcmsg.time_util import epoch_in_a_while
+
+from oidcop.util import lv_pack
+from oidcop.util import lv_unpack
+
+__author__ = "Roland Hedberg"
+
+LOGGER = logging.getLogger(__name__)
+
+
+# The only thing I want to be able to do is to set names, values, expires and max-age on cookies.
+# I don't care about the remaining attributes of a cookie.
+
+
+class CookieHandler:
+ def __init__(
+ self,
+ sign_key: Optional[SYMKey] = None,
+ enc_key: Optional[SYMKey] = None,
+ keys: Optional[dict] = None,
+ sign_alg: [str] = "SHA256",
+ name: Optional[dict] = None,
+ ):
+
+ if keys:
+ key_jar = init_key_jar(**keys)
+ _keys = key_jar.get_signing_key(key_type="oct", kid="sig")
+ if _keys:
+ self.sign_key = _keys[0]
+ _keys = key_jar.get_encrypt_key(key_type="oct", kid="enc")
+ if _keys:
+ self.enc_key = _keys[0]
+ else:
+ if sign_key:
+ if isinstance(sign_key, SYMKey):
+ self.sign_key = sign_key
+ else:
+ self.sign_key = SYMKey(k=sign_key)
+ else:
+ self.sign_key = None
+
+ if enc_key:
+ if isinstance(enc_key, SYMKey):
+ self.enc_key = enc_key
+ else:
+ self.enc_key = SYMKey(k=enc_key)
+ else:
+ self.enc_key = None
+
+ self.sign_alg = sign_alg
+
+ self.time_format = "%a, %d-%b-%Y %H:%M:%S GMT"
+
+ if name is None:
+ self.name = {
+ "session": "oidc_op",
+ "register": "oidc_op_reg",
+ "session_management": "oidc_op_sman",
+ }
+ else:
+ self.name = name
+
+ def _sign_enc_payload(self, payload: str, timestamp: Optional[Union[int, str]] = 0):
+ """
+ Creates signed and/or encrypted information.
+
+ :param load: The basic information in the payload
+ :param timestamp: A timestamp (seconds since epoch)
+ :return: Signed and/or encrypted payload
+ """
+
+ # Just sign, sign and encrypt or just encrypt
+
+ if timestamp:
+ timestamp = str(timestamp)
+ else:
+ timestamp = str(int(time.time()))
+
+ bytes_load = payload.encode("utf-8")
+ bytes_timestamp = timestamp.encode("utf-8")
+
+ if self.sign_key:
+ signer = HMACSigner(algorithm=self.sign_alg)
+ mac = signer.sign(bytes_load + bytes_timestamp, self.sign_key.key)
+ else:
+ mac = b""
+
+ if self.enc_key:
+ if len(self.enc_key.key) not in [16, 24, 32]:
+ raise ValueError("Wrong size of enc_key")
+
+ encrypter = AES_GCMEncrypter(key=self.enc_key.key)
+ iv = os.urandom(12)
+ if mac:
+ msg = lv_pack(payload, timestamp, base64.b64encode(mac).decode("utf-8"))
+ else:
+ msg = lv_pack(payload, timestamp)
+
+ enc_msg = encrypter.encrypt(msg.encode("utf-8"), iv)
+ ctx, tag = split_ctx_and_tag(enc_msg)
+
+ cookie_payload = [
+ bytes_timestamp,
+ base64.b64encode(iv),
+ base64.b64encode(ctx),
+ base64.b64encode(tag),
+ ]
+ else:
+ cookie_payload = [bytes_timestamp, bytes_load, base64.b64encode(mac)]
+
+ return (b"|".join(cookie_payload)).decode("utf-8")
+
+ def _ver_dec_content(self, parts):
+ """
+ Verifies the value of a cookie
+
+ :param parts: The parts of the payload
+ :return: A tuple with basic information and a timestamp
+ """
+
+ if parts is None:
+ return None
+ elif len(parts) == 3:
+ # verify the cookie signature
+ timestamp, payload, b64_mac = parts
+ mac = base64.b64decode(b64_mac)
+ verifier = HMACSigner(algorithm=self.sign_alg)
+ if verifier.verify(
+ payload.encode("utf-8") + timestamp.encode("utf-8"), mac, self.sign_key.key,
+ ):
+ return payload, timestamp
+ else:
+ raise VerificationError()
+ elif len(parts) == 4:
+ iv = base64.b64decode(parts[1])
+ ciphertext = base64.b64decode(parts[2])
+ tag = base64.b64decode(parts[3])
+
+ decrypter = AES_GCMEncrypter(key=self.enc_key.key)
+ try:
+ msg = decrypter.decrypt(ciphertext, iv, tag=tag)
+ except InvalidTag:
+ return None
+
+ p = lv_unpack(msg.decode("utf-8"))
+ payload = p[0]
+ timestamp = p[1]
+ if len(p) == 3:
+ verifier = HMACSigner(algorithm=self.sign_alg)
+ if verifier.verify(
+ payload.encode("utf-8") + timestamp.encode("utf-8"),
+ base64.b64decode(p[2]),
+ self.sign_key.key,
+ ):
+ return payload, timestamp
+ else:
+ return payload, timestamp
+ return None
+
+ def make_cookie_content(
+ self,
+ name: str,
+ value: str,
+ typ: Optional[str] = "",
+ timestamp: Optional[Union[int, str]] = "",
+ max_age: Optional[int] = 0,
+ **kwargs
+ ) -> dict:
+ """
+ Create and return information to put in a cookie
+
+ :param typ: The type of cookie
+ :param name: Cookie name
+ :param value: Cookie value
+ :param timestamp: A time stamp
+ :param max_age: The time in seconds for when a cookie will be deleted
+ :return: A dictionary
+ """
+
+ if not timestamp:
+ timestamp = str(int(time.time()))
+
+ # create cookie payload
+ if not value and not typ:
+ _cookie_value = ""
+ else:
+ try:
+ cookie_payload = "::".join([value, typ])
+ except TypeError:
+ cookie_payload = "::".join([value[0], typ])
+
+ _cookie_value = self._sign_enc_payload(cookie_payload, timestamp)
+
+ content = {"name": name, "value": _cookie_value}
+
+ if max_age == -1:
+ content["Expires"] = "Thu, 01 Jan 1970 00:00:00 GMT;"
+ elif max_age:
+ content["Max-Age"] = epoch_in_a_while(seconds=max_age)
+
+ return content
+
+ def parse_cookie(self, name: str, cookies: List[dict]) -> Optional[List[dict]]:
+ """Parses and verifies a cookie value
+
+ Parses a cookie created by `make_cookie` and verifies
+ it has not been tampered with.
+
+ You need to provide the same `sign_key` and `enc_key`
+ used when creating the cookie, otherwise the verification
+ fails. See `make_cookie` for details about the verification.
+
+ :param kakor: A list of dictionaries with cookie information
+ :raises InvalidCookieSign: When verification fails.
+ :return: A list of dictionaries with information from the cookie or None if parsing fails
+ """
+ if not cookies:
+ return None
+
+ res = []
+ for _cookie in cookies:
+ if _cookie["name"] == name:
+ payload, timestamp = self._ver_dec_content(_cookie["value"].split("|"))
+ value, typ = payload.split("::")
+ res.append({"value": value, "type": typ, "timestamp": timestamp})
+ return res
+
+
+def compute_session_state(opbs, salt, client_id, redirect_uri):
+ """
+ Computes a session state value.
+ This value is later used during session management to check whether
+ the log in state has changed.
+
+ :param opbs: Cookie value
+ :param salt:
+ :param client_id:
+ :param redirect_uri:
+ :return: Session state value
+ """
+ parsed_uri = urlparse(redirect_uri)
+ rp_origin_url = "{uri.scheme}://{uri.netloc}".format(uri=parsed_uri)
+ session_str = client_id + " " + rp_origin_url + " " + opbs + " " + salt
+ return hashlib.sha256(session_str.encode("utf-8")).hexdigest() + "." + salt
diff --git a/src/oidcop/endpoint.py b/src/oidcop/endpoint.py
new file mode 100755
index 00000000..3152cbb3
--- /dev/null
+++ b/src/oidcop/endpoint.py
@@ -0,0 +1,418 @@
+import logging
+from typing import Callable
+from typing import List
+from typing import Optional
+from typing import Union
+from urllib.parse import urlparse
+
+from oidcmsg.exception import MissingRequiredAttribute
+from oidcmsg.exception import MissingRequiredValue
+from oidcmsg.message import Message
+from oidcmsg.oauth2 import ResponseMessage
+
+from oidcop import sanitize
+from oidcop.client_authn import client_auth_setup
+from oidcop.client_authn import verify_client
+from oidcop.construct import construct_endpoint_info
+from oidcop.endpoint_context import EndpointContext
+from oidcop.exception import UnAuthorizedClient
+from oidcop.util import OAUTH2_NOCACHE_HEADERS
+
+__author__ = "Roland Hedberg"
+
+LOGGER = logging.getLogger(__name__)
+
+"""
+method call structure for Endpoints:
+
+parse_request
+ - client_authentication (*)
+ - post_parse_request (*)
+
+process_request
+
+do_response
+ - response_info
+ - construct
+ - pre_construct (*)
+ - _parse_args
+ - post_construct (*)
+ - update_http_args
+
+do_response returns a dictionary that can look like this::
+
+ {
+ 'response':
+ _response as a string or as a Message instance_
+ 'http_headers': [
+ ('Content-type', 'application/json'),
+ ('Pragma', 'no-cache'),
+ ('Cache-Control', 'no-store')
+ ],
+ 'cookie': _list of cookies_,
+ 'response_placement': 'body'
+ }
+
+"response" MUST be present
+"http_headers" MAY be present
+"cookie": MAY be present
+"response_placement": If absent defaults to the endpoints response_placement
+parameter value or if that is also missing 'url'
+"""
+
+
+def set_content_type(headers, content_type):
+ if ("Content-type", content_type) in headers:
+ return headers
+
+ _headers = [h for h in headers if h[0] != "Content-type"]
+ _headers.append(("Content-type", content_type))
+ return _headers
+
+
+def fragment_encoding(return_type):
+ if return_type == ["code"]:
+ return False
+ else:
+ return True
+
+
+class Endpoint(object):
+ request_cls = Message
+ response_cls = Message
+ error_cls = ResponseMessage
+ endpoint_name = ""
+ endpoint_path = ""
+ name = ""
+ request_format = "urlencoded"
+ request_placement = "query"
+ response_format = "json"
+ response_placement = "body"
+ client_authn_method = ""
+ default_capabilities = None
+
+ def __init__(self, server_get: Callable, **kwargs):
+ self.server_get = server_get
+ self.pre_construct = []
+ self.post_construct = []
+ self.post_parse_request = []
+ self.kwargs = kwargs
+ self.full_path = ""
+
+ for param in [
+ "request_cls",
+ "response_cls",
+ "request_format",
+ "request_placement",
+ "response_format",
+ "response_placement",
+ ]:
+ _val = kwargs.get(param)
+ if _val:
+ setattr(self, param, _val)
+
+ _methods = kwargs.get("client_authn_method")
+ self.client_authn_method = []
+ if _methods:
+ self.client_authn_method = client_auth_setup(_methods, server_get)
+ elif _methods is not None: # [] or '' or something not None but regarded as nothing.
+ self.client_authn_method = [None] # Ignore default value
+ elif self.default_capabilities:
+ _methods = self.default_capabilities.get("client_authn_method")
+ if _methods:
+ self.client_authn_method = client_auth_setup(_methods, server_get)
+ self.endpoint_info = construct_endpoint_info(self.default_capabilities, **kwargs)
+
+ # This is for matching against aud in JWTs
+ # By default the endpoint's endpoint URL is an allowed target
+ self.allowed_targets = [self.name]
+ self.client_verification_method = []
+
+ def parse_cookies(self, cookies: List[dict], context: EndpointContext, name: str):
+ res = context.cookie_handler.parse_cookie(name, cookies)
+ return res
+
+ def parse_request(
+ self, request: Union[Message, dict, str], http_info: Optional[dict] = None, **kwargs
+ ):
+ """
+
+ :param request: The request the server got
+ :param http_info: HTTP information in connection with the request.
+ This is a dictionary with keys: headers, url, cookies.
+ :param kwargs: extra keyword arguments
+ :return:
+ """
+ LOGGER.debug("- {} -".format(self.endpoint_name))
+ LOGGER.info("Request: %s" % sanitize(request))
+
+ _context = self.server_get("endpoint_context")
+
+ if http_info is None:
+ http_info = {}
+
+ if request:
+ if isinstance(request, (dict, Message)):
+ req = self.request_cls(**request)
+ else:
+ _cls_inst = self.request_cls()
+ if self.request_format == "jwt":
+ req = _cls_inst.deserialize(
+ request,
+ "jwt",
+ keyjar=_context.keyjar,
+ verify=_context.httpc_params["verify"],
+ **kwargs
+ )
+ elif self.request_format == "url":
+ parts = urlparse(request)
+ scheme, netloc, path, params, query, fragment = parts[:6]
+ req = _cls_inst.deserialize(query, "urlencoded")
+ else:
+ req = _cls_inst.deserialize(request, self.request_format)
+ else:
+ req = self.request_cls()
+
+ # Verify that the client is allowed to do this
+ _client_id = ""
+ auth_info = self.client_authentication(req, http_info, endpoint=self, **kwargs)
+
+ if "client_id" in auth_info:
+ req["client_id"] = auth_info["client_id"]
+ _client_id = auth_info["client_id"]
+ else:
+ _client_id = req.get("client_id")
+
+ keyjar = _context.keyjar
+
+ # verify that the request message is correct
+ try:
+ req.verify(keyjar=keyjar, opponent_id=_client_id)
+ except (MissingRequiredAttribute, ValueError, MissingRequiredValue) as err:
+ return self.error_cls(error="invalid_request", error_description="%s" % err)
+
+ LOGGER.info("Parsed and verified request: %s" % sanitize(req))
+
+ # Do any endpoint specific parsing
+ return self.do_post_parse_request(request=req, client_id=_client_id, http_info=http_info,
+ **kwargs)
+
+ def get_client_id_from_token(
+ self,
+ endpoint_context: EndpointContext,
+ token: str,
+ request: Optional[Union[Message, dict]] = None,
+ ):
+ return ""
+
+ def client_authentication(self, request: Message, http_info: Optional[dict] = None, **kwargs):
+ """
+ Do client authentication
+
+ :param request: Parsed request, a self.request_cls class instance
+ :param http_info: HTTP headers, URL used and cookies.
+ :return: client_id or raise an exception
+ """
+
+ if "endpoint" not in kwargs:
+ kwargs["endpoint"] = self
+
+ authn_info = verify_client(
+ endpoint_context=self.server_get("endpoint_context"),
+ request=request,
+ http_info=http_info,
+ get_client_id_from_token=self.get_client_id_from_token,
+ **kwargs
+ )
+
+ LOGGER.debug("authn_info: %s", authn_info)
+ if authn_info == {} and self.client_authn_method and len(self.client_authn_method):
+ LOGGER.debug("client_authn_method: %s", self.client_authn_method)
+ raise UnAuthorizedClient("Authorization failed")
+
+ return authn_info
+
+ def do_post_parse_request(
+ self, request: Message, client_id: Optional[str] = "", **kwargs
+ ) -> Message:
+ _context = self.server_get("endpoint_context")
+ for meth in self.post_parse_request:
+ if isinstance(request, self.error_cls):
+ break
+ request = meth(request, client_id, endpoint_context=_context, **kwargs)
+ return request
+
+ def do_pre_construct(
+ self, response_args: dict, request: Optional[Union[Message, dict]] = None, **kwargs
+ ) -> dict:
+ _context = self.server_get("endpoint_context")
+ for meth in self.pre_construct:
+ response_args = meth(response_args, request, endpoint_context=_context, **kwargs)
+
+ return response_args
+
+ def do_post_construct(
+ self,
+ response_args: Union[Message, dict],
+ request: Optional[Union[Message, dict]] = None,
+ **kwargs
+ ) -> dict:
+ _context = self.server_get("endpoint_context")
+ for meth in self.post_construct:
+ response_args = meth(response_args, request, endpoint_context=_context, **kwargs)
+
+ return response_args
+
+ def process_request(
+ self,
+ request: Optional[Union[Message, dict]] = None,
+ http_info: Optional[dict] = None,
+ **kwargs
+ ):
+ """
+
+ :param http_info: Information on the HTTP request
+ :param request: The request, can be in a number of formats
+ :return: Arguments for the do_response method
+ """
+ return {}
+
+ def construct(
+ self,
+ response_args: Optional[dict] = None,
+ request: Optional[Union[Message, dict]] = None,
+ **kwargs
+ ):
+ """
+ Construct the response
+
+ :param response_args: response arguments
+ :param request: The parsed request, a self.request_cls class instance
+ :param kwargs: Extra keyword arguments
+ :return: An instance of the self.response_cls class
+ """
+ response_args = self.do_pre_construct(response_args, request, **kwargs)
+
+ # LOGGER.debug("kwargs: %s" % sanitize(kwargs))
+ response = self.response_cls(**response_args)
+
+ return self.do_post_construct(response, request, **kwargs)
+
+ def response_info(
+ self,
+ response_args: Optional[dict] = None,
+ request: Optional[Union[Message, dict]] = None,
+ **kwargs
+ ) -> dict:
+ return self.construct(response_args, request, **kwargs)
+
+ def do_response(
+ self,
+ response_args: Optional[dict] = None,
+ request: Optional[Union[Message, dict]] = None,
+ error: Optional[str] = "",
+ **kwargs
+ ) -> dict:
+ """
+ :param response_args: Information to use when constructing the response
+ :param request: The original request
+ :param error: Possible error encountered while processing the request
+ """
+ do_placement = True
+ content_type = "text/html"
+ _resp = {}
+ _response_placement = None
+ if response_args is None:
+ response_args = {}
+
+ LOGGER.debug("do_response kwargs: %s", kwargs)
+
+ resp = None
+ if error:
+ _response = ResponseMessage(error=error)
+ try:
+ _response["error_description"] = kwargs["error_description"]
+ except KeyError:
+ pass
+ elif "response_msg" in kwargs:
+ resp = kwargs["response_msg"]
+ _response_placement = kwargs.get("response_placement")
+ do_placement = False
+ _response = ""
+ content_type = kwargs.get("content_type")
+ if content_type is None:
+ if self.response_format == "json":
+ content_type = "application/json"
+ elif self.response_format in ["jws", "jwe", "jose"]:
+ content_type = "application/jose"
+ else:
+ content_type = "application/x-www-form-urlencoded"
+ else:
+ _response = self.response_info(response_args, request, **kwargs)
+
+ if do_placement:
+ content_type = kwargs.get("content_type")
+ if content_type is None:
+ if self.response_placement == "body":
+ if self.response_format == "json":
+ content_type = "application/json; charset=utf-8"
+ resp = _response.to_json()
+ elif self.response_format in ["jws", "jwe", "jose"]:
+ content_type = "application/jose; charset=utf-8"
+ resp = _response
+ else:
+ content_type = "application/x-www-form-urlencoded"
+ resp = _response.to_urlencoded()
+ elif self.response_placement == "url":
+ content_type = "application/x-www-form-urlencoded"
+ fragment_enc = kwargs.get("fragment_enc")
+ if not fragment_enc:
+ _ret_type = kwargs.get("return_type")
+ if _ret_type:
+ fragment_enc = fragment_encoding(_ret_type)
+ else:
+ fragment_enc = False
+
+ if fragment_enc:
+ resp = _response.request(kwargs["return_uri"], True)
+ else:
+ resp = _response.request(kwargs["return_uri"])
+ else:
+ raise ValueError(
+ "Don't know where that is: '{}".format(self.response_placement)
+ )
+
+ if content_type:
+ try:
+ http_headers = set_content_type(kwargs["http_headers"], content_type)
+ except KeyError:
+ http_headers = [("Content-type", content_type)]
+ else:
+ try:
+ http_headers = kwargs["http_headers"]
+ except KeyError:
+ http_headers = []
+
+ if _response_placement:
+ _resp["response_placement"] = _response_placement
+
+ http_headers.extend(OAUTH2_NOCACHE_HEADERS)
+
+ _resp.update({"response": resp, "http_headers": http_headers})
+
+ try:
+ _resp["cookie"] = kwargs["cookie"]
+ except KeyError:
+ pass
+
+ return _resp
+
+ def allowed_target_uris(self):
+ res = []
+ _context = self.server_get("endpoint_context")
+ for t in self.allowed_targets:
+ if t == "":
+ res.append(_context.issuer)
+ else:
+ res.append(self.server_get("endpoint", t).full_path)
+ return set(res)
diff --git a/src/oidcop/endpoint_context.py b/src/oidcop/endpoint_context.py
new file mode 100755
index 00000000..e68cc99c
--- /dev/null
+++ b/src/oidcop/endpoint_context.py
@@ -0,0 +1,324 @@
+import json
+import logging
+from typing import Any
+from typing import Optional
+from typing import Union
+
+import requests
+from cryptojwt import KeyJar
+from jinja2 import Environment
+from jinja2 import FileSystemLoader
+from oidcmsg.context import OidcContext
+
+from oidcop import rndstr
+from oidcop.configure import OPConfiguration
+from oidcop.scopes import SCOPE2CLAIMS
+from oidcop.scopes import Scopes
+from oidcop.session.claims import STANDARD_CLAIMS
+from oidcop.session.manager import SessionManager
+from oidcop.template_handler import Jinja2TemplateHandler
+from oidcop.util import get_http_params
+from oidcop.util import importer
+
+logger = logging.getLogger(__name__)
+
+
+def add_path(url: str, path: str) -> str:
+ if url.endswith("/"):
+ if path.startswith("/"):
+ return "{}{}".format(url, path[1:])
+
+ return "{}{}".format(url, path)
+
+ if path.startswith("/"):
+ return "{}{}".format(url, path)
+
+ return "{}/{}".format(url, path)
+
+
+def init_user_info(conf, cwd: str):
+ kwargs = conf.get("kwargs", {})
+
+ if isinstance(conf["class"], str):
+ return importer(conf["class"])(**kwargs)
+
+ return conf["class"](**kwargs)
+
+
+def init_service(conf, server_get=None):
+ kwargs = conf.get("kwargs", {})
+
+ if server_get:
+ kwargs["server_get"] = server_get
+
+ if isinstance(conf["class"], str):
+ try:
+ return importer(conf["class"])(**kwargs)
+ except TypeError as err:
+ logger.error("Could not init service class: {}".format(conf["class"]), err)
+ raise
+
+ return conf["class"](**kwargs)
+
+
+def get_token_handler_args(conf: dict) -> dict:
+ """
+
+ :param conf: The configuration
+ :rtype: dict
+ """
+ th_args = conf.get("token_handler_args", None)
+ if not th_args:
+ # create 3 keys
+ keydef = [
+ {"type": "oct", "bytes": "24", "use": ["enc"], "kid": "code"},
+ {"type": "oct", "bytes": "24", "use": ["enc"], "kid": "token"},
+ {"type": "oct", "bytes": "24", "use": ["enc"], "kid": "refresh"},
+ ]
+
+ jwks_def = {
+ "private_path": "private/token_jwks.json",
+ "key_defs": keydef,
+ "read_only": False,
+ }
+ th_args = {"jwks_def": jwks_def}
+ for typ, tid in [("code", 600), ("token", 3600), ("refresh", 86400)]:
+ th_args[typ] = {"lifetime": tid}
+
+ return th_args
+
+
+class EndpointContext(OidcContext):
+ parameter = {
+ "args": {},
+ # "authn_broker": AuthnBroker,
+ # "authz": AuthzHandling,
+ "cdb": {},
+ "conf": {},
+ # "cookie_handler": None,
+ "cwd": "",
+ "endpoint_to_authn_method": {},
+ "httpc_params": {},
+ # "idtoken": IDToken,
+ "issuer": "",
+ "jti_db": {},
+ "jwks_uri": "",
+ "keyjar": KeyJar,
+ "login_hint_lookup": None,
+ "login_hint2acrs": {},
+ "par_db": {},
+ "provider_info": {},
+ "registration_access_token": {},
+ "scope2claims": {},
+ # "session_db": {},
+ "session_manager": SessionManager,
+ "sso_ttl": None,
+ "symkey": "",
+ "token_args_methods": [],
+ # "userinfo": UserInfo,
+ }
+
+ def __init__(
+ self,
+ conf: Union[dict, OPConfiguration],
+ keyjar: Optional[KeyJar] = None,
+ cwd: Optional[str] = "",
+ cookie_handler: Optional[Any] = None,
+ httpc: Optional[Any] = None,
+ ):
+ OidcContext.__init__(self, conf, keyjar, entity_id=conf.get("issuer", ""))
+ self.conf = conf
+
+ # For my Dev environment
+ self.cdb = {}
+ self.jti_db = {}
+ self.registration_access_token = {}
+ # self.session_db = {}
+
+ self.cwd = cwd
+
+ # Default values, to be changed below depending on configuration
+ # arguments for endpoints add-ons
+ self.args = {}
+ self.authn_broker = None
+ self.authz = None
+ self.cookie_handler = cookie_handler
+ self.endpoint_to_authn_method = {}
+ self.httpc = httpc or requests
+ self.idtoken = None
+ self.issuer = ""
+ self.jwks_uri = None
+ self.login_hint_lookup = None
+ self.login_hint2acrs = None
+ self.par_db = {}
+ self.provider_info = {}
+ self.scope2claims = SCOPE2CLAIMS
+ self.session_manager = None
+ self.sso_ttl = 14400 # 4h
+ self.symkey = rndstr(24)
+ self.template_handler = None
+ self.token_args_methods = []
+ self.userinfo = None
+
+ for param in [
+ "issuer",
+ "sso_ttl",
+ "symkey",
+ "client_authn",
+ # "id_token_schema",
+ ]:
+ try:
+ setattr(self, param, conf[param])
+ except KeyError:
+ pass
+
+ self.th_args = get_token_handler_args(conf)
+
+ # session db
+ self._sub_func = {}
+ self.do_sub_func()
+
+ _handler = conf.get("template_handler")
+ if _handler:
+ self.template_handler = _handler
+ else:
+ _loader = conf.get("template_loader")
+
+ if _loader is None:
+ _template_dir = conf.get("template_dir")
+ if _template_dir:
+ _loader = Environment(loader=FileSystemLoader(_template_dir), autoescape=True)
+
+ if _loader:
+ self.template_handler = Jinja2TemplateHandler(_loader)
+
+ # self.setup = {}
+ _keys_conf = conf.get("keys")
+ if _keys_conf:
+ jwks_uri_path = _keys_conf["uri_path"]
+
+ if self.issuer.endswith("/"):
+ self.jwks_uri = "{}{}".format(self.issuer, jwks_uri_path)
+ else:
+ self.jwks_uri = "{}/{}".format(self.issuer, jwks_uri_path)
+
+ for item in [
+ "cookie_handler",
+ "authentication",
+ "id_token",
+ "scope2claims",
+ ]:
+ _func = getattr(self, "do_{}".format(item), None)
+ if _func:
+ _func()
+
+ for item in ["login_hint2acrs"]:
+ _func = getattr(self, "do_{}".format(item), None)
+ if _func:
+ _func()
+
+ # which signing/encryption algorithms to use in what context
+ self.jwx_def = {}
+
+ # The HTTP clients request arguments
+ _cnf = conf.get("httpc_params")
+ if _cnf:
+ self.httpc_params = get_http_params(_cnf)
+ else: # Backward compatibility
+ self.httpc_params = {"verify": conf.get("verify_ssl", True)}
+
+ self.set_scopes_handler()
+ self.dev_auth_db = None
+ self.claims_interface = None
+
+ def new_cookie(self, name: str, max_age: Optional[int] = 0, **kwargs):
+ return self.cookie_handler.make_cookie_content(
+ name=name, value=json.dumps(kwargs), max_age=max_age
+ )
+
+ def set_scopes_handler(self):
+ _spec = self.conf.get("scopes_handler")
+ if _spec:
+ _kwargs = _spec.get("kwargs", {})
+ _cls = importer(_spec["class"])(**_kwargs)
+ self.scopes_handler = _cls(_kwargs)
+ else:
+ self.scopes_handler = Scopes()
+
+ def do_add_on(self, endpoints):
+ _add_on_conf = self.conf.get("add_on")
+ if _add_on_conf:
+ for spec in _add_on_conf.values():
+ if isinstance(spec["function"], str):
+ _func = importer(spec["function"])
+ else:
+ _func = spec["function"]
+ _func(endpoints, **spec["kwargs"])
+
+ def do_login_hint2acrs(self):
+ _conf = self.conf.get("login_hint2acrs")
+
+ if _conf:
+ self.login_hint2acrs = init_service(_conf)
+ else:
+ self.login_hint2acrs = None
+
+ def do_userinfo(self):
+ _conf = self.conf.get("userinfo")
+ if _conf:
+ if self.session_manager:
+ self.userinfo = init_user_info(_conf, self.cwd)
+ self.session_manager.userinfo = self.userinfo
+ else:
+ logger.warning("Cannot init_user_info if no session manager was provided.")
+
+ def do_cookie_handler(self):
+ _conf = self.conf.get("cookie_handler")
+ if _conf:
+ if not self.cookie_handler:
+ self.cookie_handler = init_service(_conf)
+
+ def do_sub_func(self) -> None:
+ """
+ Loads functions that creates subject "sub" values
+
+ :return: string
+ """
+ ses_par = self.conf.get("session_params") or {}
+ sub_func = ses_par.get("sub_func") or {}
+ for key, args in sub_func.items():
+ if "class" in args:
+ self._sub_func[key] = init_service(args)
+ elif "function" in args:
+ if isinstance(args["function"], str):
+ self._sub_func[key] = importer(args["function"])
+ else:
+ self._sub_func[key] = args["function"]
+
+ def create_providerinfo(self, capabilities):
+ """
+ Dynamically create the provider info response
+
+ :param capabilities:
+ :return:
+ """
+
+ _provider_info = capabilities
+ _provider_info["issuer"] = self.issuer
+ _provider_info["version"] = "3.0"
+
+ # acr_values
+ if self.authn_broker:
+ acr_values = self.authn_broker.get_acr_values()
+ if acr_values is not None:
+ _provider_info["acr_values_supported"] = acr_values
+
+ if self.jwks_uri and self.keyjar:
+ _provider_info["jwks_uri"] = self.jwks_uri
+
+ if "scopes_supported" not in _provider_info:
+ _provider_info["scopes_supported"] = [s for s in self.scope2claims.keys()]
+ if "claims_supported" not in _provider_info:
+ _provider_info["claims_supported"] = STANDARD_CLAIMS[:]
+
+ return _provider_info
diff --git a/src/oidcop/exception.py b/src/oidcop/exception.py
new file mode 100755
index 00000000..48a25093
--- /dev/null
+++ b/src/oidcop/exception.py
@@ -0,0 +1,102 @@
+class OidcEndpointError(Exception):
+ pass
+
+
+class InvalidRedirectURIError(OidcEndpointError):
+ pass
+
+
+class InvalidSectorIdentifier(OidcEndpointError):
+ pass
+
+
+class ConfigurationError(OidcEndpointError):
+ pass
+
+
+class NoSuchAuthentication(OidcEndpointError):
+ pass
+
+
+class TamperAllert(OidcEndpointError):
+ pass
+
+
+class ToOld(OidcEndpointError):
+ pass
+
+
+class MultipleUsage(OidcEndpointError):
+ pass
+
+
+class FailedAuthentication(OidcEndpointError):
+ pass
+
+
+class InstantiationError(OidcEndpointError):
+ pass
+
+
+class ImproperlyConfigured(OidcEndpointError):
+ pass
+
+
+class NotForMe(OidcEndpointError):
+ pass
+
+
+class UnknownAssertionType(OidcEndpointError):
+ pass
+
+
+class RedirectURIError(OidcEndpointError):
+ pass
+
+
+class UnknownClient(OidcEndpointError):
+ pass
+
+
+class InvalidClient(OidcEndpointError):
+ pass
+
+
+class UnAuthorizedClient(OidcEndpointError):
+ pass
+
+
+class UnAuthorizedClientScope(OidcEndpointError):
+ pass
+
+
+class InvalidCookieSign(Exception):
+ pass
+
+
+class OnlyForTestingWarning(Warning):
+ "Warned when using a feature that only should be used for testing."
+
+
+class ProcessError(OidcEndpointError):
+ pass
+
+
+class ServiceError(OidcEndpointError):
+ pass
+
+
+class InvalidRequest(OidcEndpointError):
+ pass
+
+
+class CapabilitiesMisMatch(OidcEndpointError):
+ pass
+
+
+class MultipleCodeUsage(OidcEndpointError):
+ pass
+
+
+class InvalidToken(Exception):
+ pass
diff --git a/src/oidcop/logging.py b/src/oidcop/logging.py
index f5134def..d04c577e 100755
--- a/src/oidcop/logging.py
+++ b/src/oidcop/logging.py
@@ -1,52 +1,41 @@
"""Common logging functions"""
-
-import os
import logging
+import os
from logging.config import dictConfig
+from typing import Optional
import yaml
-
-LOGGING_CONF = 'logging.yaml'
+LOGGING_CONF = "logging.yaml"
LOGGING_DEFAULT = {
- 'version': 1,
- 'formatters': {
- 'default': {
- 'format': '%(asctime)s %(name)s %(levelname)s %(message)s'
- }
- },
- 'handlers': {
- 'default': {
- 'class': 'logging.StreamHandler',
- 'formatter': 'default'
- }
- },
- 'root': {
- 'handlers': ['default'],
- 'level': 'INFO'
- }
+ "version": 1,
+ "formatters": {"default": {"format": "%(asctime)s %(name)s %(levelname)s %(message)s"}},
+ "handlers": {"default": {"class": "logging.StreamHandler", "formatter": "default"}},
+ "root": {"handlers": ["default"], "level": "INFO"},
}
-def configure_logging(debug: bool = False, config: dict = None,
- filename: str = LOGGING_CONF) -> logging.Logger:
+def configure_logging(
+ debug: Optional[bool] = False, config: Optional[dict] = None, filename: Optional[str] = "",
+) -> logging.Logger:
"""Configure logging"""
if config is not None:
config_dict = config
- config_source = 'dictionary'
- elif filename is not None and os.path.exists(filename):
+ config_source = "dictionary"
+ elif filename and os.path.exists(filename):
with open(filename, "rt") as file:
- config_dict = yaml.load(file)
- config_source = 'file'
+ config_dict = yaml.safe_load(file)
+ config_source = "file"
else:
config_dict = LOGGING_DEFAULT
- config_source = 'default'
+ config_source = "default"
if debug:
- config_dict['root']['level'] = 'DEBUG'
+ config_dict["root"]["level"] = "DEBUG"
dictConfig(config_dict)
- logging.debug("Configured logging using %s", config_source)
- return logging.getLogger()
+ logger = logging.getLogger()
+ logger.debug("Configured logging using: {}".format(config_source))
+ return logger
diff --git a/src/oidcop/login_hint.py b/src/oidcop/login_hint.py
new file mode 100644
index 00000000..cd64a3c9
--- /dev/null
+++ b/src/oidcop/login_hint.py
@@ -0,0 +1,36 @@
+from urllib.parse import urlparse
+
+
+class LoginHintLookup(object):
+ def __init__(self, userinfo=None, server_get=None):
+ self.userinfo = userinfo
+ self.default_country_code = "46"
+ self.server_get = server_get
+
+ def __call__(self, arg):
+ if arg.startswith("tel:"):
+ _pnr = arg[4:]
+ if _pnr[0] == "+":
+ pass
+ else:
+ _pnr = "+" + self.default_country_code + _pnr[1:]
+ return self.userinfo.search(phone_number=_pnr)
+ elif arg.startswith("mail:"):
+ _mail = arg[5:]
+ return self.userinfo.search(email=_mail)
+
+
+class LoginHint2Acrs(object):
+ """
+ OIDC Login hint support
+ """
+ def __init__(self, scheme_map, server_get=None):
+ self.scheme_map = scheme_map
+ self.server_get = server_get
+
+ def __call__(self, hint):
+ p = urlparse(hint)
+ try:
+ return self.scheme_map[p.scheme]
+ except KeyError:
+ return []
diff --git a/src/oidcop/oauth2/__init__.py b/src/oidcop/oauth2/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/oidcop/oauth2/add_on/__init__.py b/src/oidcop/oauth2/add_on/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/oidcop/oauth2/add_on/dpop.py b/src/oidcop/oauth2/add_on/dpop.py
new file mode 100644
index 00000000..472c5ea6
--- /dev/null
+++ b/src/oidcop/oauth2/add_on/dpop.py
@@ -0,0 +1,170 @@
+from typing import Optional
+
+from cryptojwt import JWS
+from cryptojwt import as_unicode
+from cryptojwt.jwk.jwk import key_from_jwk_dict
+from cryptojwt.jws.jws import factory
+from oidcmsg.message import SINGLE_REQUIRED_INT
+from oidcmsg.message import SINGLE_REQUIRED_JSON
+from oidcmsg.message import SINGLE_REQUIRED_STRING
+from oidcmsg.message import Message
+
+from oidcop.client_authn import AuthnFailure
+from oidcop.client_authn import ClientAuthnMethod
+from oidcop.client_authn import basic_authn
+
+
+class DPoPProof(Message):
+ c_param = {
+ # header
+ "typ": SINGLE_REQUIRED_STRING,
+ "alg": SINGLE_REQUIRED_STRING,
+ "jwk": SINGLE_REQUIRED_JSON,
+ # body
+ "jti": SINGLE_REQUIRED_STRING,
+ "htm": SINGLE_REQUIRED_STRING,
+ "htu": SINGLE_REQUIRED_STRING,
+ "iat": SINGLE_REQUIRED_INT,
+ }
+ header_params = {"typ", "alg", "jwk"}
+ body_params = {"jti", "htm", "htu", "iat"}
+
+ def __init__(self, set_defaults=True, **kwargs):
+ self.key = None
+ Message.__init__(self, set_defaults=set_defaults, **kwargs)
+
+ if self.key:
+ pass
+ elif "jwk" in self:
+ self.key = key_from_jwk_dict(self["jwk"])
+ self.key.deserialize()
+
+ def from_dict(self, dictionary, **kwargs):
+ Message.from_dict(self, dictionary, **kwargs)
+
+ if "jwk" in self:
+ self.key = key_from_jwk_dict(self["jwk"])
+ self.key.deserialize()
+
+ return self
+
+ def verify(self, **kwargs):
+ Message.verify(self, **kwargs)
+ if self["typ"] != "dpop+jwt":
+ raise ValueError("Wrong type")
+ if self["alg"] == "none":
+ raise ValueError("'none' is not allowed as signing algorithm")
+
+ def create_header(self) -> str:
+ payload = {k: self[k] for k in self.body_params}
+ _jws = JWS(payload, alg=self["alg"])
+ _headers = {k: self[k] for k in self.header_params}
+ self.key.kid = ""
+ _sjwt = _jws.sign_compact(keys=[self.key], **_headers)
+ return _sjwt
+
+ def verify_header(self, dpop_header) -> Optional["DPoPProof"]:
+ _jws = factory(dpop_header)
+ if _jws:
+ _jwt = _jws.jwt
+ if "jwk" in _jwt.headers:
+ _pub_key = key_from_jwk_dict(_jwt.headers["jwk"])
+ _pub_key.deserialize()
+ _info = _jws.verify_compact(keys=[_pub_key], sigalg=_jwt.headers["alg"])
+ for k, v in _jwt.headers.items():
+ self[k] = v
+
+ for k, v in _info.items():
+ self[k] = v
+ else:
+ raise Exception()
+
+ return self
+ else:
+ return None
+
+
+def post_parse_request(request, client_id, endpoint_context, **kwargs):
+ """
+ Expect http_info attribute in kwargs. http_info should be a dictionary
+ containing HTTP information.
+
+ :param request:
+ :param client_id:
+ :param endpoint_context:
+ :param kwargs:
+ :return:
+ """
+
+ _http_info = kwargs.get("http_info")
+ if not _http_info:
+ return request
+
+ _dpop = DPoPProof().verify_header(_http_info["headers"]["dpop"])
+
+ # The signature of the JWS is verified, now for checking the
+ # content
+
+ if _dpop["htu"] != _http_info["url"]:
+ raise ValueError("htu in DPoP does not match the HTTP URI")
+
+ if _dpop["htm"] != _http_info["method"]:
+ raise ValueError("htm in DPoP does not match the HTTP method")
+
+ if not _dpop.key:
+ _dpop.key = key_from_jwk_dict(_dpop["jwk"])
+
+ # Need something I can add as a reference when minting tokens
+ request["dpop_jkt"] = as_unicode(_dpop.key.thumbprint("SHA-256"))
+ return request
+
+
+def token_args(endpoint_context, client_id, token_args: Optional[dict] = None):
+ dpop_jkt = endpoint_context.cdb[client_id]["dpop_jkt"]
+ _jkt = list(dpop_jkt.keys())[0]
+ if "dpop_jkt" in endpoint_context.cdb[client_id]:
+ if token_args is None:
+ token_args = {"cnf": {"jkt": _jkt}}
+ else:
+ token_args.update({"cnf": {"jkt": endpoint_context.cdb[client_id]["dpop_jkt"]}})
+
+ return token_args
+
+
+def add_support(endpoint, **kwargs):
+ #
+ _token_endp = endpoint["token"]
+ _token_endp.post_parse_request.append(post_parse_request)
+
+ # Endpoint Context stuff
+ # _endp.endpoint_context.token_args_methods.append(token_args)
+ _algs_supported = kwargs.get("dpop_signing_alg_values_supported")
+ if not _algs_supported:
+ _algs_supported = ["RS256"]
+
+ _token_endp.server_get("endpoint_context").provider_info[
+ "dpop_signing_alg_values_supported"
+ ] = _algs_supported
+
+ _endpoint_context = _token_endp.server_get("endpoint_context")
+ _endpoint_context.dpop_enabled = True
+
+
+# DPoP-bound access token in the "Authorization" header and the DPoP proof in the "DPoP" header
+
+
+class DPoPClientAuth(ClientAuthnMethod):
+ tag = "dpop_client_auth"
+
+ def is_usable(self, request=None, authorization_info=None, http_headers=None):
+ if authorization_info is not None and authorization_info.startswith("DPoP "):
+ return True
+ return False
+
+ def verify(self, authorization_info, **kwargs):
+ client_info = basic_authn(authorization_info)
+ _context = self.server_get("endpoint_context")
+ if _context.cdb[client_info["id"]]["client_secret"] == client_info["secret"]:
+ return {"client_id": client_info["id"]}
+ else:
+ raise AuthnFailure()
diff --git a/src/oidcop/oauth2/authorization.py b/src/oidcop/oauth2/authorization.py
new file mode 100755
index 00000000..2a68230e
--- /dev/null
+++ b/src/oidcop/oauth2/authorization.py
@@ -0,0 +1,944 @@
+import json
+import logging
+from typing import List
+from typing import Optional
+from typing import Union
+from urllib.parse import unquote
+from urllib.parse import urlencode
+from urllib.parse import urlparse
+
+from cryptojwt import BadSyntax
+from cryptojwt import as_unicode
+from cryptojwt import b64d
+from cryptojwt.jwe.exception import JWEException
+from cryptojwt.jws.exception import NoSuitableSigningKeys
+from cryptojwt.utils import as_bytes
+from cryptojwt.utils import b64e
+from oidcmsg import oauth2
+from oidcmsg.exception import ParameterError
+from oidcmsg.exception import URIError
+from oidcmsg.message import Message
+from oidcmsg.oauth2 import AuthorizationRequest
+from oidcmsg.oidc import AuthorizationResponse
+from oidcmsg.oidc import verified_claim_name
+from oidcmsg.time_util import utc_time_sans_frac
+
+from oidcop import rndstr
+from oidcop.authn_event import create_authn_event
+from oidcop.cookie_handler import compute_session_state
+from oidcop.endpoint import Endpoint
+from oidcop.endpoint_context import EndpointContext
+from oidcop.exception import InvalidRequest
+from oidcop.exception import NoSuchAuthentication
+from oidcop.exception import RedirectURIError
+from oidcop.exception import ServiceError
+from oidcop.exception import TamperAllert
+from oidcop.exception import ToOld
+from oidcop.exception import UnAuthorizedClientScope
+from oidcop.exception import UnknownClient
+from oidcop.session import Revoked
+from oidcop.token.exception import UnknownToken
+from oidcop.user_authn.authn_context import pick_auth
+from oidcop.util import split_uri
+
+logger = logging.getLogger(__name__)
+
+# For the time being. This is JAR specific and should probably be configurable.
+ALG_PARAMS = {
+ "sign": ["request_object_signing_alg", "request_object_signing_alg_values_supported",],
+ "enc_alg": ["request_object_encryption_alg", "request_object_encryption_alg_values_supported",],
+ "enc_enc": ["request_object_encryption_enc", "request_object_encryption_enc_values_supported",],
+}
+
+FORM_POST = """
+
+ Submit This Form
+
+
+
+
+"""
+
+
+def inputs(form_args):
+ """
+ Creates list of input elements
+ """
+ element = []
+ html_field = ''
+ for name, value in form_args.items():
+ element.append(html_field.format(name, value))
+ return "\n".join(element)
+
+
+def max_age(request):
+ verified_request = verified_claim_name("request")
+ return request.get(verified_request, {}).get("max_age") or request.get("max_age", 0)
+
+
+def verify_uri(
+ endpoint_context: EndpointContext,
+ request: Union[dict, Message],
+ uri_type: str,
+ client_id: Optional[str] = None,
+):
+ """
+ A redirect URI
+ MUST NOT contain a fragment
+ MAY contain query component
+
+ :param endpoint_context: An EndpointContext instance
+ :param request: The authorization request
+ :param uri_type: redirect_uri or post_logout_redirect_uri
+ :return: An error response if the redirect URI is faulty otherwise
+ None
+ """
+ _cid = request.get("client_id", client_id)
+
+ if not _cid:
+ logger.error("No client id found")
+ raise UnknownClient("No client_id provided")
+ else:
+ logger.debug("Client ID: {}".format(_cid))
+
+ _uri = request.get(uri_type)
+ if _uri is None:
+ raise ValueError(f"Wrong uri_type: {uri_type}")
+
+ _redirect_uri = unquote(_uri)
+
+ part = urlparse(_redirect_uri)
+ if part.fragment:
+ raise URIError("Contains fragment")
+
+ (_base, _query) = split_uri(_redirect_uri)
+ # if _query:
+ # _query = parse_qs(_query)
+
+ # Get the clients registered redirect uris
+ client_info = endpoint_context.cdb.get(_cid)
+ if client_info is None:
+ raise KeyError("No such client")
+
+ logger.debug("Client info: {}".format(client_info))
+ redirect_uris = client_info.get("{}s".format(uri_type))
+ if redirect_uris is None:
+ raise ValueError(f"No registered {uri_type} for {_cid}")
+ else:
+ match = False
+ for regbase, rquery in redirect_uris:
+ # The URI MUST exactly match one of the Redirection URI
+ if _base == regbase:
+ # every registered query component must exist in the uri
+ if rquery:
+ if not _query:
+ raise ValueError("Missing query part")
+
+ for key, vals in rquery.items():
+ if key not in _query:
+ raise ValueError('"{}" not in query part'.format(key))
+
+ for val in vals:
+ if val not in _query[key]:
+ raise ValueError("{}={} value not in query part".format(key, val))
+
+ # and vice versa, every query component in the uri
+ # must be registered
+ if _query:
+ if not rquery:
+ raise ValueError("No registered query part")
+
+ for key, vals in _query.items():
+ if key not in rquery:
+ raise ValueError('"{}" extra in query part'.format(key))
+ for val in vals:
+ if val not in rquery[key]:
+ raise ValueError("Extra {}={} value in query part".format(key, val))
+ match = True
+ break
+ if not match:
+ raise RedirectURIError("Doesn't match any registered uris")
+
+
+def join_query(base, query):
+ """
+
+ :param base: URL base
+ :param query: query part as a dictionary
+ :return:
+ """
+ if query:
+ return "{}?{}".format(base, urlencode(query, doseq=True))
+ else:
+ return base
+
+
+def get_uri(endpoint_context, request, uri_type):
+ """ verify that the redirect URI is reasonable.
+
+ :param endpoint_context: An EndpointContext instance
+ :param request: The Authorization request
+ :param uri_type: 'redirect_uri' or 'post_logout_redirect_uri'
+ :return: redirect_uri
+ """
+ uri = ""
+
+ if uri_type in request:
+ verify_uri(endpoint_context, request, uri_type)
+ uri = request[uri_type]
+ else:
+ uris = f"{uri_type}s"
+ client_id = str(request["client_id"])
+ if client_id in endpoint_context.cdb:
+ _specs = endpoint_context.cdb[client_id].get(uris)
+ if not _specs:
+ raise ParameterError(f"Missing '{uri_type}' and none registered")
+
+ if len(_specs) > 1:
+ raise ParameterError(f"Missing '{uri_type}' and more than one registered")
+
+ uri = join_query(*_specs[0])
+ else:
+ raise UnknownClient(client_id)
+
+ return uri
+
+
+def authn_args_gather(
+ request: Union[AuthorizationRequest, dict], authn_class_ref: str, cinfo: dict, **kwargs,
+):
+ """
+ Gather information to be used by the authentication method
+
+ :param request: The request either as a dictionary or as a Message instance
+ :param authn_class_ref: Authentication class reference
+ :param cinfo: Client information
+ :param kwargs: Extra keyword arguments
+ :return: Authentication arguments
+ """
+ authn_args = {}
+
+ if isinstance(request, Message):
+ authn_args["query"] = request.to_urlencoded()
+ elif isinstance(request, dict):
+ authn_args["query"] = urlencode(request)
+ else:
+ raise ValueError("Wrong request format")
+
+ authn_args.update({"authn_class_ref": authn_class_ref, "return_uri": request["redirect_uri"]})
+
+ if "req_user" in kwargs:
+ authn_args["as_user"] = (kwargs["req_user"],)
+
+ # Below are OIDC specific. Just ignore if OAuth2
+ if cinfo:
+ for attr in ["policy_uri", "logo_uri", "tos_uri"]:
+ if cinfo.get(attr):
+ authn_args[attr] = cinfo[attr]
+
+ for attr in ["ui_locales", "acr_values", "login_hint"]:
+ if request.get(attr):
+ authn_args[attr] = request[attr]
+
+ return authn_args
+
+
+def check_unknown_scopes_policy(request_info, cinfo, endpoint_context):
+ op_capabilities = endpoint_context.conf["capabilities"]
+ client_allowed_scopes = cinfo.get("allowed_scopes") or op_capabilities["scopes_supported"]
+
+ # this prevents that authz would be released for unavailable scopes
+ for scope in request_info["scope"]:
+ if op_capabilities.get("deny_unknown_scopes") and scope not in client_allowed_scopes:
+ _msg = "{} requested an unauthorized scope ({})"
+ logger.warning(_msg.format(cinfo["client_id"], scope))
+ raise UnAuthorizedClientScope()
+
+
+class Authorization(Endpoint):
+ request_cls = oauth2.AuthorizationRequest
+ response_cls = oauth2.AuthorizationResponse
+ error_cls = oauth2.AuthorizationErrorResponse
+ request_format = "urlencoded"
+ response_format = "urlencoded"
+ response_placement = "url"
+ endpoint_name = "authorization_endpoint"
+ name = "authorization"
+ default_capabilities = {
+ "claims_parameter_supported": True,
+ "request_parameter_supported": True,
+ "request_uri_parameter_supported": True,
+ "response_types_supported": ["code", "token", "code token"],
+ "response_modes_supported": ["query", "fragment", "form_post"],
+ "request_object_signing_alg_values_supported": None,
+ "request_object_encryption_alg_values_supported": None,
+ "request_object_encryption_enc_values_supported": None,
+ "grant_types_supported": ["authorization_code", "implicit"],
+ "scopes_supported": [],
+ }
+
+ def __init__(self, server_get, **kwargs):
+ Endpoint.__init__(self, server_get, **kwargs)
+ self.post_parse_request.append(self._do_request_uri)
+ self.post_parse_request.append(self._post_parse_request)
+ self.allowed_request_algorithms = AllowedAlgorithms(ALG_PARAMS)
+
+ def filter_request(self, endpoint_context, req):
+ return req
+
+ def extra_response_args(self, aresp):
+ return aresp
+
+ def verify_response_type(self, request: Union[Message, dict], cinfo: dict) -> bool:
+ # Checking response types
+ _registered = [set(rt.split(" ")) for rt in cinfo.get("response_types", [])]
+ if not _registered:
+ # If no response_type is registered by the client then we'll use code.
+ _registered = [{"code"}]
+
+ # Is the asked for response_type among those that are permitted
+ return set(request["response_type"]) in _registered
+
+ def mint_token(self, token_class, grant, session_id, based_on=None, **kwargs):
+ usage_rules = grant.usage_rules.get(token_class, {})
+ token = grant.mint_token(
+ session_id=session_id,
+ endpoint_context=self.server_get("endpoint_context"),
+ token_class=token_class,
+ based_on=based_on,
+ usage_rules=usage_rules,
+ **kwargs,
+ )
+
+ _exp_in = usage_rules.get("expires_in")
+ if isinstance(_exp_in, str):
+ _exp_in = int(_exp_in)
+ if _exp_in:
+ token.expires_at = utc_time_sans_frac() + _exp_in
+
+ _mngr = self.server_get("endpoint_context").session_manager
+ _mngr.set(_mngr.unpack_session_key(session_id), grant)
+
+ return token
+
+ def _do_request_uri(self, request, client_id, endpoint_context, **kwargs):
+ _request_uri = request.get("request_uri")
+ if _request_uri:
+ # Do I do pushed authorization requests ?
+ _endp = self.server_get("endpoint", "pushed_authorization")
+ if _endp:
+ # Is it a UUID urn
+ if _request_uri.startswith("urn:uuid:"):
+ _req = endpoint_context.par_db.get(_request_uri)
+ if _req:
+ # One time usage
+ del endpoint_context.par_db[_request_uri]
+ return _req
+ else:
+ raise ValueError("Got a request_uri I can not resolve")
+
+ # Do I support request_uri ?
+ if endpoint_context.provider_info.get("request_uri_parameter_supported", True) is False:
+ raise ServiceError("Someone is using request_uri which I'm not supporting")
+
+ _registered = endpoint_context.cdb[client_id].get("request_uris")
+ # Not registered should be handled else where
+ if _registered:
+ # Before matching remove a possible fragment
+ _p = _request_uri.split("#")
+ # ignore registered fragments for now.
+ if _p[0] not in [base for base, qp in _registered]:
+ raise ValueError("A request_uri outside the registered")
+
+ # Fetch the request
+ _resp = endpoint_context.httpc.get(_request_uri, **endpoint_context.httpc_params)
+ if _resp.status_code == 200:
+ args = {"keyjar": endpoint_context.keyjar, "issuer": client_id}
+ _ver_request = self.request_cls().from_jwt(_resp.text, **args)
+ self.allowed_request_algorithms(
+ client_id,
+ endpoint_context,
+ _ver_request.jws_header.get("alg", "RS256"),
+ "sign",
+ )
+ if _ver_request.jwe_header is not None:
+ self.allowed_request_algorithms(
+ client_id, endpoint_context, _ver_request.jws_header.get("alg"), "enc_alg",
+ )
+ self.allowed_request_algorithms(
+ client_id, endpoint_context, _ver_request.jws_header.get("enc"), "enc_enc",
+ )
+ # The protected info overwrites the non-protected
+ for k, v in _ver_request.items():
+ request[k] = v
+
+ request[verified_claim_name("request")] = _ver_request
+ else:
+ raise ServiceError("Got a %s response", _resp.status)
+
+ return request
+
+ def _post_parse_request(self, request, client_id, endpoint_context, **kwargs):
+ """
+ Verify the authorization request.
+
+ :param endpoint_context:
+ :param request:
+ :param client_id:
+ :param kwargs:
+ :return:
+ """
+ if not request:
+ logger.debug("No AuthzRequest")
+ return self.error_cls(
+ error="invalid_request", error_description="Can not parse AuthzRequest"
+ )
+
+ request = self.filter_request(endpoint_context, request)
+
+ _cinfo = endpoint_context.cdb.get(client_id)
+ if not _cinfo:
+ logger.error("Client ID ({}) not in client database".format(request["client_id"]))
+ return self.error_cls(error="unauthorized_client", error_description="unknown client")
+
+ # Is the asked for response_type among those that are permitted
+ if not self.verify_response_type(request, _cinfo):
+ return self.error_cls(
+ error="invalid_request",
+ error_description="Trying to use unregistered response_type",
+ )
+
+ # Get a verified redirect URI
+ try:
+ redirect_uri = get_uri(endpoint_context, request, "redirect_uri")
+ except (RedirectURIError, ParameterError) as err:
+ return self.error_cls(
+ error="invalid_request",
+ error_description="{}:{}".format(err.__class__.__name__, err),
+ )
+ else:
+ request["redirect_uri"] = redirect_uri
+
+ return request
+
+ def pick_authn_method(self, request, redirect_uri, acr=None, **kwargs):
+ _context = self.server_get("endpoint_context")
+ auth_id = kwargs.get("auth_method_id")
+ if auth_id:
+ return _context.authn_broker[auth_id]
+
+ res = None
+ if acr:
+ res = _context.authn_broker.pick(acr)
+ else:
+ try:
+ res = pick_auth(_context, request)
+ except Exception as exc:
+ logger.exception(
+ f"An error occurred while picking the authN broker: {exc}"
+ )
+ if res:
+ return res
+ else:
+ return {
+ "error": "access_denied",
+ "error_description": "ACR I do not support",
+ "return_uri": redirect_uri,
+ "return_type": request["response_type"],
+ }
+
+ def create_session(self, request, user_id, acr, time_stamp, authn_method):
+ _context = self.server_get("endpoint_context")
+ _mngr = _context.session_manager
+ authn_event = create_authn_event(user_id, authn_info=acr, time_stamp=time_stamp,)
+ _exp_in = authn_method.kwargs.get("expires_in")
+ if _exp_in and "valid_until" in authn_event:
+ authn_event["valid_until"] = utc_time_sans_frac() + _exp_in
+
+ _token_usage_rules = _context.authz.usage_rules(request["client_id"])
+ return _mngr.create_session(
+ authn_event=authn_event,
+ auth_req=request,
+ user_id=user_id,
+ client_id=request["client_id"],
+ token_usage_rules=_token_usage_rules,
+ )
+
+ def setup_auth(
+ self,
+ request: Optional[Union[Message, dict]],
+ redirect_uri: str,
+ cinfo: dict,
+ cookie: List[dict] = None,
+ acr: str = None,
+ **kwargs,
+ ):
+ """
+
+ :param request: The authorization/authentication request
+ :param redirect_uri:
+ :param cinfo: client info
+ :param cookie: List of cookies
+ :param acr: Default ACR, if nothing else is specified
+ :param kwargs:
+ :return:
+ """
+
+ res = self.pick_authn_method(request, redirect_uri, acr, **kwargs)
+
+ authn = res["method"]
+ authn_class_ref = res["acr"]
+
+ client_id = request.get("client_id")
+ _context = self.server_get("endpoint_context")
+ try:
+ _auth_info = kwargs.get("authn", "")
+ if "upm_answer" in request and request["upm_answer"] == "true":
+ _max_age = 0
+ else:
+ _max_age = max_age(request)
+ identity, _ts = authn.authenticated_as(
+ client_id, cookie, authorization=_auth_info, max_age=_max_age
+ )
+ except (NoSuchAuthentication, TamperAllert):
+ identity = None
+ _ts = 0
+ except ToOld:
+ logger.info("Too old authentication")
+ identity = None
+ _ts = 0
+ except UnknownToken:
+ logger.info("Unknown Token")
+ identity = None
+ _ts = 0
+ else:
+ if identity:
+ try: # If identity['uid'] is in fact a base64 encoded JSON string
+ _id = b64d(as_bytes(identity["uid"]))
+ except BadSyntax:
+ pass
+ else:
+ identity = json.loads(as_unicode(_id))
+
+ try:
+ _csi = _context.session_manager[identity.get("sid")]
+ except Revoked:
+ identity = None
+ else:
+ if _csi.is_active() is False:
+ identity = None
+
+ authn_args = authn_args_gather(request, authn_class_ref, cinfo, **kwargs)
+ _mngr = _context.session_manager
+ _session_id = ""
+
+ # To authenticate or Not
+ if not identity: # No!
+ logger.info("No active authentication")
+ logger.debug("Known clients: {}".format(list(_context.cdb.keys())))
+
+ if "prompt" in request and "none" in request["prompt"]:
+ # Need to authenticate but not allowed
+ return {
+ "error": "login_required",
+ "return_uri": redirect_uri,
+ "return_type": request["response_type"],
+ }
+ else:
+ return {"function": authn, "args": authn_args}
+ else:
+ logger.info("Active authentication")
+ if re_authenticate(request, authn):
+ # demand re-authentication
+ return {"function": authn, "args": authn_args}
+ else:
+ # I got back a dictionary
+ user = identity["uid"]
+ if "req_user" in kwargs:
+ if user != kwargs["req_user"]:
+ logger.debug("Wanted to be someone else!")
+ if "prompt" in request and "none" in request["prompt"]:
+ # Need to authenticate but not allowed
+ return {
+ "error": "login_required",
+ "return_uri": redirect_uri,
+ }
+ else:
+ return {"function": authn, "args": authn_args}
+
+ if "sid" in identity:
+ _session_id = identity["sid"]
+
+ # make sure the client is the same
+ _uid, _cid, _gid = _mngr.decrypt_session_id(_session_id)
+ if request["client_id"] != _cid:
+ return {"function": authn, "args": authn_args}
+
+ grant = _mngr[_session_id]
+ if grant.is_active() is False:
+ return {"function": authn, "args": authn_args}
+ elif request != grant.authorization_request:
+ authn_event = _mngr.get_authentication_event(session_id=_session_id)
+ if authn_event.is_valid() is False: # if not valid, do new login
+ return {"function": authn, "args": authn_args}
+
+ # create new grant
+ _session_id = _mngr.create_grant(
+ authn_event=authn_event,
+ auth_req=request,
+ user_id=user,
+ client_id=request["client_id"],
+ )
+
+ if _session_id:
+ authn_event = _mngr.get_authentication_event(session_id=_session_id)
+ if authn_event.is_valid() is False: # if not valid, do new login
+ return {"function": authn, "args": authn_args}
+ else:
+ _session_id = self.create_session(request, identity["uid"], authn_class_ref, _ts, authn)
+
+ return {"session_id": _session_id, "identity": identity, "user": user}
+
+ def aresp_check(self, aresp, request):
+ return ""
+
+ def response_mode(
+ self,
+ request: Union[dict, AuthorizationRequest],
+ response_args: Optional[AuthorizationResponse] = None,
+ return_uri: Optional[str] = "",
+ fragment_enc: Optional[bool] = None,
+ **kwargs,
+ ) -> dict:
+ resp_mode = request["response_mode"]
+ if resp_mode == "form_post":
+ if isinstance(response_args, AuthorizationRequest):
+ _args = response_args.to_dict()
+ else:
+ _args = response_args
+ msg = FORM_POST.format(inputs=inputs(_args), action=return_uri,)
+ kwargs.update(
+ {"response_msg": msg, "content_type": "text/html", "response_placement": "body",}
+ )
+ elif resp_mode == "fragment":
+ if fragment_enc is False:
+ # Can't be done
+ raise InvalidRequest("wrong response_mode")
+ else:
+ kwargs["fragment_enc"] = True
+ elif resp_mode == "query":
+ if fragment_enc is True:
+ # Can't be done
+ raise InvalidRequest("wrong response_mode")
+ else:
+ raise InvalidRequest("Unknown response_mode")
+
+ if resp_mode in ["fragment", "query"]:
+ kwargs.update({"response_args": response_args, "return_uri": return_uri})
+
+ return kwargs
+
+ def error_response(self, response_info, error, error_description):
+ resp = self.error_cls(error=error, error_description=str(error_description))
+ response_info["response_args"] = resp
+ return response_info
+
+ def create_authn_response(self, request: Union[dict, Message], sid: str) -> dict:
+ """
+ :param request:
+ :param sid:
+ :return:
+ """
+ # create the response
+ aresp = self.response_cls()
+ if request.get("state"):
+ aresp["state"] = request["state"]
+
+ if "response_type" in request and request["response_type"] == ["none"]:
+ fragment_enc = False
+ else:
+ _context = self.server_get("endpoint_context")
+ _mngr = _context.session_manager
+ _sinfo = _mngr.get_session_info(sid, grant=True)
+
+ if request.get("scope"):
+ aresp["scope"] = request["scope"]
+
+ rtype = set(request["response_type"][:])
+ handled_response_type = []
+
+ fragment_enc = True
+ if len(rtype) == 1 and "code" in rtype:
+ fragment_enc = False
+
+ grant = _sinfo["grant"]
+
+ if "code" in request["response_type"]:
+ _code = self.mint_token(
+ token_class="authorization_code", grant=grant, session_id=_sinfo["session_id"],
+ )
+ aresp["code"] = _code.value
+ handled_response_type.append("code")
+ else:
+ _code = None
+
+ if "token" in rtype:
+ _access_token = self.mint_token(
+ token_class="access_token", grant=grant, session_id=_sinfo["session_id"],
+ )
+ aresp["access_token"] = _access_token.value
+ aresp["token_type"] = "Bearer"
+ if _access_token.expires_at:
+ aresp["expires_in"] = _access_token.expires_at - utc_time_sans_frac()
+ handled_response_type.append("token")
+ else:
+ _access_token = None
+
+ if "id_token" in request["response_type"]:
+ kwargs = {}
+ if {"code", "id_token", "token"}.issubset(rtype):
+ kwargs = {"code": _code.value, "access_token": _access_token.value}
+ elif {"code", "id_token"}.issubset(rtype):
+ kwargs = {"code": _code.value}
+ elif {"id_token", "token"}.issubset(rtype):
+ kwargs = {"access_token": _access_token.value}
+
+ try:
+ id_token = self.mint_token(
+ token_class="id_token",
+ grant=grant,
+ session_id=_sinfo["session_id"],
+ **kwargs,
+ )
+ # id_token = _context.idtoken.make(sid, **kwargs)
+ except (JWEException, NoSuitableSigningKeys) as err:
+ logger.warning(str(err))
+ resp = self.error_cls(
+ error="invalid_request",
+ error_description="Could not sign/encrypt id_token",
+ )
+ return {"response_args": resp, "fragment_enc": fragment_enc}
+
+ aresp["id_token"] = id_token.value
+ handled_response_type.append("id_token")
+
+ not_handled = rtype.difference(handled_response_type)
+ if not_handled:
+ resp = self.error_cls(
+ error="invalid_request", error_description="unsupported_response_type",
+ )
+ return {"response_args": resp, "fragment_enc": fragment_enc}
+
+ aresp = self.extra_response_args(aresp)
+
+ return {"response_args": aresp, "fragment_enc": fragment_enc}
+
+ def post_authentication(self, request: Union[dict, Message], session_id: str, **kwargs) -> dict:
+ """
+ Things that are done after a successful authentication.
+
+ :param request: The authorization request
+ :param session_id: Session identifier
+ :param kwargs:
+ :return: A dictionary with 'response_args'
+ """
+
+ response_info = {}
+ _context = self.server_get("endpoint_context")
+ _mngr = _context.session_manager
+
+ # Do the authorization
+
+ grant = _context.authz(session_id, request=request)
+ if grant.is_active() is False:
+ return self.error_response(response_info, "server_error", "Grant not usable")
+
+ user_id, client_id, grant_id = _mngr.decrypt_session_id(session_id)
+ try:
+ _mngr.set([user_id, client_id, grant_id], grant)
+ except Exception as err:
+ return self.error_response(response_info, "server_error", "{}".format(err.args))
+
+ logger.debug("response type: %s" % request["response_type"])
+
+ response_info = self.create_authn_response(request, session_id)
+ response_info["session_id"] = session_id
+
+ logger.debug("Known clients: {}".format(list(_context.cdb.keys())))
+
+ try:
+ redirect_uri = get_uri(_context, request, "redirect_uri")
+ except (RedirectURIError, ParameterError) as err:
+ return self.error_response(response_info, "invalid_request", "{}".format(err.args))
+ else:
+ response_info["return_uri"] = redirect_uri
+
+ # Now about the response_mode. Should not be set if it's obvious
+ # from the response_type. Knows about 'query', 'fragment' and
+ # 'form_post'.
+
+ if "response_mode" in request:
+ try:
+ response_info = self.response_mode(request, **response_info)
+ except InvalidRequest as err:
+ return self.error_response(response_info, "invalid_request", "{}".format(err.args))
+
+ _cookie_info = _context.new_cookie(
+ name=_context.cookie_handler.name["session"],
+ sid=session_id,
+ state=request.get("state"),
+ )
+
+ response_info["cookie"] = [_cookie_info]
+
+ return response_info
+
+ def authz_part2(self, request, session_id, **kwargs):
+ """
+ After the authentication this is where you should end up
+
+ :param request: The Authorization Request
+ :param session_id: Session identifier
+ :param kwargs: possible other parameters
+ :return: A redirect to the redirect_uri of the client
+ """
+ try:
+ resp_info = self.post_authentication(request, session_id, **kwargs)
+ except Exception as err:
+ return self.error_response({}, "server_error", err)
+
+ _context = self.server_get("endpoint_context")
+
+ if "check_session_iframe" in _context.provider_info:
+ salt = rndstr()
+ try:
+ authn_event = _context.session_manager.get_authentication_event(session_id)
+ except KeyError:
+ return self.error_response({}, "server_error", "No such session")
+ else:
+ if authn_event.is_valid() is False:
+ return self.error_response({}, "server_error", "Authentication has timed out")
+
+ _state = b64e(as_bytes(json.dumps({"authn_time": authn_event["authn_time"]})))
+
+ _session_cookie_content = _context.new_cookie(
+ name=_context.cookie_handler.name["session_management"], state=as_unicode(_state),
+ )
+
+ opbs_value = _session_cookie_content["value"]
+
+ logger.debug(
+ "compute_session_state: client_id=%s, origin=%s, opbs=%s, salt=%s",
+ request["client_id"],
+ resp_info["return_uri"],
+ opbs_value,
+ salt,
+ )
+
+ _session_state = compute_session_state(
+ opbs_value, salt, request["client_id"], resp_info["return_uri"]
+ )
+
+ if _session_cookie_content:
+ if "cookie" in resp_info:
+ resp_info["cookie"].append(_session_cookie_content)
+ else:
+ resp_info["cookie"] = [_session_cookie_content]
+
+ resp_info["response_args"]["session_state"] = _session_state
+
+ # Mix-Up mitigation
+ resp_info["response_args"]["iss"] = _context.issuer
+ resp_info["response_args"]["client_id"] = request["client_id"]
+
+ return resp_info
+
+ def do_request_user(self, request_info, **kwargs):
+ return kwargs
+
+ def process_request(
+ self,
+ request: Optional[Union[Message, dict]] = None,
+ http_info: Optional[dict] = None,
+ **kwargs,
+ ):
+ """ The AuthorizationRequest endpoint
+
+ :param http_info: Information on the HTTP request
+ :param request: The authorization request as a Message instance
+ :return: dictionary
+ """
+
+ if isinstance(request, self.error_cls):
+ return request
+
+ _cid = request["client_id"]
+ _context = self.server_get("endpoint_context")
+ cinfo = _context.cdb[_cid]
+ logger.debug("client {}: {}".format(_cid, cinfo))
+
+ # this apply the default optionally deny_unknown_scopes policy
+ if cinfo:
+ check_unknown_scopes_policy(request, cinfo, _context)
+
+ if http_info is None:
+ http_info = {}
+
+ _cookies = http_info.get("cookie")
+ if _cookies:
+ _cookies = _context.cookie_handler.parse_cookie("oidcop", _cookies)
+
+ kwargs = self.do_request_user(request_info=request, **kwargs)
+
+ info = self.setup_auth(request, request["redirect_uri"], cinfo, _cookies, **kwargs)
+
+ if "error" in info:
+ return info
+
+ _function = info.get("function")
+ if not _function:
+ logger.debug("- authenticated -")
+ logger.debug("AREQ keys: %s" % request.keys())
+ return self.authz_part2(request=request, cookie=_cookies, **info)
+
+ try:
+ # Run the authentication function
+ return {
+ "http_response": _function(**info["args"]),
+ "return_uri": request["redirect_uri"],
+ }
+ except Exception as err:
+ logger.exception(err)
+ return {"http_response": "Internal error: {}".format(err)}
+
+
+class AllowedAlgorithms:
+ def __init__(self, algorithm_parameters):
+ self.algorithm_parameters = algorithm_parameters
+
+ def __call__(self, client_id, endpoint_context, alg, alg_type):
+ _cinfo = endpoint_context.cdb[client_id]
+ _pinfo = endpoint_context.provider_info
+
+ _reg, _sup = self.algorithm_parameters[alg_type]
+ _allowed = _cinfo.get(_reg)
+ if _allowed is None:
+ _allowed = _pinfo.get(_sup)
+
+ if alg not in _allowed:
+ logger.error("Signing alg user: {} not among allowed: {}".format(alg, _allowed))
+ raise ValueError("Not allowed '%s' algorithm used", alg)
+
+
+def re_authenticate(request, authn) -> bool:
+ """
+ This is where you can demand reauthentication even though the authentication in use
+ is still valid.
+
+ :param request:
+ :param authn:
+ :return:
+ """
+ return False
diff --git a/src/oidcop/oauth2/introspection.py b/src/oidcop/oauth2/introspection.py
new file mode 100644
index 00000000..de32f720
--- /dev/null
+++ b/src/oidcop/oauth2/introspection.py
@@ -0,0 +1,127 @@
+"""Implements RFC7662"""
+import logging
+from typing import Optional
+
+from oidcmsg import oauth2
+
+from oidcop.endpoint import Endpoint
+from oidcop.token.exception import UnknownToken
+
+LOGGER = logging.getLogger(__name__)
+
+
+class Introspection(Endpoint):
+ """Implements RFC 7662"""
+
+ request_cls = oauth2.TokenIntrospectionRequest
+ response_cls = oauth2.TokenIntrospectionResponse
+ request_format = "urlencoded"
+ response_format = "json"
+ endpoint_name = "introspection_endpoint"
+ name = "introspection"
+
+ def __init__(self, server_get, **kwargs):
+ Endpoint.__init__(self, server_get, **kwargs)
+ self.offset = kwargs.get("offset", 0)
+
+ def _introspect(self, token, client_id, grant):
+ # Make sure that the token is an access_token or a refresh_token
+ if token.token_class not in ["access_token", "refresh_token"]:
+ return None
+
+ if not token.is_active():
+ return None
+
+ scope = token.scope
+ if not scope:
+ if token.based_on:
+ scope = grant.find_scope(token.based_on)
+ else:
+ scope = grant.scope
+ aud = token.resources
+ if not aud:
+ aud = grant.resources
+
+ _context = self.server_get("endpoint_context")
+ ret = {
+ "active": True,
+ "scope": " ".join(scope),
+ "client_id": client_id,
+ "token_class": token.token_class,
+ "exp": token.expires_at,
+ "iat": token.issued_at,
+ "sub": grant.sub,
+ "iss": _context.issuer,
+ }
+
+ try:
+ _token_type = token.token_type
+ except AttributeError:
+ _token_type = None
+
+ if _token_type:
+ ret["token_type"] = _token_type
+
+ if aud:
+ ret["aud"] = aud
+
+ token_args = {}
+ for meth in _context.token_args_methods:
+ token_args = meth(_context, client_id, token_args)
+
+ if token_args:
+ ret.update(token_args)
+
+ return ret
+
+ def process_request(self, request=None, release: Optional[list] = None, **kwargs):
+ """
+
+ :param request: The authorization request as a dictionary
+ :param release: Information about what should be released
+ :param kwargs:
+ :return:
+ """
+ _introspect_request = self.request_cls(**request)
+ if "error" in _introspect_request:
+ return _introspect_request
+
+ request_token = _introspect_request["token"]
+ _resp = self.response_cls(active=False)
+ _context = self.server_get("endpoint_context")
+
+ try:
+ _session_info = _context.session_manager.get_session_info_by_token(
+ request_token, grant=True
+ )
+ except UnknownToken:
+ return {"response_args": _resp}
+
+ grant = _session_info["grant"]
+ _token = grant.get_token(request_token)
+
+ _info = self._introspect(_token, _session_info["client_id"], _session_info["grant"])
+ if _info is None:
+ return {"response_args": _resp}
+
+ if release:
+ if "username" in release:
+ try:
+ _info["username"] = _session_info["user_id"]
+ except KeyError:
+ pass
+
+ _resp.update(_info)
+ _resp.weed()
+
+ _claims_restriction = grant.claims.get("introspection")
+ if _claims_restriction:
+ user_info = _context.claims_interface.get_user_claims(
+ _session_info["user_id"], _claims_restriction
+ )
+ if user_info:
+ _resp.update(user_info)
+
+ _resp["active"] = True
+
+ return {"response_args": _resp}
diff --git a/src/oidcop/oauth2/pushed_authorization.py b/src/oidcop/oauth2/pushed_authorization.py
new file mode 100644
index 00000000..d1f84f81
--- /dev/null
+++ b/src/oidcop/oauth2/pushed_authorization.py
@@ -0,0 +1,38 @@
+import uuid
+
+from oidcmsg import oauth2
+
+from oidcop.oauth2.authorization import Authorization
+
+
+class PushedAuthorization(Authorization):
+ request_cls = oauth2.PushedAuthorizationRequest
+ response_cls = oauth2.Message
+ endpoint_name = "pushed_authorization_request_endpoint"
+ request_placement = "body"
+ request_format = "urlencoded"
+ response_placement = "body"
+ response_format = "json"
+ name = "pushed_authorization"
+
+ def __init__(self, server_get, **kwargs):
+ Authorization.__init__(self, server_get, **kwargs)
+ # self.pre_construct.append(self._pre_construct)
+ self.post_parse_request.append(self._post_parse_request)
+ self.ttl = kwargs.get("ttl", 3600)
+
+ def process_request(self, request=None, **kwargs):
+ """
+ Store the request and return a URI.
+
+ :param request:
+ """
+ # create URN
+
+ _urn = "urn:uuid:{}".format(uuid.uuid4())
+ self.server_get("endpoint_context").par_db[_urn] = request
+
+ return {
+ "http_response": {"request_uri": _urn, "expires_in": self.ttl},
+ "return_uri": request["redirect_uri"],
+ }
diff --git a/src/oidcop/oauth2/token.py b/src/oidcop/oauth2/token.py
new file mode 100755
index 00000000..d35af5d3
--- /dev/null
+++ b/src/oidcop/oauth2/token.py
@@ -0,0 +1,422 @@
+import logging
+from typing import Optional
+from typing import Union
+
+from cryptojwt.jwe.exception import JWEException
+from cryptojwt.jwt import utc_time_sans_frac
+
+from oidcmsg import oidc
+from oidcmsg.message import Message
+from oidcmsg.oauth2 import AccessTokenResponse
+from oidcmsg.oauth2 import ResponseMessage
+from oidcmsg.oidc import RefreshAccessTokenRequest
+from oidcmsg.oidc import TokenErrorResponse
+from oidcmsg.time_util import time_sans_frac
+
+from oidcop import sanitize
+from oidcop.endpoint import Endpoint
+from oidcop.exception import ProcessError
+from oidcop.session.grant import AuthorizationCode
+from oidcop.session.grant import Grant
+from oidcop.session.grant import RefreshToken
+from oidcop.session.token import MintingNotAllowed
+from oidcop.session.token import SessionToken
+from oidcop.token.exception import UnknownToken
+from oidcop.util import importer
+
+logger = logging.getLogger(__name__)
+
+
+class TokenEndpointHelper(object):
+ def __init__(self, endpoint, config=None):
+ self.endpoint = endpoint
+ self.config = config
+ self.error_cls = self.endpoint.error_cls
+
+ def post_parse_request(
+ self, request: Union[Message, dict], client_id: Optional[str] = "", **kwargs
+ ):
+ """Context specific parsing of the request.
+ This is done after general request parsing and before processing
+ the request.
+ """
+ raise NotImplementedError
+
+ def process_request(self, req: Union[Message, dict], **kwargs):
+ """Acts on a process request."""
+ raise NotImplementedError
+
+ def _mint_token(
+ self,
+ token_class: str,
+ grant: Grant,
+ session_id: str,
+ client_id: str,
+ based_on: Optional[SessionToken] = None,
+ token_args: Optional[dict] = None,
+ token_type: Optional[str] = ""
+ ) -> SessionToken:
+ _context = self.endpoint.server_get("endpoint_context")
+ _mngr = _context.session_manager
+ usage_rules = grant.usage_rules.get(type)
+ if usage_rules:
+ _exp_in = usage_rules.get("expires_in")
+ else:
+ _exp_in = 0
+
+ token_args = token_args or {}
+ for meth in _context.token_args_methods:
+ token_args = meth(_context, client_id, token_args)
+
+ if token_args:
+ _args = {"token_args": token_args}
+ else:
+ _args = {}
+
+ token = grant.mint_token(
+ session_id,
+ endpoint_context=_context,
+ token_class=token_class,
+ token_handler=_mngr.token_handler[token_class],
+ based_on=based_on,
+ usage_rules=usage_rules,
+ token_type=token_type,
+ **_args,
+ )
+
+ if _exp_in:
+ if isinstance(_exp_in, str):
+ _exp_in = int(_exp_in)
+
+ if _exp_in:
+ token.expires_at = time_sans_frac() + _exp_in
+
+ _context.session_manager.set(_context.session_manager.unpack_session_key(session_id), grant)
+
+ return token
+
+
+class AccessTokenHelper(TokenEndpointHelper):
+ def process_request(self, req: Union[Message, dict], **kwargs):
+ """
+
+ :param req:
+ :param kwargs:
+ :return:
+ """
+ _context = self.endpoint.server_get("endpoint_context")
+
+ _mngr = _context.session_manager
+ _log_debug = logger.debug
+
+ if req["grant_type"] != "authorization_code":
+ return self.error_cls(error="invalid_request", error_description="Unknown grant_type")
+
+ try:
+ _access_code = req["code"].replace(" ", "+")
+ except KeyError: # Missing code parameter - absolutely fatal
+ return self.error_cls(error="invalid_request", error_description="Missing code")
+
+ _session_info = _mngr.get_session_info_by_token(_access_code, grant=True)
+ grant = _session_info["grant"]
+
+ _based_on = grant.get_token(_access_code)
+ _supports_minting = _based_on.usage_rules.get("supports_minting", [])
+
+ _authn_req = grant.authorization_request
+
+ # If redirect_uri was in the initial authorization request
+ # verify that the one given here is the correct one.
+ if "redirect_uri" in _authn_req:
+ if req["redirect_uri"] != _authn_req["redirect_uri"]:
+ return self.error_cls(
+ error="invalid_request", error_description="redirect_uri mismatch"
+ )
+
+ _log_debug("All checks OK")
+
+ issue_refresh = kwargs.get("issue_refresh", False)
+
+ _response = {
+ "token_type": "Bearer",
+ "scope": grant.scope,
+ }
+
+ if "access_token" in _supports_minting:
+ try:
+ token = self._mint_token(
+ token_class="access_token",
+ grant=grant,
+ session_id=_session_info["session_id"],
+ client_id=_session_info["client_id"],
+ based_on=_based_on,
+ )
+ except MintingNotAllowed as err:
+ logger.warning(err)
+ else:
+ _response["access_token"] = token.value
+ if token.expires_at:
+ _response["expires_in"] = token.expires_at - utc_time_sans_frac()
+
+ if issue_refresh and "refresh_token" in _supports_minting:
+ try:
+ refresh_token = self._mint_token(
+ token_class="refresh_token",
+ grant=grant,
+ session_id=_session_info["session_id"],
+ client_id=_session_info["client_id"],
+ based_on=_based_on,
+ )
+ except MintingNotAllowed as err:
+ logger.warning(err)
+ else:
+ _response["refresh_token"] = refresh_token.value
+
+ # since the grant content has changed. Make sure it's stored
+ _mngr[_session_info["session_id"]] = grant
+
+ _based_on.register_usage()
+
+ return _response
+
+ def post_parse_request(
+ self, request: Union[Message, dict], client_id: Optional[str] = "", **kwargs
+ ):
+ """
+ This is where clients come to get their access tokens
+
+ :param request: The request
+ :param client_id: Client identifier
+ :returns:
+ """
+
+ _mngr = self.endpoint.server_get("endpoint_context").session_manager
+ try:
+ _session_info = _mngr.get_session_info_by_token(request["code"], grant=True)
+ except (KeyError, UnknownToken):
+ logger.error("Access Code invalid")
+ return self.error_cls(error="invalid_grant", error_description="Unknown code")
+
+ grant = _session_info["grant"]
+ code = grant.get_token(request["code"])
+ if not isinstance(code, AuthorizationCode):
+ return self.error_cls(error="invalid_request", error_description="Wrong token type")
+
+ if code.is_active() is False:
+ return self.error_cls(error="invalid_request", error_description="Code inactive")
+
+ _auth_req = grant.authorization_request
+
+ if "client_id" not in request: # Optional for access token request
+ request["client_id"] = _auth_req["client_id"]
+
+ logger.debug("%s: %s" % (request.__class__.__name__, sanitize(request)))
+
+ return request
+
+
+class RefreshTokenHelper(TokenEndpointHelper):
+ def process_request(self, req: Union[Message, dict], **kwargs):
+ _context = self.endpoint.server_get("endpoint_context")
+ _mngr = _context.session_manager
+
+ if req["grant_type"] != "refresh_token":
+ return self.error_cls(error="invalid_request", error_description="Wrong grant_type")
+
+ token_value = req["refresh_token"]
+ _session_info = _mngr.get_session_info_by_token(token_value, grant=True)
+
+ _grant = _session_info["grant"]
+ token = _grant.get_token(token_value)
+ access_token = self._mint_token(
+ token_class="access_token",
+ grant=_grant,
+ session_id=_session_info["session_id"],
+ client_id=_session_info["client_id"],
+ based_on=token,
+ )
+
+ _resp = {
+ "access_token": access_token.value,
+ "token_type": access_token.token_type,
+ "scope": _grant.scope,
+ }
+
+ if access_token.expires_at:
+ _resp["expires_in"] = access_token.expires_at - utc_time_sans_frac()
+
+ _mints = token.usage_rules.get("supports_minting")
+ if "refresh_token" in _mints:
+ refresh_token = self._mint_token(
+ token_class="refresh_token",
+ grant=_grant,
+ session_id=_session_info["session_id"],
+ client_id=_session_info["client_id"],
+ based_on=token,
+ )
+ refresh_token.usage_rules = token.usage_rules.copy()
+ _resp["refresh_token"] = refresh_token.value
+
+ token.register_usage()
+
+ return _resp
+
+ def post_parse_request(
+ self, request: Union[Message, dict], client_id: Optional[str] = "", **kwargs
+ ):
+ """
+ This is where clients come to refresh their access tokens
+
+ :param request: The request
+ :param client_id: Client identifier
+ :returns:
+ """
+
+ request = RefreshAccessTokenRequest(**request.to_dict())
+ _context = self.endpoint.server_get("endpoint_context")
+ try:
+ keyjar = _context.keyjar
+ except AttributeError:
+ keyjar = ""
+
+ request.verify(keyjar=keyjar, opponent_id=client_id)
+
+ _mngr = _context.session_manager
+ try:
+ _session_info = _mngr.get_session_info_by_token(request["refresh_token"], grant=True)
+ except KeyError:
+ logger.error("Access Code invalid")
+ return self.error_cls(error="invalid_grant")
+
+ token = _session_info["grant"].get_token(request["refresh_token"])
+
+ if not isinstance(token, RefreshToken):
+ return self.error_cls(error="invalid_request", error_description="Wrong token type")
+
+ if token.is_active() is False:
+ return self.error_cls(
+ error="invalid_request", error_description="Refresh token inactive"
+ )
+
+ return request
+
+
+class Token(Endpoint):
+ request_cls = Message
+ response_cls = AccessTokenResponse
+ error_cls = TokenErrorResponse
+ request_format = "json"
+ request_placement = "body"
+ response_format = "json"
+ response_placement = "body"
+ endpoint_name = "token_endpoint"
+ name = "token"
+ default_capabilities = {"token_endpoint_auth_signing_alg_values_supported": None}
+ helper_by_grant_type = {
+ "authorization_code": AccessTokenHelper,
+ "refresh_token": RefreshTokenHelper,
+ }
+
+ def __init__(self, server_get, new_refresh_token=False, **kwargs):
+ Endpoint.__init__(self, server_get, **kwargs)
+ self.post_parse_request.append(self._post_parse_request)
+ if "client_authn_method" in kwargs:
+ self.endpoint_info["token_endpoint_auth_methods_supported"] = kwargs[
+ "client_authn_method"
+ ]
+ self.allow_refresh = False
+ self.new_refresh_token = new_refresh_token
+ self.configure_grant_types(kwargs.get("grant_types_supported"))
+
+ def configure_grant_types(self, grant_types_supported):
+ if grant_types_supported is None:
+ self.helper = {k: v(self) for k, v in self.helper_by_grant_type.items()}
+ return
+
+ self.helper = {}
+ # TODO: do we want to allow any grant_type?
+ for grant_type, grant_type_options in grant_types_supported.items():
+ _conf = grant_type_options.get("kwargs", {})
+ if _conf is False:
+ continue
+
+ try:
+ grant_class = grant_type_options["class"]
+ except (KeyError, TypeError):
+ raise ProcessError(
+ "Token Endpoint's grant types must be True, None or a dict with a"
+ " 'class' key."
+ )
+
+ if isinstance(grant_class, str):
+ try:
+ grant_class = importer(grant_class)
+ except (ValueError, AttributeError):
+ raise ProcessError(
+ f"Token Endpoint's grant type class {grant_class} can't" " be imported."
+ )
+
+ try:
+ self.helper[grant_type] = grant_class(self, _conf)
+ except Exception as e:
+ raise ProcessError(f"Failed to initialize class {grant_class}: {e}")
+
+ def _post_parse_request(
+ self, request: Union[Message, dict], client_id: Optional[str] = "", **kwargs
+ ):
+ _helper = self.helper.get(request["grant_type"])
+ if _helper:
+ return _helper.post_parse_request(request, client_id, **kwargs)
+ else:
+ return self.error_cls(
+ error="invalid_request",
+ error_description=f"Unsupported grant_type: {request['grant_type']}",
+ )
+
+ def process_request(self, request: Optional[Union[Message, dict]] = None, **kwargs):
+ """
+
+ :param request:
+ :param kwargs:
+ :return: Dictionary with response information
+ """
+ if isinstance(request, self.error_cls):
+ return request
+
+ if request is None:
+ return self.error_cls(error="invalid_request")
+
+ try:
+ _helper = self.helper.get(request["grant_type"])
+ if _helper:
+ response_args = _helper.process_request(request, **kwargs)
+ else:
+ return self.error_cls(
+ error="invalid_request",
+ error_description=f"Unsupported grant_type: {request['grant_type']}",
+ )
+ except JWEException as err:
+ return self.error_cls(error="invalid_request", error_description="%s" % err)
+
+ if isinstance(response_args, ResponseMessage):
+ return response_args
+
+ _access_token = response_args["access_token"]
+ _context = self.server_get("endpoint_context")
+ _session_info = _context.session_manager.get_session_info_by_token(
+ _access_token, grant=True
+ )
+
+ _cookie = _context.new_cookie(
+ name=_context.cookie_handler.name["session"],
+ sub=_session_info["grant"].sub,
+ sid=_context.session_manager.session_key(
+ _session_info["user_id"], _session_info["user_id"], _session_info["grant"].id,
+ ),
+ )
+
+ _headers = [("Content-type", "application/json")]
+ resp = {"response_args": response_args, "http_headers": _headers}
+ if _cookie:
+ resp["cookie"] = [_cookie]
+ return resp
diff --git a/src/oidcop/oidc/__init__.py b/src/oidcop/oidc/__init__.py
new file mode 100755
index 00000000..e69de29b
diff --git a/src/oidcop/oidc/add_on/__init__.py b/src/oidcop/oidc/add_on/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/oidcop/oidc/add_on/custom_scopes.py b/src/oidcop/oidc/add_on/custom_scopes.py
new file mode 100644
index 00000000..1366548f
--- /dev/null
+++ b/src/oidcop/oidc/add_on/custom_scopes.py
@@ -0,0 +1,28 @@
+import logging
+
+from oidcop.scopes import SCOPE2CLAIMS
+
+LOGGER = logging.getLogger(__name__)
+
+
+def add_custom_scopes(endpoint, **kwargs):
+ """
+ :param endpoint: A dictionary with endpoint instances as values
+ """
+ # Just need an endpoint, anyone will do
+ _endpoint = list(endpoint.values())[0]
+
+ _scopes2claims = SCOPE2CLAIMS.copy()
+ _scopes2claims.update(kwargs)
+ _context = _endpoint.server_get("endpoint_context")
+ _context.scope2claims = _scopes2claims
+
+ pi = _context.provider_info
+ _scopes = set(pi.get("scopes_supported", []))
+ _scopes.update(set(kwargs.keys()))
+ pi["scopes_supported"] = list(_scopes)
+
+ _claims = set(pi.get("claims_supported", []))
+ for vals in kwargs.values():
+ _claims.update(set(vals))
+ pi["claims_supported"] = list(_claims)
diff --git a/src/oidcop/oidc/add_on/pkce.py b/src/oidcop/oidc/add_on/pkce.py
new file mode 100644
index 00000000..75b541d6
--- /dev/null
+++ b/src/oidcop/oidc/add_on/pkce.py
@@ -0,0 +1,145 @@
+import hashlib
+import logging
+from typing import Dict
+
+from cryptojwt.utils import b64e
+from oidcmsg.oauth2 import (
+ AuthorizationErrorResponse,
+ RefreshAccessTokenRequest,
+ TokenExchangeRequest,
+)
+from oidcmsg.oidc import TokenErrorResponse
+
+from oidcop.endpoint import Endpoint
+
+LOGGER = logging.getLogger(__name__)
+
+
+def hash_fun(f):
+ def wrapper(code_verifier):
+ _h = f(code_verifier.encode("ascii")).digest()
+ _cc = b64e(_h)
+ return _cc.decode("ascii")
+
+ return wrapper
+
+
+CC_METHOD = {
+ "plain": lambda x: x,
+ "S256": hash_fun(hashlib.sha256),
+ "S384": hash_fun(hashlib.sha384),
+ "S512": hash_fun(hashlib.sha512),
+}
+
+
+def post_authn_parse(request, client_id, endpoint_context, **kwargs):
+ """
+
+ :param request:
+ :param client_id:
+ :param endpoint_context:
+ :param kwargs:
+ :return:
+ """
+ if endpoint_context.args["pkce"]["essential"] and "code_challenge" not in request:
+ return AuthorizationErrorResponse(
+ error="invalid_request", error_description="Missing required code_challenge",
+ )
+
+ if "code_challenge_method" not in request:
+ request["code_challenge_method"] = "plain"
+
+ if "code_challenge" in request and (
+ request["code_challenge_method"]
+ not in endpoint_context.args["pkce"]["code_challenge_methods"]
+ ):
+ return AuthorizationErrorResponse(
+ error="invalid_request",
+ error_description="Unsupported code_challenge_method={}".format(
+ request["code_challenge_method"]
+ ),
+ )
+
+ return request
+
+
+def verify_code_challenge(code_verifier, code_challenge, code_challenge_method="S256"):
+ """
+ Verify a PKCE (RFC7636) code challenge.
+
+
+ :param code_verifier: The origin
+ :param code_challenge: The transformed verifier used as challenge
+ :return:
+ """
+ if CC_METHOD[code_challenge_method](code_verifier) != code_challenge:
+ LOGGER.error("PKCE Code Challenge check failed")
+ return False
+
+ LOGGER.debug("PKCE Code Challenge check succeeded")
+ return True
+
+
+def post_token_parse(request, client_id, endpoint_context, **kwargs):
+ """
+ To be used as a post_parse_request function.
+
+ :param token_request:
+ :return:
+ """
+ if isinstance(
+ request, (AuthorizationErrorResponse, RefreshAccessTokenRequest, TokenExchangeRequest),
+ ):
+ return request
+
+ try:
+ _session_info = endpoint_context.session_manager.get_session_info_by_token(
+ request["code"], grant=True
+ )
+ except KeyError:
+ return TokenErrorResponse(error="invalid_grant", error_description="Unknown access grant")
+
+ _authn_req = _session_info["grant"].authorization_request
+
+ if "code_challenge" in _authn_req:
+ if "code_verifier" not in request:
+ return TokenErrorResponse(
+ error="invalid_grant", error_description="Missing code_verifier",
+ )
+
+ _method = _authn_req["code_challenge_method"]
+
+ if not verify_code_challenge(
+ request["code_verifier"], _authn_req["code_challenge"], _method,
+ ):
+ return TokenErrorResponse(error="invalid_grant", error_description="PKCE check failed")
+
+ return request
+
+
+def add_pkce_support(endpoint: Dict[str, Endpoint], **kwargs):
+ authn_endpoint = endpoint.get("authorization")
+ if authn_endpoint is None:
+ LOGGER.warning("No authorization endpoint found, skipping PKCE configuration")
+ return
+
+ token_endpoint = endpoint.get("token")
+ if token_endpoint is None:
+ LOGGER.warning("No token endpoint found, skipping PKCE configuration")
+ return
+
+ authn_endpoint.post_parse_request.append(post_authn_parse)
+ token_endpoint.post_parse_request.append(post_token_parse)
+
+ if "essential" not in kwargs:
+ kwargs["essential"] = False
+
+ code_challenge_methods = kwargs.get("code_challenge_methods", CC_METHOD.keys())
+
+ kwargs["code_challenge_methods"] = {}
+ for method in code_challenge_methods:
+ if method not in CC_METHOD:
+ raise ValueError("Unsupported method: {}".format(method))
+ kwargs["code_challenge_methods"][method] = CC_METHOD[method]
+
+ authn_endpoint.server_get("endpoint_context").args["pkce"] = kwargs
diff --git a/src/oidcop/oidc/authorization.py b/src/oidcop/oidc/authorization.py
new file mode 100755
index 00000000..670b42a7
--- /dev/null
+++ b/src/oidcop/oidc/authorization.py
@@ -0,0 +1,105 @@
+import logging
+from typing import Callable
+from urllib.parse import urlsplit
+
+from oidcmsg import oidc
+from oidcmsg.oidc import Claims
+from oidcmsg.oidc import verified_claim_name
+
+from oidcop.oauth2 import authorization
+
+logger = logging.getLogger(__name__)
+
+
+def proposed_user(request):
+ cn = verified_claim_name("it_token_hint")
+ if request.get(cn):
+ return request[cn].get("sub", "")
+ return ""
+
+
+def acr_claims(request):
+ acrdef = None
+
+ _claims = request.get("claims")
+ if isinstance(_claims, str):
+ _claims = Claims().from_json(_claims)
+
+ if _claims:
+ _id_token_claim = _claims.get("id_token")
+ if _id_token_claim:
+ acrdef = _id_token_claim.get("acr")
+
+ if isinstance(acrdef, dict):
+ if acrdef.get("value"):
+ return [acrdef["value"]]
+ elif acrdef.get("values"):
+ return acrdef["values"]
+
+
+def host_component(url):
+ res = urlsplit(url)
+ return "{}://{}".format(res.scheme, res.netloc)
+
+
+ALG_PARAMS = {
+ "sign": ["request_object_signing_alg", "request_object_signing_alg_values_supported",],
+ "enc_alg": ["request_object_encryption_alg", "request_object_encryption_alg_values_supported",],
+ "enc_enc": ["request_object_encryption_enc", "request_object_encryption_enc_values_supported",],
+}
+
+
+def re_authenticate(request, authn):
+ if "prompt" in request and "login" in request["prompt"]:
+ if authn.done(request):
+ return True
+
+ return False
+
+
+class Authorization(authorization.Authorization):
+ request_cls = oidc.AuthorizationRequest
+ response_cls = oidc.AuthorizationResponse
+ error_cls = oidc.AuthorizationErrorResponse
+ request_format = "urlencoded"
+ response_format = "urlencoded"
+ response_placement = "url"
+ endpoint_name = "authorization_endpoint"
+ name = "authorization"
+ default_capabilities = {
+ "claims_parameter_supported": True,
+ "request_parameter_supported": True,
+ "request_uri_parameter_supported": True,
+ "response_types_supported": [
+ "code",
+ "token",
+ "id_token",
+ "code token",
+ "code id_token",
+ "id_token token",
+ "code id_token token",
+ ],
+ "response_modes_supported": ["query", "fragment", "form_post"],
+ "request_object_signing_alg_values_supported": None,
+ "request_object_encryption_alg_values_supported": None,
+ "request_object_encryption_enc_values_supported": None,
+ "grant_types_supported": ["authorization_code", "implicit"],
+ "claim_types_supported": ["normal", "aggregated", "distributed"],
+ }
+
+ def __init__(self, server_get: Callable, **kwargs):
+ authorization.Authorization.__init__(self, server_get, **kwargs)
+ # self.pre_construct.append(self._pre_construct)
+ self.post_parse_request.append(self._do_request_uri)
+ self.post_parse_request.append(self._post_parse_request)
+
+ def do_request_user(self, request_info, **kwargs):
+ if proposed_user(request_info):
+ kwargs["req_user"] = proposed_user(request_info)
+ else:
+ _login_hint = request_info.get("login_hint")
+ if _login_hint:
+ _context = self.server_get("endpoint_context")
+ if _context.login_hint_lookup:
+ kwargs["req_user"] = _context.login_hint_lookup(_login_hint)
+ return kwargs
diff --git a/src/oidcop/oidc/discovery.py b/src/oidcop/oidc/discovery.py
new file mode 100755
index 00000000..b4809b4f
--- /dev/null
+++ b/src/oidcop/oidc/discovery.py
@@ -0,0 +1,42 @@
+from oidcmsg import oidc
+from oidcmsg.oidc import JRD
+from oidcmsg.oidc import Link
+
+from oidcop.endpoint import Endpoint
+
+OIC_ISSUER = "http://openid.net/specs/connect/1.0/issuer"
+
+
+class Discovery(Endpoint):
+ request_cls = oidc.DiscoveryRequest
+ response_cls = JRD
+ request_format = "urlencoded"
+ response_format = "json"
+ name = "discovery"
+
+ def do_response(self, response_args=None, request=None, **kwargs):
+ """
+ **Placeholder for the time being**
+
+ :param response_args:
+ :param request:
+ :param kwargs: request arguments
+ :return: Response information
+ """
+
+ links = [Link(href=h, rel=OIC_ISSUER) for h in kwargs["hrefs"]]
+
+ _response = JRD(subject=kwargs["subject"], links=links)
+
+ info = {
+ "response": _response.to_json(),
+ "http_headers": [("Content-type", "application/json")],
+ }
+
+ return info
+
+ def process_request(self, request=None, **kwargs):
+ return {
+ "subject": request["resource"],
+ "hrefs": [self.server_get("endpoint_context").issuer],
+ }
diff --git a/src/oidcop/oidc/provider_config.py b/src/oidcop/oidc/provider_config.py
new file mode 100755
index 00000000..0a3ff443
--- /dev/null
+++ b/src/oidcop/oidc/provider_config.py
@@ -0,0 +1,37 @@
+import logging
+
+from oidcmsg import oidc
+
+from oidcop.endpoint import Endpoint
+
+logger = logging.getLogger(__name__)
+
+
+class ProviderConfiguration(Endpoint):
+ request_cls = oidc.Message
+ response_cls = oidc.ProviderConfigurationResponse
+ request_format = ""
+ response_format = "json"
+ name = "provider_config"
+ default_capabilities = {"require_request_uri_registration": None}
+
+ def __init__(self, server_get, **kwargs):
+ Endpoint.__init__(self, server_get=server_get, **kwargs)
+ self.pre_construct.append(self.add_endpoints)
+
+ def add_endpoints(self, request, client_id, endpoint_context, **kwargs):
+ for endpoint in [
+ "authorization_endpoint",
+ "registration_endpoint",
+ "token_endpoint",
+ "userinfo_endpoint",
+ "end_session_endpoint",
+ ]:
+ endp_instance = self.server_get("endpoint", endpoint)
+ if endp_instance:
+ request[endpoint] = endp_instance.endpoint_path
+
+ return request
+
+ def process_request(self, request=None, **kwargs):
+ return {"response_args": self.server_get("endpoint_context").provider_info}
diff --git a/src/oidcop/oidc/read_registration.py b/src/oidcop/oidc/read_registration.py
new file mode 100644
index 00000000..a5dbb305
--- /dev/null
+++ b/src/oidcop/oidc/read_registration.py
@@ -0,0 +1,31 @@
+from oidcmsg.message import Message
+from oidcmsg.oauth2 import ResponseMessage
+from oidcmsg.oidc import RegistrationResponse
+
+from oidcop.endpoint import Endpoint
+from oidcop.oidc.registration import comb_uri
+
+
+class RegistrationRead(Endpoint):
+ request_cls = Message
+ response_cls = RegistrationResponse
+ error_response = ResponseMessage
+ request_format = "urlencoded"
+ request_placement = "url"
+ response_format = "json"
+ name = "registration_read"
+
+ def get_client_id_from_token(self, endpoint_context, token, request=None):
+ if "client_id" in request:
+ if (
+ request["client_id"]
+ == self.server_get("endpoint_context").registration_access_token[token]
+ ):
+ return request["client_id"]
+ return ""
+
+ def process_request(self, request=None, **kwargs):
+ _cli_info = self.server_get("endpoint_context").cdb[request["client_id"]]
+ args = {k: v for k, v in _cli_info.items() if k in RegistrationResponse.c_param}
+ comb_uri(args)
+ return {"response_args": RegistrationResponse(**args)}
diff --git a/src/oidcop/oidc/registration.py b/src/oidcop/oidc/registration.py
new file mode 100755
index 00000000..71aa7374
--- /dev/null
+++ b/src/oidcop/oidc/registration.py
@@ -0,0 +1,476 @@
+import hashlib
+import hmac
+import json
+import logging
+import secrets
+import time
+from typing import List
+from urllib.parse import urlencode
+from urllib.parse import urlparse
+
+from cryptojwt.jws.utils import alg2keytype
+from cryptojwt.utils import as_bytes
+from oidcmsg.exception import MessageException
+from oidcmsg.oauth2 import ResponseMessage
+from oidcmsg.oidc import ClientRegistrationErrorResponse
+from oidcmsg.oidc import RegistrationRequest
+from oidcmsg.oidc import RegistrationResponse
+from oidcmsg.time_util import utc_time_sans_frac
+
+from oidcop import rndstr
+from oidcop import sanitize
+from oidcop.endpoint import Endpoint
+from oidcop.exception import CapabilitiesMisMatch
+from oidcop.exception import InvalidRedirectURIError
+from oidcop.exception import InvalidSectorIdentifier
+from oidcop.util import importer
+from oidcop.util import split_uri
+
+PREFERENCE2PROVIDER = {
+ # "require_signed_request_object": "request_object_algs_supported",
+ "request_object_signing_alg": "request_object_signing_alg_values_supported",
+ "request_object_encryption_alg": "request_object_encryption_alg_values_supported",
+ "request_object_encryption_enc": "request_object_encryption_enc_values_supported",
+ "userinfo_signed_response_alg": "userinfo_signing_alg_values_supported",
+ "userinfo_encrypted_response_alg": "userinfo_encryption_alg_values_supported",
+ "userinfo_encrypted_response_enc": "userinfo_encryption_enc_values_supported",
+ "id_token_signed_response_alg": "id_token_signing_alg_values_supported",
+ "id_token_encrypted_response_alg": "id_token_encryption_alg_values_supported",
+ "id_token_encrypted_response_enc": "id_token_encryption_enc_values_supported",
+ "default_acr_values": "acr_values_supported",
+ "subject_type": "subject_types_supported",
+ "token_endpoint_auth_method": "token_endpoint_auth_methods_supported",
+ "token_endpoint_auth_signing_alg": "token_endpoint_auth_signing_alg_values_supported",
+ "response_types": "response_types_supported",
+ "grant_types": "grant_types_supported",
+}
+
+logger = logging.getLogger(__name__)
+
+
+def match_sp_sep(first, second):
+ """
+ Verify that all the values in 'first' appear in 'second'.
+ The values can either be in the form of lists or as space separated
+ items.
+
+ :param first:
+ :param second:
+ :return: True/False
+ """
+ if isinstance(first, list):
+ one = [set(v.split(" ")) for v in first]
+ else:
+ one = [{v} for v in first.split(" ")]
+
+ if isinstance(second, list):
+ other = [set(v.split(" ")) for v in second]
+ else:
+ other = [{v} for v in second.split(" ")]
+
+ # all values in one must appear in other
+ if any(rt not in other for rt in one):
+ return False
+ return True
+
+
+def verify_url(url: str, urlset: List[list]) -> bool:
+ part = urlparse(url)
+
+ for reg, qp in urlset:
+ _part = urlparse(reg)
+ if part.scheme == _part.scheme and part.netloc == _part.netloc:
+ return True
+
+ return False
+
+
+def secret(seed: str, sid: str):
+ msg = "{}{}{}".format(time.time(), secrets.token_urlsafe(16), sid).encode("utf-8")
+ csum = hmac.new(as_bytes(seed), msg, hashlib.sha224)
+ return csum.hexdigest()
+
+
+def comb_uri(args):
+ for param in ["redirect_uris", "post_logout_redirect_uris"]:
+ if param not in args:
+ continue
+
+ val = []
+ for base, query_dict in args[param]:
+ if query_dict:
+ query_string = urlencode(
+ [
+ (key, v)
+ for key in query_dict
+ for v in query_dict[key]
+ ]
+ )
+ val.append("{base}?{query_string}")
+ else:
+ val.append(base)
+
+ args[param] = val
+
+ request_uris = args.get("request_uris")
+ if request_uris:
+ val = []
+ for base, frag in request_uris:
+ if frag:
+ val.append("{}#{}".format(base, frag))
+ else:
+ val.append(base)
+ args["request_uris"] = val
+
+
+def random_client_id(length: int = 16, reserved: list = [], **kwargs):
+ # create new id och secret
+ client_id = rndstr(16)
+ # cdb client_id MUST be unique!
+ while client_id in reserved:
+ client_id = rndstr(16)
+ return client_id
+
+
+class Registration(Endpoint):
+ request_cls = RegistrationRequest
+ response_cls = RegistrationResponse
+ error_response = ClientRegistrationErrorResponse
+ request_format = "json"
+ request_placement = "body"
+ response_format = "json"
+ endpoint_name = "registration_endpoint"
+ name = "registration"
+
+ # default
+ # response_placement = 'body'
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Those that use seed wants bytes but I can only store str.
+ # seed
+ _seed = kwargs.get("seed") or rndstr(32)
+ self.seed = as_bytes(_seed)
+
+ def match_client_request(self, request):
+ _context = self.server_get("endpoint_context")
+ for _pref, _prov in PREFERENCE2PROVIDER.items():
+ if _pref in request:
+ if _pref in ["response_types", "default_acr_values"]:
+ if not match_sp_sep(request[_pref], _context.provider_info[_prov]):
+ raise CapabilitiesMisMatch(_pref)
+ else:
+ if isinstance(request[_pref], str):
+ if request[_pref] not in _context.provider_info[_prov]:
+ raise CapabilitiesMisMatch(_pref)
+ else:
+ if not set(request[_pref]).issubset(set(_context.provider_info[_prov])):
+ raise CapabilitiesMisMatch(_pref)
+
+ def do_client_registration(self, request, client_id, ignore=None):
+ if ignore is None:
+ ignore = []
+ _context = self.server_get("endpoint_context")
+ _cinfo = _context.cdb[client_id].copy()
+ logger.debug("_cinfo: %s" % sanitize(_cinfo))
+
+ for key, val in request.items():
+ if key not in ignore:
+ _cinfo[key] = val
+
+ if "post_logout_redirect_uris" in request:
+ plruri = []
+ for uri in request["post_logout_redirect_uris"]:
+ if urlparse(uri).fragment:
+ err = self.error_cls(
+ error="invalid_configuration_parameter",
+ error_description="post_logout_redirect_uris contains fragment",
+ )
+ return err
+ plruri.append(split_uri(uri))
+ _cinfo["post_logout_redirect_uris"] = plruri
+
+ if "redirect_uris" in request:
+ try:
+ ruri = self.verify_redirect_uris(request)
+ _cinfo["redirect_uris"] = ruri
+ except InvalidRedirectURIError as e:
+ return self.error_cls(error="invalid_redirect_uri", error_description=str(e))
+
+ if "request_uris" in request:
+ _uris = []
+ for uri in request["request_uris"]:
+ _up = urlparse(uri)
+ if _up.query:
+ err = self.error_cls(
+ error="invalid_configuration_parameter",
+ error_description="request_uris contains query part",
+ )
+ return err
+ if _up.fragment:
+ # store base and fragment
+ _uris.append(uri.split("#"))
+ else:
+ _uris.append([uri, ""])
+ _cinfo["request_uris"] = _uris
+
+ if "sector_identifier_uri" in request:
+ try:
+ (_cinfo["si_redirects"], _cinfo["sector_id"],) = self._verify_sector_identifier(
+ request
+ )
+ except InvalidSectorIdentifier as err:
+ return ResponseMessage(
+ error="invalid_configuration_parameter", error_description=str(err)
+ )
+
+ for item in ["policy_uri", "logo_uri", "tos_uri"]:
+ if item in request:
+ if verify_url(request[item], _cinfo["redirect_uris"]):
+ _cinfo[item] = request[item]
+ else:
+ return ResponseMessage(
+ error="invalid_configuration_parameter",
+ error_description="%s pointed to illegal URL" % item,
+ )
+
+ # Do I have the necessary keys
+ for item in ["id_token_signed_response_alg", "userinfo_signed_response_alg"]:
+ if item in request:
+ if request[item] in _context.provider_info[PREFERENCE2PROVIDER[item]]:
+ ktyp = alg2keytype(request[item])
+ # do I have this ktyp and for EC type keys the curve
+ if ktyp not in ["none", "oct"]:
+ _k = []
+ for iss in ["", _context.issuer]:
+ _k.extend(
+ _context.keyjar.get_signing_key(
+ ktyp, alg=request[item], issuer_id=iss
+ )
+ )
+ if not _k:
+ logger.warning('Lacking support for "{}"'.format(request[item]))
+ del _cinfo[item]
+
+ t = {"jwks_uri": "", "jwks": None}
+
+ for item in ["jwks_uri", "jwks"]:
+ if item in request:
+ t[item] = request[item]
+
+ # if it can't load keys because the URL is false it will
+ # just silently fail. Waiting for better times.
+ _context.keyjar.load_keys(client_id, jwks_uri=t["jwks_uri"], jwks=t["jwks"])
+
+ n_keys = 0
+ for kb in _context.keyjar.get(client_id, []):
+ n_keys += len(kb.keys())
+ msg = "found {} keys for client_id={}"
+ logger.debug(msg.format(n_keys, client_id))
+
+ return _cinfo
+
+ @staticmethod
+ def verify_redirect_uris(registration_request):
+ verified_redirect_uris = []
+ client_type = registration_request.get("application_type", "web")
+
+ must_https = False
+ if client_type == "web":
+ must_https = True
+ if registration_request.get("response_types") == ["code"]:
+ must_https = False
+
+ for uri in registration_request["redirect_uris"]:
+ _custom = False
+ p = urlparse(uri)
+ if client_type == "native":
+ if p.scheme not in ["http", "https"]: # Custom scheme
+ _custom = True
+ elif p.scheme == "http" and p.hostname in ["localhost", "127.0.0.1"]:
+ pass
+ else:
+ logger.error(
+ "InvalidRedirectURI: scheme:%s, hostname:%s", p.scheme, p.hostname,
+ )
+ raise InvalidRedirectURIError(
+ "Redirect_uri must use custom " "scheme or http and localhost"
+ )
+ elif must_https and p.scheme != "https":
+ msg = "None https redirect_uri not allowed"
+ raise InvalidRedirectURIError(msg)
+ elif p.scheme not in ["http", "https"]:
+ # Custom scheme
+ raise InvalidRedirectURIError("Custom redirect_uri not allowed for web client")
+ elif p.fragment:
+ raise InvalidRedirectURIError("redirect_uri contains fragment")
+
+ if _custom: # Can not verify a custom scheme
+ verified_redirect_uris.append((uri, {}))
+ else:
+ base, query = split_uri(uri)
+ if query:
+ verified_redirect_uris.append((base, query))
+ else:
+ verified_redirect_uris.append((base, {}))
+
+ return verified_redirect_uris
+
+ def _verify_sector_identifier(self, request):
+ """
+ Verify `sector_identifier_uri` is reachable and that it contains
+ `redirect_uri`s.
+
+ :param request: Provider registration request
+ :return: si_redirects, sector_id
+ :raises: InvalidSectorIdentifier
+ """
+ si_url = request["sector_identifier_uri"]
+ try:
+ res = self.server_get("endpoint_context").httpc.get(
+ si_url, **self.server_get("endpoint_context").httpc_params
+ )
+ logger.debug("sector_identifier_uri => %s", sanitize(res.text))
+ except Exception as err:
+ logger.error(err)
+ # res = None
+ raise InvalidSectorIdentifier("Couldn't read from sector_identifier_uri")
+
+ try:
+ si_redirects = json.loads(res.text)
+ except ValueError:
+ raise InvalidSectorIdentifier("Error deserializing sector_identifier_uri content")
+
+ if "redirect_uris" in request:
+ logger.debug("redirect_uris: %s", request["redirect_uris"])
+ for uri in request["redirect_uris"]:
+ if uri not in si_redirects:
+ raise InvalidSectorIdentifier("redirect_uri missing from sector_identifiers")
+
+ return si_redirects, si_url
+
+ def add_registration_api(self, cinfo, client_id, context):
+ _rat = rndstr(32)
+
+ cinfo["registration_access_token"] = _rat
+ endpoint = self.server_get("endpoints")
+ cinfo["registration_client_uri"] = "{}?client_id={}".format(
+ endpoint["registration_read"].full_path, client_id
+ )
+
+ context.registration_access_token[_rat] = client_id
+
+ def client_secret_expiration_time(self):
+ """
+ Returns client_secret expiration time.
+ """
+ if not self.kwargs.get("client_secret_expires", True):
+ return 0
+
+ _expiration_time = self.kwargs.get("client_secret_expires_in", 2592000)
+ return utc_time_sans_frac() + _expiration_time
+
+ def add_client_secret(self, cinfo, client_id, context):
+ client_secret = secret(self.seed, client_id)
+ cinfo["client_secret"] = client_secret
+ _eat = self.client_secret_expiration_time()
+ if _eat:
+ cinfo["client_secret_expires_at"] = _eat
+
+ return client_secret
+
+ def client_registration_setup(self, request, new_id=True, set_secret=True):
+ try:
+ request.verify()
+ except (MessageException, ValueError) as err:
+ logger.error("request.verify() on %s", request)
+ return ResponseMessage(
+ error="invalid_configuration_request", error_description="%s" % err
+ )
+
+ request.rm_blanks()
+ try:
+ self.match_client_request(request)
+ except CapabilitiesMisMatch as err:
+ return ResponseMessage(
+ error="invalid_request", error_description="Don't support proposed %s" % err,
+ )
+
+ _context = self.server_get("endpoint_context")
+ if new_id:
+ if self.kwargs.get("client_id_generator"):
+ cid_generator = importer(self.kwargs["client_id_generator"]["class"])
+ cid_gen_kwargs = self.kwargs["client_id_generator"].get("kwargs", {})
+ else:
+ cid_generator = importer("oidcop.oidc.registration.random_client_id")
+ cid_gen_kwargs = {}
+ client_id = cid_generator(reserved=_context.cdb.keys(), **cid_gen_kwargs)
+ if "client_id" in request:
+ del request["client_id"]
+ else:
+ client_id = request.get("client_id")
+ if not client_id:
+ raise ValueError("Missing client_id")
+
+ _cinfo = {"client_id": client_id, "client_salt": rndstr(8)}
+
+ if self.server_get("endpoint", "registration_read"):
+ self.add_registration_api(_cinfo, client_id, _context)
+
+ if new_id:
+ _cinfo["client_id_issued_at"] = utc_time_sans_frac()
+
+ client_secret = ""
+ if set_secret:
+ client_secret = self.add_client_secret(_cinfo, client_id, _context)
+
+ logger.debug("Stored client info in CDB under cid={}".format(client_id))
+
+ _context.cdb[client_id] = _cinfo
+ _cinfo = self.do_client_registration(
+ request, client_id, ignore=["redirect_uris", "policy_uri", "logo_uri", "tos_uri"],
+ )
+ if isinstance(_cinfo, ResponseMessage):
+ return _cinfo
+
+ args = dict([(k, v) for k, v in _cinfo.items() if k in self.response_cls.c_param])
+
+ comb_uri(args)
+ response = self.response_cls(**args)
+
+ # Add the client_secret as a symmetric key to the key jar
+ if client_secret:
+ _context.keyjar.add_symmetric(client_id, str(client_secret))
+
+ logger.debug("Stored updated client info in CDB under cid={}".format(client_id))
+ logger.debug("ClientInfo: {}".format(_cinfo))
+ _context.cdb[client_id] = _cinfo
+
+ # Not all databases can be sync'ed
+ if hasattr(_context.cdb, "sync") and callable(_context.cdb.sync):
+ _context.cdb.sync()
+
+ msg = "registration_response: {}"
+ logger.info(msg.format(sanitize(response.to_dict())))
+
+ return response
+
+ def process_request(self, request=None, new_id=True, set_secret=True, **kwargs):
+ try:
+ reg_resp = self.client_registration_setup(request, new_id, set_secret)
+ except Exception as err:
+ logger.error("client_registration_setup: %s", request)
+ return ResponseMessage(
+ error="invalid_configuration_request", error_description="%s" % err
+ )
+
+ if "error" in reg_resp:
+ return reg_resp
+ else:
+ _context = self.server_get("endpoint_context")
+ _cookie = _context.new_cookie(
+ name=_context.cookie_handler.name["register"], client_id=reg_resp["client_id"],
+ )
+
+ return {"response_args": reg_resp, "cookie": _cookie}
diff --git a/src/oidcop/oidc/session.py b/src/oidcop/oidc/session.py
new file mode 100644
index 00000000..b3db0e42
--- /dev/null
+++ b/src/oidcop/oidc/session.py
@@ -0,0 +1,399 @@
+import json
+import logging
+from typing import Optional
+from typing import Union
+from urllib.parse import parse_qs
+from urllib.parse import urlencode
+from urllib.parse import urlparse
+
+from cryptojwt import as_unicode
+from cryptojwt import b64d
+from cryptojwt.jwe.aes import AES_GCMEncrypter
+from cryptojwt.jwe.utils import split_ctx_and_tag
+from cryptojwt.jws.exception import JWSException
+from cryptojwt.jws.jws import factory
+from cryptojwt.jws.utils import alg2keytype
+from cryptojwt.jwt import JWT
+from cryptojwt.utils import as_bytes
+from cryptojwt.utils import b64e
+from oidcmsg.exception import InvalidRequest
+from oidcmsg.exception import VerificationError
+from oidcmsg.message import Message
+from oidcmsg.oauth2 import ResponseMessage
+from oidcmsg.oidc import verified_claim_name
+from oidcmsg.oidc.session import BACK_CHANNEL_LOGOUT_EVENT
+from oidcmsg.oidc.session import EndSessionRequest
+
+from oidcop import rndstr
+from oidcop.client_authn import UnknownOrNoAuthnMethod
+from oidcop.endpoint import Endpoint
+from oidcop.endpoint_context import add_path
+from oidcop.oauth2.authorization import verify_uri
+
+logger = logging.getLogger(__name__)
+
+
+def do_front_channel_logout_iframe(cinfo, iss, sid):
+ """
+
+ :param cinfo: Client info
+ :param iss: Issuer ID
+ :param sid: Session ID
+ :return: IFrame
+ """
+ try:
+ frontchannel_logout_uri = cinfo["frontchannel_logout_uri"]
+ except KeyError:
+ return None
+
+ try:
+ flsr = cinfo["frontchannel_logout_session_required"]
+ except KeyError:
+ flsr = False
+
+ if flsr:
+ _query = {"iss": iss, "sid": sid}
+ if "?" in frontchannel_logout_uri:
+ p = urlparse(frontchannel_logout_uri)
+ _args = parse_qs(p.query)
+ _args.update(_query)
+ _query = _args
+ _np = p._replace(query="")
+ frontchannel_logout_uri = _np.geturl()
+
+ _iframe = '