diff --git a/README.md b/README.md index c140e8cf..7188f1b8 100644 --- a/README.md +++ b/README.md @@ -118,21 +118,10 @@ git clone https://github.com/rohe/oidc-op.git ```` ##### Configure a Django OP -```` -cd oidc-op/django-oidc-op -pip install -r requirements.txt - -cd example -pip install -r requirements.txt - -./manage.py migrate -./manage.py createsuperuser -./manage.py collectstatic -gunicorn example.wsgi -b0.0.0.0:8000 --keyfile=./data/oidc_op/certs/key.pem --certfile=./data/oidc_op/certs/cert.pem --reload +See - -```` +https://github.com/peppelinux/django-oidc-op ##### Configure a Flask OP diff --git a/example/django_op/README.md b/example/django_op/README.md index 034bbaac..a03cea12 100644 --- a/example/django_op/README.md +++ b/example/django_op/README.md @@ -1,49 +1,39 @@ # django-oidc-op -A Django implementation of an **OIDC Provider** built top of [jwtconnect libraries](https://jwtconnect.io/). -If you are just going to build a standard OIDC Provider you only have to write the configuration file. +A Django implementation of an **OIDC Provider** on top of [jwtconnect.io](https://jwtconnect.io/). +To configure a standard OIDC Provider you have to edit the oidcop configuration file. +See `example/example/oidc_op.conf.yaml` to get in. This project is based on [Roland Hedberg's oidc-op](https://github.com/rohe/oidc-op). +Oidcendpoint supports the following standards and drafts: -## Status -_Work in Progress_ - -Please wait for the first release tag before considering it ready to use. -Before adopting this project in a production use you should consider if the following endpoint should be enabled: - -- [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) - -**TODO**: _document how to disable them and how to register RP via django admin backend._ - -#### Endpoints +- [OpenID Connect Core 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-core-1_0.html) +- [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) +- [OAuth2 Token introspection](https://tools.ietf.org/html/rfc7662) -Available resources are: +It also supports the followings `add_ons` modules. -- webfinger - - /.well-known/webfinger [to be tested] +- Custom scopes, that extends [OIDC standard ScopeClaims](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) +- PKCE, [Proof Key for Code Exchange by OAuth Public Clients](https://tools.ietf.org/html/rfc7636) -- provider_info - - /.well-known/openid-configuration - -- registration - - /registration - -- authorization - - /authorization - - authentication, which type decide to support, default: login form. +## Status -- token - - access/authorization token +The development status of this project is *experimental*, something goes wrong following latest oidcendpoint releases. +A roadmap for a stable release is still in progress. -- refresh_token -- userinfo - - /userinfo +Works: -- end_session - - logout +- Relying-Parties Admin UI completed, unit tests included (works v1.0.1) +- Session and SSO management completed (works v1.0.1) +- Logout session handler +Work in progress: +- KeyJAR and default storage (issuer, keybundles) (TODO with a full storage handler integration) +- Cookie handling, at this time we do not use cookies (disabled in configuration) +- Custom scopes ## Run the example demo @@ -59,26 +49,37 @@ pip install -r requirements.txt ./manage.py createsuperuser ./manage.py collectstatic -gunicorn example.wsgi -b0.0.0.0:8000 --keyfile=./data/oidc_op/certs/key.pem --certfile=./data/oidc_op/certs/cert.pem --reload +## debug server +gunicorn example.wsgi -b0.0.0.0:8000 --keyfile=./data/oidc_op/certs/key.pem --certfile=./data/oidc_op/certs/cert.pem --reload --timeout 3600 --capture-output --enable-stdio-inheritance ```` -You can use [JWTConnect-Python-OidcRP](https://github.com/openid/JWTConnect-Python-OidcRP) as an example RP as follows: +You can use [JWTConnect-Python-OidcRP](https://github.com/openid/JWTConnect-Python-OidcRP) as follow: +``` +cd JWTConnect-Python-OidcRP +RP_LOGFILE_NAME="./flrp.django.log" python3 -m flask_rp.wsgi ../django-oidc-op/example/data/oidc_rp/conf.django.yaml +``` -`RP_LOGFILE_NAME="./flrp.django.log" python3 -m flask_rp.wsgi ../django-oidc-op/example/data/oidc_rp/conf.django.yaml` +You can also use a scripted RP handler on top of oidc-rp +```` +python3 snippets/rp_handler.py -c example/data/oidc_rp/conf.django.yaml -u myuser -p mypass -iss django_oidc_op +```` ## Configure OIDC endpoint #### Django settings.py parameters +`OIDCENDPOINT_CONFIG`: The path containing the oidc-op configuration file. `OIDC_OP_AUTHN_SALT_SIZE`: Salt size in byte, default: 4 (Integer). -#### Signatures -These following files needed to be present in `data/oidc_op/private`. +#### JWKs +These following files needed to be present in `data/oidc_op/private` otherwise they will be created on the first time automatically. 1. session.json (JWK symmetric); -2. cookie_sign_jwk.json (JWK symmetric); -3. cookie_enc_jwk.json (JWK symmetric), optional, see `conf.yaml`. + +These are not used anymore, disabled in op conf.yaml: +1. cookie_sign_jwk.json (JWK symmetric); +2. cookie_enc_jwk.json (JWK symmetric), optional, see `conf.yaml`. To create them by hands comment out `'read_only': False'` in `conf.yaml`, otherwise they will be created automatically on each run. @@ -88,30 +89,19 @@ A JWK creation example would be: jwkgen --kty SYM > data/oidc_op/private/cookie_enc_jwk.json ```` -## General description - -The example included in this project enables dynamic registration of RPs (you can even disable it). -Using an example RP like [JWTConnect-Python-OidcRP](https://github.com/openid/JWTConnect-Python-OidcRP) -and configuring in `CLIENTS` section to use django-oidc-op (see `example/data/oidc_rp/conf.django.yaml`), -we'll see the following flow happens: +## Django specific implementation -1. /.well-known/openid-configuration - RP get the Provider configuration, what declared in the configuration at `op.server_info`; -2. /registration - RP registers in the Provider if `dynamic client registration` is enabled (default true) -3. /authorization - RP mades OIDC authorization -4. RP going to be redirected to login form page (see authn_methods.py) -5. user-agent posts form (user credentials) to `/verify/user_pass_django` -6. verify_user in django, on top of oidcendpoint_app.endpoint_context.authn_broker -7. RP request for an access token -> the response of the previous authentication is a HttpRedirect to op's /token resource -8. RP get the redirection to OP's USERINFO endpoint, using the access token got before +This project rely interely on behaviour and features provided by oidcendpoint, to get an exaustive integration in Django it +adopt the following customizations. +#### DataStore management +Oidcendpoint have some data persistence: +You can use oidcendpoint's standard `oidcmsg.storage.abfile.AbstractFileSystem` or Django models (Work in Progress). -## UserInfo endpoint +#### UserInfo endpoint Claims to be released are configured in `op.server_info.user_info` (in `conf.yaml`). -All the attributes release and user authentication mechanism rely on classes implemented in `oidc_op.users.py`. +The attributes release and user authentication mechanism rely on classes implemented in `oidc_op.users.py`. Configuration Example: @@ -128,8 +118,16 @@ Configuration Example: verified_email: email ```` -**TODO**: Do a RP configuration UI for custom claims release for every client. +#### Relying-Party search panel + +See `oidc_op.models` and `oidc_op.admin`, an UI was built to configure new RP via Django admin backend. +![Alt text](images/rp_search.png) +#### Relying-Party Registration +![Alt text](images/rp.png) + +#### Session management and token preview +![Alt text](images/oidc_session2.png) ## OIDC endpoint url prefix Can be configured in `urls.py` and also in oidc_op `conf.yaml`. @@ -137,4 +135,16 @@ Can be configured in `urls.py` and also in oidc_op `conf.yaml`. - /oidc/endpoint/ +## Running tests +running tests +```` +./manage.py test --pdb oidc_op.tests.01_client_db +```` + +## code coverage +```` +coverage erase +coverage run manage.py test +coverage report +```` diff --git a/example/django_op/example/.coveragerc b/example/django_op/example/.coveragerc new file mode 100644 index 00000000..7fe59bb4 --- /dev/null +++ b/example/django_op/example/.coveragerc @@ -0,0 +1,9 @@ +[run] +source = . + +[report] +fail_under = 100 +show_missing = True +skip_covered = True + + diff --git a/example/django_op/example/accounts/__init__.py b/example/django_op/example/accounts/__init__.py new file mode 100644 index 00000000..b48c1ec8 --- /dev/null +++ b/example/django_op/example/accounts/__init__.py @@ -0,0 +1,2 @@ +default_app_config = 'accounts.apps.AccountsConfig' + diff --git a/example/django_op/example/unical_accounts/admin.py b/example/django_op/example/accounts/admin.py similarity index 100% rename from example/django_op/example/unical_accounts/admin.py rename to example/django_op/example/accounts/admin.py diff --git a/example/django_op/example/unical_accounts/admin_inlines.py b/example/django_op/example/accounts/admin_inlines.py similarity index 100% rename from example/django_op/example/unical_accounts/admin_inlines.py rename to example/django_op/example/accounts/admin_inlines.py diff --git a/example/django_op/example/unical_accounts/apps.py b/example/django_op/example/accounts/apps.py similarity index 58% rename from example/django_op/example/unical_accounts/apps.py rename to example/django_op/example/accounts/apps.py index 3b5103e3..e4b019ea 100644 --- a/example/django_op/example/unical_accounts/apps.py +++ b/example/django_op/example/accounts/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class Unical_AccountsConfig(AppConfig): - name = 'unical_accounts' +class AccountsConfig(AppConfig): + name = 'accounts' verbose_name = "Autenticazione e Autorizzazione Utenti" diff --git a/example/django_op/example/unical_accounts/forms.py b/example/django_op/example/accounts/forms.py similarity index 100% rename from example/django_op/example/unical_accounts/forms.py rename to example/django_op/example/accounts/forms.py diff --git a/example/django_op/example/unical_accounts/migrations/0001_initial.py b/example/django_op/example/accounts/migrations/0001_initial.py similarity index 100% rename from example/django_op/example/unical_accounts/migrations/0001_initial.py rename to example/django_op/example/accounts/migrations/0001_initial.py diff --git a/example/django_op/example/accounts/migrations/0002_auto_20191202_1526.py b/example/django_op/example/accounts/migrations/0002_auto_20191202_1526.py new file mode 100644 index 00000000..57e6923c --- /dev/null +++ b/example/django_op/example/accounts/migrations/0002_auto_20191202_1526.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0 on 2019-12-02 15:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='place_of_birth', + field=models.CharField(blank=True, choices=[('Aruba', 'Aruba'), ('Afghanistan', 'Afghanistan'), ('Angola', 'Angola'), ('Anguilla', 'Anguilla'), ('Åland Islands', 'Åland Islands'), ('Albania', 'Albania'), ('Andorra', 'Andorra'), ('United Arab Emirates', 'United Arab Emirates'), ('Argentina', 'Argentina'), ('Armenia', 'Armenia'), ('American Samoa', 'American Samoa'), ('Antarctica', 'Antarctica'), ('French Southern Territories', 'French Southern Territories'), ('Antigua and Barbuda', 'Antigua and Barbuda'), ('Australia', 'Australia'), ('Austria', 'Austria'), ('Azerbaijan', 'Azerbaijan'), ('Burundi', 'Burundi'), ('Belgium', 'Belgium'), ('Benin', 'Benin'), ('Bonaire, Sint Eustatius and Saba', 'Bonaire, Sint Eustatius and Saba'), ('Burkina Faso', 'Burkina Faso'), ('Bangladesh', 'Bangladesh'), ('Bulgaria', 'Bulgaria'), ('Bahrain', 'Bahrain'), ('Bahamas', 'Bahamas'), ('Bosnia and Herzegovina', 'Bosnia and Herzegovina'), ('Saint Barthélemy', 'Saint Barthélemy'), ('Belarus', 'Belarus'), ('Belize', 'Belize'), ('Bermuda', 'Bermuda'), ('Bolivia, Plurinational State of', 'Bolivia, Plurinational State of'), ('Brazil', 'Brazil'), ('Barbados', 'Barbados'), ('Brunei Darussalam', 'Brunei Darussalam'), ('Bhutan', 'Bhutan'), ('Bouvet Island', 'Bouvet Island'), ('Botswana', 'Botswana'), ('Central African Republic', 'Central African Republic'), ('Canada', 'Canada'), ('Cocos (Keeling) Islands', 'Cocos (Keeling) Islands'), ('Switzerland', 'Switzerland'), ('Chile', 'Chile'), ('China', 'China'), ("Côte d'Ivoire", "Côte d'Ivoire"), ('Cameroon', 'Cameroon'), ('Congo, The Democratic Republic of the', 'Congo, The Democratic Republic of the'), ('Congo', 'Congo'), ('Cook Islands', 'Cook Islands'), ('Colombia', 'Colombia'), ('Comoros', 'Comoros'), ('Cabo Verde', 'Cabo Verde'), ('Costa Rica', 'Costa Rica'), ('Cuba', 'Cuba'), ('Curaçao', 'Curaçao'), ('Christmas Island', 'Christmas Island'), ('Cayman Islands', 'Cayman Islands'), ('Cyprus', 'Cyprus'), ('Czechia', 'Czechia'), ('Germany', 'Germany'), ('Djibouti', 'Djibouti'), ('Dominica', 'Dominica'), ('Denmark', 'Denmark'), ('Dominican Republic', 'Dominican Republic'), ('Algeria', 'Algeria'), ('Ecuador', 'Ecuador'), ('Egypt', 'Egypt'), ('Eritrea', 'Eritrea'), ('Western Sahara', 'Western Sahara'), ('Spain', 'Spain'), ('Estonia', 'Estonia'), ('Ethiopia', 'Ethiopia'), ('Finland', 'Finland'), ('Fiji', 'Fiji'), ('Falkland Islands (Malvinas)', 'Falkland Islands (Malvinas)'), ('France', 'France'), ('Faroe Islands', 'Faroe Islands'), ('Micronesia, Federated States of', 'Micronesia, Federated States of'), ('Gabon', 'Gabon'), ('United Kingdom', 'United Kingdom'), ('Georgia', 'Georgia'), ('Guernsey', 'Guernsey'), ('Ghana', 'Ghana'), ('Gibraltar', 'Gibraltar'), ('Guinea', 'Guinea'), ('Guadeloupe', 'Guadeloupe'), ('Gambia', 'Gambia'), ('Guinea-Bissau', 'Guinea-Bissau'), ('Equatorial Guinea', 'Equatorial Guinea'), ('Greece', 'Greece'), ('Grenada', 'Grenada'), ('Greenland', 'Greenland'), ('Guatemala', 'Guatemala'), ('French Guiana', 'French Guiana'), ('Guam', 'Guam'), ('Guyana', 'Guyana'), ('Hong Kong', 'Hong Kong'), ('Heard Island and McDonald Islands', 'Heard Island and McDonald Islands'), ('Honduras', 'Honduras'), ('Croatia', 'Croatia'), ('Haiti', 'Haiti'), ('Hungary', 'Hungary'), ('Indonesia', 'Indonesia'), ('Isle of Man', 'Isle of Man'), ('India', 'India'), ('British Indian Ocean Territory', 'British Indian Ocean Territory'), ('Ireland', 'Ireland'), ('Iran, Islamic Republic of', 'Iran, Islamic Republic of'), ('Iraq', 'Iraq'), ('Iceland', 'Iceland'), ('Israel', 'Israel'), ('Italy', 'Italy'), ('Jamaica', 'Jamaica'), ('Jersey', 'Jersey'), ('Jordan', 'Jordan'), ('Japan', 'Japan'), ('Kazakhstan', 'Kazakhstan'), ('Kenya', 'Kenya'), ('Kyrgyzstan', 'Kyrgyzstan'), ('Cambodia', 'Cambodia'), ('Kiribati', 'Kiribati'), ('Saint Kitts and Nevis', 'Saint Kitts and Nevis'), ('Korea, Republic of', 'Korea, Republic of'), ('Kuwait', 'Kuwait'), ("Lao People's Democratic Republic", "Lao People's Democratic Republic"), ('Lebanon', 'Lebanon'), ('Liberia', 'Liberia'), ('Libya', 'Libya'), ('Saint Lucia', 'Saint Lucia'), ('Liechtenstein', 'Liechtenstein'), ('Sri Lanka', 'Sri Lanka'), ('Lesotho', 'Lesotho'), ('Lithuania', 'Lithuania'), ('Luxembourg', 'Luxembourg'), ('Latvia', 'Latvia'), ('Macao', 'Macao'), ('Saint Martin (French part)', 'Saint Martin (French part)'), ('Morocco', 'Morocco'), ('Monaco', 'Monaco'), ('Moldova, Republic of', 'Moldova, Republic of'), ('Madagascar', 'Madagascar'), ('Maldives', 'Maldives'), ('Mexico', 'Mexico'), ('Marshall Islands', 'Marshall Islands'), ('North Macedonia', 'North Macedonia'), ('Mali', 'Mali'), ('Malta', 'Malta'), ('Myanmar', 'Myanmar'), ('Montenegro', 'Montenegro'), ('Mongolia', 'Mongolia'), ('Northern Mariana Islands', 'Northern Mariana Islands'), ('Mozambique', 'Mozambique'), ('Mauritania', 'Mauritania'), ('Montserrat', 'Montserrat'), ('Martinique', 'Martinique'), ('Mauritius', 'Mauritius'), ('Malawi', 'Malawi'), ('Malaysia', 'Malaysia'), ('Mayotte', 'Mayotte'), ('Namibia', 'Namibia'), ('New Caledonia', 'New Caledonia'), ('Niger', 'Niger'), ('Norfolk Island', 'Norfolk Island'), ('Nigeria', 'Nigeria'), ('Nicaragua', 'Nicaragua'), ('Niue', 'Niue'), ('Netherlands', 'Netherlands'), ('Norway', 'Norway'), ('Nepal', 'Nepal'), ('Nauru', 'Nauru'), ('New Zealand', 'New Zealand'), ('Oman', 'Oman'), ('Pakistan', 'Pakistan'), ('Panama', 'Panama'), ('Pitcairn', 'Pitcairn'), ('Peru', 'Peru'), ('Philippines', 'Philippines'), ('Palau', 'Palau'), ('Papua New Guinea', 'Papua New Guinea'), ('Poland', 'Poland'), ('Puerto Rico', 'Puerto Rico'), ("Korea, Democratic People's Republic of", "Korea, Democratic People's Republic of"), ('Portugal', 'Portugal'), ('Paraguay', 'Paraguay'), ('Palestine, State of', 'Palestine, State of'), ('French Polynesia', 'French Polynesia'), ('Qatar', 'Qatar'), ('Réunion', 'Réunion'), ('Romania', 'Romania'), ('Russian Federation', 'Russian Federation'), ('Rwanda', 'Rwanda'), ('Saudi Arabia', 'Saudi Arabia'), ('Sudan', 'Sudan'), ('Senegal', 'Senegal'), ('Singapore', 'Singapore'), ('South Georgia and the South Sandwich Islands', 'South Georgia and the South Sandwich Islands'), ('Saint Helena, Ascension and Tristan da Cunha', 'Saint Helena, Ascension and Tristan da Cunha'), ('Svalbard and Jan Mayen', 'Svalbard and Jan Mayen'), ('Solomon Islands', 'Solomon Islands'), ('Sierra Leone', 'Sierra Leone'), ('El Salvador', 'El Salvador'), ('San Marino', 'San Marino'), ('Somalia', 'Somalia'), ('Saint Pierre and Miquelon', 'Saint Pierre and Miquelon'), ('Serbia', 'Serbia'), ('South Sudan', 'South Sudan'), ('Sao Tome and Principe', 'Sao Tome and Principe'), ('Suriname', 'Suriname'), ('Slovakia', 'Slovakia'), ('Slovenia', 'Slovenia'), ('Sweden', 'Sweden'), ('Eswatini', 'Eswatini'), ('Sint Maarten (Dutch part)', 'Sint Maarten (Dutch part)'), ('Seychelles', 'Seychelles'), ('Syrian Arab Republic', 'Syrian Arab Republic'), ('Turks and Caicos Islands', 'Turks and Caicos Islands'), ('Chad', 'Chad'), ('Togo', 'Togo'), ('Thailand', 'Thailand'), ('Tajikistan', 'Tajikistan'), ('Tokelau', 'Tokelau'), ('Turkmenistan', 'Turkmenistan'), ('Timor-Leste', 'Timor-Leste'), ('Tonga', 'Tonga'), ('Trinidad and Tobago', 'Trinidad and Tobago'), ('Tunisia', 'Tunisia'), ('Turkey', 'Turkey'), ('Tuvalu', 'Tuvalu'), ('Taiwan, Province of China', 'Taiwan, Province of China'), ('Tanzania, United Republic of', 'Tanzania, United Republic of'), ('Uganda', 'Uganda'), ('Ukraine', 'Ukraine'), ('United States Minor Outlying Islands', 'United States Minor Outlying Islands'), ('Uruguay', 'Uruguay'), ('United States', 'United States'), ('Uzbekistan', 'Uzbekistan'), ('Holy See (Vatican City State)', 'Holy See (Vatican City State)'), ('Saint Vincent and the Grenadines', 'Saint Vincent and the Grenadines'), ('Venezuela, Bolivarian Republic of', 'Venezuela, Bolivarian Republic of'), ('Virgin Islands, British', 'Virgin Islands, British'), ('Virgin Islands, U.S.', 'Virgin Islands, U.S.'), ('Viet Nam', 'Viet Nam'), ('Vanuatu', 'Vanuatu'), ('Wallis and Futuna', 'Wallis and Futuna'), ('Samoa', 'Samoa'), ('Yemen', 'Yemen'), ('South Africa', 'South Africa'), ('Zambia', 'Zambia'), ('Zimbabwe', 'Zimbabwe')], max_length=56, null=True, verbose_name='Luogo di nascita'), + ), + ] diff --git a/example/django_op/example/unical_accounts/migrations/__init__.py b/example/django_op/example/accounts/migrations/__init__.py similarity index 100% rename from example/django_op/example/unical_accounts/migrations/__init__.py rename to example/django_op/example/accounts/migrations/__init__.py diff --git a/example/django_op/example/unical_accounts/models.py b/example/django_op/example/accounts/models.py similarity index 99% rename from example/django_op/example/unical_accounts/models.py rename to example/django_op/example/accounts/models.py index 2fa08c1f..bc994780 100644 --- a/example/django_op/example/unical_accounts/models.py +++ b/example/django_op/example/accounts/models.py @@ -26,7 +26,7 @@ class User(AbstractUser): blank=True, null=True) gender = models.CharField(_('Genere'), choices=GENDER, max_length=12, blank=True, null=True) - place_of_birth = models.CharField('Luogo di nascita', max_length=30, + place_of_birth = models.CharField('Luogo di nascita', max_length=56, blank=True, null=True, choices=[(i.name, i.name) for i in pycountry.countries]) birth_date = models.DateField('Data di nascita', diff --git a/example/django_op/example/unical_accounts/templatetags/__init__.py b/example/django_op/example/accounts/templatetags/__init__.py similarity index 100% rename from example/django_op/example/unical_accounts/templatetags/__init__.py rename to example/django_op/example/accounts/templatetags/__init__.py diff --git a/example/django_op/example/unical_accounts/templatetags/has_group.py b/example/django_op/example/accounts/templatetags/has_group.py similarity index 100% rename from example/django_op/example/unical_accounts/templatetags/has_group.py rename to example/django_op/example/accounts/templatetags/has_group.py diff --git a/example/django_op/example/unical_accounts/tests.py b/example/django_op/example/accounts/tests.py similarity index 100% rename from example/django_op/example/unical_accounts/tests.py rename to example/django_op/example/accounts/tests.py diff --git a/example/django_op/example/unical_accounts/urls.py b/example/django_op/example/accounts/urls.py similarity index 96% rename from example/django_op/example/unical_accounts/urls.py rename to example/django_op/example/accounts/urls.py index 7a56fbd5..7bd77a54 100644 --- a/example/django_op/example/unical_accounts/urls.py +++ b/example/django_op/example/accounts/urls.py @@ -16,11 +16,11 @@ from django.urls import path from .views import * -app_name="unical_accounts" +app_name="accounts" urlpatterns = [ # url(r'^login/$', Login, name='login'), # path('logout', Logout, name='logout'), - + ] diff --git a/example/django_op/example/unical_accounts/views.py b/example/django_op/example/accounts/views.py similarity index 100% rename from example/django_op/example/unical_accounts/views.py rename to example/django_op/example/accounts/views.py diff --git a/example/django_op/example/data/oidc_rp/conf.django.yaml b/example/django_op/example/data/oidc_rp/conf.django.yaml index f5242b4b..699a6f89 100644 --- a/example/django_op/example/data/oidc_rp/conf.django.yaml +++ b/example/django_op/example/data/oidc_rp/conf.django.yaml @@ -1,15 +1,42 @@ -PORT: 8099 -BASEURL: "https://127.0.0.1:8099" +logging: + version: 1 + disable_existing_loggers: False + root: + handlers: + - default + - console + level: DEBUG + loggers: + 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: 8099 +base_url: "https://127.0.0.1:8099" # If BASE is https these has to be specified -SERVER_CERT: "certs/cert.pem" -SERVER_KEY: "certs/key.pem" -CA_BUNDLE: '' +webserver: + port: '{port}' + server_cert: "certs/cert.pem" + server_key: "certs/key.pem" + domain: '{domain}' # This is just for testing an local usage. In all other cases it MUST be True -VERIFY_SSL: false +httpc_params: + verify: False -KEYDEFS: &keydef +key_defs: &keydef - "type": "RSA" "key": '' @@ -19,60 +46,85 @@ KEYDEFS: &keydef "crv": "P-256" "use": ["sig"] -HTML_HOME: 'html' -SECRET_KEY: 'secret_key' -SESSION_COOKIE_NAME: 'rp_session' -PREFERRED_URL_SCHEME: 'https' +# html_home: 'html' +# secret_key: 'secret_key' +# session_cookie_name: 'rp_session' +# preferred_url_scheme: 'https' -RP_KEYS: +rp_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 -# PUBLIC_JWKS_PATH: 'https://127.0.0.1:8090/static/jwks.json' -# PRIVATE_JWKS_PATH: './private/jwks.json' - -client_preferences: &id001 - application_name: rphandler +# information used when registering the client, this may be the same for all OPs +client_preferences: &prefs + application_name: rp_test application_type: web - contacts: [ops@example.com] - response_types: [code] - scope: [openid, profile, email, address, phone] - token_endpoint_auth_method: [client_secret_basic, client_secret_post] + contacts: + - ops@example.com + response_types: + - code + scope: + - openid + - that_scope + - profile + - email + - address + - phone + token_endpoint_auth_method: + - client_secret_basic + - client_secret_post -services: &id002 - discovery: +services: &services + discovery: &disc class: oidcservice.oidc.provider_info_discovery.ProviderInfoDiscovery kwargs: {} - registration: + registration: ®ist class: oidcservice.oidc.registration.Registration kwargs: {} - authorization: + authorization: &authz class: oidcservice.oidc.authorization.Authorization kwargs: {} - accesstoken: + accesstoken: &acctok class: oidcservice.oidc.access_token.AccessToken kwargs: {} - refresh_accesstoken: - class: oidcservice.oidc.refresh_access_token.RefreshAccessToken - kwargs: {} - userinfo: + userinfo: &userinfo class: oidcservice.oidc.userinfo.UserInfo kwargs: {} - end_session: + end_session: &sess class: oidcservice.oidc.end_session.EndSession kwargs: {} +clients: + # The ones that support webfinger, OP discovery and client registration + # This is the default, any client that is not listed here is expected to + # support dynamic discovery and registration. + "": + client_preferences: *prefs + redirect_uris: None + services: *services -CLIENTS: django_oidc_op: - client_preferences: *id001 + client_preferences: *prefs + + # if you create a client through ADMIN UI ... + #client_id: 1UUl6cwNigmj + #client_secret: 78be88872d5877c4ddb209335f4eb2fc5118a481a195a454c8b2ebcb + # this redirect_uri must be statically configured in op's rp cdb! + redirect_uris: + - https://127.0.0.1:8099/authz_cb/django_oidc_op issuer: https://127.0.0.1:8000/ jwks_uri: https://127.0.0.1:8099/static/jwks.json - redirect_uris: ['https://127.0.0.1:8099/authz_cb/django_oidc_op'] - services: *id002 + + services: + discovery: *disc + registration: *regist + authorization: *authz + accesstoken: *acctok + userinfo: *userinfo + end_session: *sess add_ons: pkce: function: oidcservice.oidc.add_on.pkce.add_pkce_support @@ -80,5 +132,28 @@ CLIENTS: code_challenge_length: 64 code_challenge_method: S256 -# Whether an attempt to fetch the userinfo should be made -USERINFO: true + shib_oidc_op: + client_preferences: *prefs + + # if you create a client through ADMIN UI ... + client_id: demo_rp + client_secret: topsecret2020_______ + # this redirect_uri must be statically configured in op's rp cdb! + redirect_uris: + - https://127.0.0.1:8099/authz_cb/shib_oidc_op + issuer: https://idp.testunical.it/ + jwks_uri: https://127.0.0.1:8099/static/jwks.json + + services: + discovery: *disc + registration: *regist + authorization: *authz + accesstoken: *acctok + userinfo: *userinfo + end_session: *sess + add_ons: + pkce: + function: oidcservice.oidc.add_on.pkce.add_pkce_support + kwargs: + code_challenge_length: 64 + code_challenge_method: S256 diff --git a/example/django_op/example/data/oidc_rp/conf.json b/example/django_op/example/data/oidc_rp/conf.json new file mode 100644 index 00000000..e12541e2 --- /dev/null +++ b/example/django_op/example/data/oidc_rp/conf.json @@ -0,0 +1,322 @@ +{ + "logging": { + "version": 1, + "disable_existing_loggers": false, + "root": { + "handlers": [ + "console", + "file" + ], + "level": "DEBUG" + }, + "loggers": { + "idp": { + "level": "DEBUG" + } + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + "formatter": "default" + }, + "file": { + "class": "logging.FileHandler", + "filename": "debug.log", + "formatter": "default" + } + }, + "formatters": { + "default": { + "format": "%(asctime)s %(name)s %(levelname)s %(message)s" + } + } + }, + "port": 8090, + "domain": "127.0.0.1", + "base_url": "https://{domain}:{port}", + "httpc_params": { + "verify": false + }, + "keydefs": [ + { + "type": "RSA", + "key": "", + "use": [ + "sig" + ] + }, + { + "type": "EC", + "crv": "P-256", + "use": [ + "sig" + ] + } + ], + "rp_keys": { + "private_path": "private/jwks.json", + "key_defs": [ + { + "type": "RSA", + "key": "", + "use": [ + "sig" + ] + }, + { + "type": "EC", + "crv": "P-256", + "use": [ + "sig" + ] + } + ], + "public_path": "static/jwks.json", + "read_only": false + }, + "client_preferences": { + "application_name": "rphandler", + "application_type": "web", + "contacts": [ + "ops@example.com" + ], + "response_types": [ + "code" + ], + "scope": [ + "openid", + "profile", + "email", + "address", + "phone" + ], + "token_endpoint_auth_method": [ + "client_secret_basic", + "client_secret_post" + ] + }, + "services": { + "discovery": { + "class": "oidcrp.oidc.provider_info_discovery.ProviderInfoDiscovery", + "kwargs": {} + }, + "registration": { + "class": "oidcrp.oidc.registration.Registration", + "kwargs": {} + }, + "authorization": { + "class": "oidcrp.oidc.authorization.Authorization", + "kwargs": {} + }, + "accesstoken": { + "class": "oidcrp.oidc.access_token.AccessToken", + "kwargs": {} + }, + "userinfo": { + "class": "oidcrp.oidc.userinfo.UserInfo", + "kwargs": {} + }, + "end_session": { + "class": "oidcrp.oidc.end_session.EndSession", + "kwargs": {} + } + }, + "clients": { + "": { + "client_preferences": { + "application_name": "rphandler", + "application_type": "web", + "contacts": [ + "ops@example.com" + ], + "response_types": [ + "code" + ], + "scope": [ + "openid", + "profile", + "email", + "address", + "phone" + ], + "token_endpoint_auth_method": [ + "client_secret_basic", + "client_secret_post" + ] + }, + "redirect_uris": "None", + "services": { + "discovery": { + "class": "oidcrp.oidc.provider_info_discovery.ProviderInfoDiscovery", + "kwargs": {} + }, + "registration": { + "class": "oidcrp.oidc.registration.Registration", + "kwargs": {} + }, + "authorization": { + "class": "oidcrp.oidc.authorization.Authorization", + "kwargs": {} + }, + "accesstoken": { + "class": "oidcrp.oidc.access_token.AccessToken", + "kwargs": {} + }, + "userinfo": { + "class": "oidcrp.oidc.userinfo.UserInfo", + "kwargs": {} + }, + "end_session": { + "class": "oidcrp.oidc.end_session.EndSession", + "kwargs": {} + } + } + }, + "flask_provider": { + "client_preferences": { + "application_name": "rphandler", + "application_type": "web", + "contacts": [ + "ops@example.com" + ], + "response_types": [ + "code" + ], + "scope": [ + "openid", + "profile", + "email", + "address", + "phone" + ], + "token_endpoint_auth_method": [ + "client_secret_basic", + "client_secret_post" + ] + }, + "issuer": "https://127.0.0.1:5000/", + "redirect_uris": [ + "https://{domain}:{port}/authz_cb/local" + ], + "post_logout_redirect_uris": [ + "https://{domain}:{port}/session_logout/local" + ], + "frontchannel_logout_uri": "https://{domain}:{port}/fc_logout/local", + "frontchannel_logout_session_required": true, + "backchannel_logout_uri": "https://{domain}:{port}/bc_logout/local", + "backchannel_logout_session_required": true, + "services": { + "discovery": { + "class": "oidcrp.oidc.provider_info_discovery.ProviderInfoDiscovery", + "kwargs": {} + }, + "registration": { + "class": "oidcrp.oidc.registration.Registration", + "kwargs": {} + }, + "authorization": { + "class": "oidcrp.oidc.authorization.Authorization", + "kwargs": {} + }, + "accesstoken": { + "class": "oidcrp.oidc.access_token.AccessToken", + "kwargs": {} + }, + "userinfo": { + "class": "oidcrp.oidc.userinfo.UserInfo", + "kwargs": {} + }, + "end_session": { + "class": "oidcrp.oidc.end_session.EndSession", + "kwargs": {} + } + }, + "add_ons": { + "pkce": { + "function": "oidcrp.oauth2.add_on.pkce.add_support", + "kwargs": { + "code_challenge_length": 64, + "code_challenge_method": "S256" + } + } + } + }, + "django_provider": { + "client_preferences": { + "application_name": "rphandler", + "application_type": "web", + "contacts": [ + "ops@example.com" + ], + "response_types": [ + "code" + ], + "scope": [ + "openid", + "profile", + "email", + "address", + "phone" + ], + "token_endpoint_auth_method": [ + "client_secret_basic", + "client_secret_post" + ] + }, + "issuer": "https://127.0.0.1:8000/", + "redirect_uris": [ + "https://{domain}:{port}/authz_cb/django" + ], + "post_logout_redirect_uris": [ + "https://{domain}:{port}/session_logout/django" + ], + "frontchannel_logout_uri": "https://{domain}:{port}/fc_logout/django", + "frontchannel_logout_session_required": true, + "backchannel_logout_uri": "https://{domain}:{port}/bc_logout/django", + "backchannel_logout_session_required": true, + "services": { + "discovery": { + "class": "oidcrp.oidc.provider_info_discovery.ProviderInfoDiscovery", + "kwargs": {} + }, + "registration": { + "class": "oidcrp.oidc.registration.Registration", + "kwargs": {} + }, + "authorization": { + "class": "oidcrp.oidc.authorization.Authorization", + "kwargs": {} + }, + "accesstoken": { + "class": "oidcrp.oidc.access_token.AccessToken", + "kwargs": {} + }, + "userinfo": { + "class": "oidcrp.oidc.userinfo.UserInfo", + "kwargs": {} + }, + "end_session": { + "class": "oidcrp.oidc.end_session.EndSession", + "kwargs": {} + } + }, + "add_ons": { + "pkce": { + "function": "oidcrp.oauth2.add_on.pkce.add_support", + "kwargs": { + "code_challenge_length": 64, + "code_challenge_method": "S256" + } + } + } + } + }, + "webserver": { + "port": 8090, + "domain": "127.0.0.1", + "server_cert": "certs/cert.pem", + "server_key": "certs/key.pem", + "debug": true + } +} diff --git a/example/django_op/example/example/oidc_op.conf.yaml b/example/django_op/example/example/oidc_op.conf.yaml index 2a88e64e..d6738699 100644 --- a/example/django_op/example/example/oidc_op.conf.yaml +++ b/example/django_op/example/example/oidc_op.conf.yaml @@ -1,9 +1,7 @@ # TODO: the following four should be handled in settings.py -session_cookie_name: django_oidc_op -port: &port 8000 -domain: &domain 127.0.0.1 -server_name: &server_name 127.0.0.1:8000 -base_url: &base_url https://127.0.0.1:8000 +# session_cookie_name: django_oidc_op +base_url: &base_url 'https://127.0.0.1:8000' +#base_data_path: 'data/oidc_op/' key_def: &key_def - @@ -24,26 +22,22 @@ OIDC_KEYS: &oidc_keys # otherwise OP metadata will have jwks_uri: https://127.0.0.1:5000/None! 'uri_path': 'static/jwks.json' -session_jwk: 'data/oidc_op/private/session.json' - op: server_info: issuer: *base_url - verify_ssl: False + httpc_params: + verify: False + seed: asdasdasdasd-random + session_key: + filename: data/oidc_op/private/session_jwks.json + type: OCT + use: sig capabilities: - 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 + # indicates that unknow/unavailable scopes requested by a RP + # would get a 403 error message instead of be declined implicitly. + # If False the op will only release the available scopes and ignoring the missings. + # Default to False + #deny_unknown_scopes: true subject_types_supported: - public - pairwise @@ -52,26 +46,12 @@ op: - implicit - urn:ietf:params:oauth:grant-type:jwt-bearer - refresh_token - # TODO: Django mapping of claims -> user attrs - claim_types_supported: - - normal - - aggregated - - distributed - # filter as you prefer, see oidcmsg(.__init__.)SCOPE2CLAIMS - # defaults are ['family_name', 'email_verified', 'profile', 'given_name', 'picture', 'address', 'name', 'sub', 'email', 'zoneinfo', 'gender', 'website', 'phone_number_verified', 'middle_name', 'nickname', 'birthdate', 'preferred_username', 'locale', 'updated_at', 'phone_number'] - # claims_supported: - # - picture - # - nickname - 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 + service_documentation: https://that.url.org/service_documentation + ui_locales_supported: [en-US, it-IT] + op_policy_uri: https://that.url.org/op_policy_uri + op_tos_uri: https://that.url.org/op_tos_uri id_token: - class: oidcop.id_token.IDToken + class: oidcendpoint.id_token.IDToken kwargs: default_claims: email: @@ -80,7 +60,7 @@ op: essential: True token_handler_args: jwks_def: - private_path: data/oidc_op/private/token_jwk.json + private_path: data/oidc_op/private/token_jwks.json read_only: False key_defs: - @@ -98,7 +78,7 @@ op: code: lifetime: 600 token: - class: oidcop.jwt_token.JWTToken + class: oidcendpoint.token.jwt_token.JWTToken lifetime: 3600 add_claims: - email @@ -107,10 +87,10 @@ op: - phone_number_verified add_claim_by_scope: True aud: - - https://127.0.0.1:8000 + - *base_url refresh: lifetime: 86400 - jwks: + keys: *oidc_keys template_dir: oidc_op/templates @@ -119,32 +99,34 @@ op: endpoint: webfinger: path: '.well-known/webfinger' - class: oidcop.oidc.discovery.Discovery + class: oidcendpoint.oidc.discovery.Discovery kwargs: # TODO: optionally manage discovery service authn client_authn_method: null provider_info: path: ".well-known/openid-configuration" - class: oidcop.oidc.provider_config.ProviderConfiguration + class: oidcendpoint.oidc.provider_config.ProviderConfiguration kwargs: # TODO: optionally manage openid-configuration authn client_authn_method: null registration: path: registration - class: oidcop.oidc.registration.Registration + class: oidcendpoint.oidc.registration.Registration kwargs: # TODO: make a authn method for 'client_authn_method' client_authn_method: null client_secret_expiration_time: 432000 + # this way the secret will never expire. + # client_secret_expires: False registration_api: path: registration_api - class: oidcop.oidc.read_registration.RegistrationRead + class: oidcendpoint.oidc.read_registration.RegistrationRead kwargs: client_authn_method: - bearer_header introspection: path: introspection - class: oidcop.oauth2.introspection.Introspection + class: oidcendpoint.oauth2.introspection.Introspection kwargs: client_authn_method: client_secret_post: ClientSecretPost @@ -152,14 +134,30 @@ op: - username authorization: path: authorization - class: oidcop.oidc.authorization.Authorization + class: oidcendpoint.oidc.authorization.Authorization kwargs: - # TODO: make a authn method for 'client_authn_method' 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.AccessToken + class: oidcendpoint.oidc.token.Token kwargs: + allow_refresh: False client_authn_method: - client_secret_post - client_secret_basic @@ -167,14 +165,24 @@ op: - private_key_jwt userinfo: path: userinfo - class: oidcop.oidc.userinfo.UserInfo + class: oidcendpoint.oidc.userinfo.UserInfo + kwargs: + claim_types_supported: + - normal + - aggregated + - distributed end_session: path: session - class: oidcop.oidc.session.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: oidc_op.users.UserInfo kwargs: @@ -190,7 +198,7 @@ op: updated_at: get_oidc_lastlogin authentication: user: - acr: oidcop.user_authn.authn_context.INTERNETPROTOCOLPASSWORD + acr: oidcendpoint.user_authn.authn_context.INTERNETPROTOCOLPASSWORD class: oidc_op.users.UserPassDjango kwargs: # this would override web resource where credentials will be POSTed @@ -199,7 +207,7 @@ op: template: oidc_login.html # args1: - # class: oidcop.util.JSONDictDB + # class: oidcendpoint.util.JSONDictDB # kwargs: # args1_1: data/oidc_op/things.json @@ -208,35 +216,48 @@ op: user_label: "Nickname" passwd_label: "Secret sauce" #anon: - #acr: oidcop.user_authn.authn_context.UNSPECIFIED - #class: oidcop.user_authn.user.NoAuthn + #acr: oidcendpoint.user_authn.authn_context.UNSPECIFIED + #class: oidcendpoint.user_authn.user.NoAuthn #kwargs: #user: thatusername - cookie_dealer: - class: oidcop.cookie.CookieDealer - kwargs: - sign_jwk: data/oidc_op/private/cookie_sign_jwk.json - sign_alg: 'SHA256' + # cookie_dealer: + # class: oidcendpoint.cookie.CookieDealer + # kwargs: + # these should be updated... + # sign_jwk: + # filename: 'data/oidc_op/private/cookie_sign_jwk.json' + # type: OCT + # kid: cookie_sign_key_id + # enc_jwk: + ## otherwise do it yourself: jwkgen --kty SYM > data/oidc_op/private/cookie_enc_jwk.json + # filename: 'data/oidc_op/private/cookie_enc_jwk.json' + # type: OCT + # kid: cookie_enc_key_id + + ## the manual one here... + # sign_jwk: data/oidc_op/private/cookie_sign_jwk.json + # sign_alg: 'SHA256' - # jwkgen --kty SYM > data/oidc_op/private/cookie_enc_jwk.json - enc_jwk: 'data/oidc_op/private/cookie_enc_jwk.json' + # ## jwkgen --kty SYM > data/oidc_op/private/cookie_enc_jwk.json + # enc_jwk: 'data/oidc_op/private/cookie_enc_jwk.json' + + # default_values: + # name: oidc_op + # domain: *base_url + # path: / + # max_age: 3600 - default_values: - name: oidc_op - domain: *domain - path: / - max_age: 3600 login_hint2acrs: - class: oidcop.login_hint.LoginHint2Acrs + class: oidcendpoint.login_hint.LoginHint2Acrs kwargs: scheme_map: email: - - oidcop.user_authn.authn_context.INTERNETPROTOCOLPASSWORD + - oidcendpoint.user_authn.authn_context.INTERNETPROTOCOLPASSWORD - # this adds PKCE support as mandatory - disable if needed + # this adds PKCE support as mandatory - disable it if needed (essential: False) add_on: pkce: - function: oidcop.oidc.add_on.pkce.add_pkce_support + function: oidcendpoint.oidc.add_on.pkce.add_pkce_support kwargs: essential: True code_challenge_method: @@ -244,3 +265,65 @@ op: S256 S384 S512 + claims: + function: oidcendpoint.oidc.add_on.custom_scopes.add_custom_scopes + kwargs: + # that_nice_scope: + # - email + research_and_scholarship: + - name + - given_name + - family_name + - email + - email_verified + - sub + - iss + - eduperson_scoped_affiliation + + db_conf: + # abstract_storage_cls: oidcmsg.storage.extension.LabeledAbstractStorage + keyjar: + handler: oidcmsg.storage.abfile.LabeledAbstractFileSystem + fdir: storage/keyjar + key_conv: oidcmsg.storage.converter.QPKey + value_conv: cryptojwt.serialize.item.KeyIssuer + label: 'keyjar' + default: + handler: oidcmsg.storage.abfile.LabeledAbstractFileSystem + fdir: storage/default + key_conv: oidcmsg.storage.converter.QPKey + value_conv: oidcmsg.storage.converter.JSON + client: + handler: oidc_op.db_interfaces.OidcClientDb + # state: + # handler: oidcmsg.storage.abfile.LabeledAbstractFileSystem + # fdir: storage/state + # key_conv: oidcmsg.storage.converter.QPKey + # value_conv: oidcmsg.storage.converter.JSON + jti: + handler: oidcmsg.storage.abfile.LabeledAbstractFileSystem + fdir: storage/jti + key_conv: oidcmsg.storage.converter.QPKey + value_conv: oidcmsg.storage.converter.JSON + session: + # handler: oidcmsg.storage.abfile.AbstractFileSystem + handler: oidc_op.db_interfaces.OidcSessionDb + fdir: storage/session + key_conv: oidcmsg.storage.converter.QPKey + value_conv: oidcmsg.storage.converter.JSON + sso: + # handler: oidcmsg.storage.abfile.AbstractFileSystem + handler: oidc_op.db_interfaces.OidcSsoDb + fdir: storage/sso + key_conv: oidcmsg.storage.converter.QPKey + value_conv: oidcmsg.storage.converter.JSON + +# not used with gunicorn or any other production server ... +# 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/example/django_op/example/example/oidc_provider_settings.py b/example/django_op/example/example/oidc_provider_settings.py new file mode 100644 index 00000000..c4872f2c --- /dev/null +++ b/example/django_op/example/example/oidc_provider_settings.py @@ -0,0 +1,367 @@ +OIDCOP_CONF = { + "port": 8000, + "domain": "127.0.0.1", + "server_name": "{domain}:{port}", + "base_url": "https://{domain}:{port}", + "key_def": [ + { + "type": "RSA", + "use": [ + "sig" + ] + }, + { + "type": "EC", + "crv": "P-256", + "use": [ + "sig" + ] + } + ], + "OIDC_KEYS": { + "private_path": "data/oidc_op/private/jwks.json", + "key_defs": [ + { + "type": "RSA", + "use": [ + "sig" + ] + }, + { + "type": "EC", + "crv": "P-256", + "use": [ + "sig" + ] + } + ], + "public_path": "data/static/jwks.json", + "read_only": False, + "uri_path": "static/jwks.json" + }, + "op": { + # "seed": "CHANGE-THIS-RANDOMNESS!!!", + "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": "oidcop.user_authn.authn_context.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" + } + } + }, + "capabilities": { + "subject_types_supported": [ + "public", + "pairwise" + ], + "grant_types_supported": [ + "authorization_code", + "implicit", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + "refresh_token" + ], + # indicates that unknow/unavailable scopes requested by a RP + # would get a 403 error message instead of be declined implicitly. + # If False the op will only release the available scopes and ignoring the missings. + # Default to False + #deny_unknown_scopes: True + }, + "cookie_handler": { + "class": "oidcop.cookie_handler.CookieHandler", + "kwargs": { + "keys": { + "private_path": "data/oidc_op/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 + }, + "id_token": { + "class": "oidcop.id_token.IDToken", + "kwargs": { + "default_claims": { + "email": { + "essential": True + }, + "email_verified": { + "essential": True + } + } + } + }, + "issuer": "https://{domain}:{port}", + "keys": { + "private_path": "data/oidc_op/private/jwks.json", + "key_defs": [ + { + "type": "RSA", + "use": [ + "sig" + ] + }, + { + "type": "EC", + "crv": "P-256", + "use": [ + "sig" + ] + } + ], + "public_path": "data/static/jwks.json", + "read_only": False, + "uri_path": "static/jwks.json" + }, + "login_hint2acrs": { + "class": "oidcop.login_hint.LoginHint2Acrs", + "kwargs": { + "scheme_map": { + "email": [ + "oidcop.user_authn.authn_context.INTERNETPROTOCOLPASSWORD" + ] + } + } + }, + "session_key": { + "filename": "data/oidc_op/private/session_jwk.json", + "type": "OCT", + "use": "sig" + }, + "template_dir": "templates", + "token_handler_args": { + "jwks_def": { + "private_path": "data/oidc_op/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 + } + } + }, + "userinfo": { + "class": "oidc_provider.users.UserInfo", + "kwargs": { + # map claims to django user attributes here: + "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/example/django_op/example/example/settings.py b/example/django_op/example/example/settings.py index 980b5f59..828b452b 100644 --- a/example/django_op/example/example/settings.py +++ b/example/django_op/example/example/settings.py @@ -31,7 +31,7 @@ # Application definition INSTALLED_APPS = [ - 'unical_accounts', + 'accounts', 'django.contrib.admin', 'django.contrib.auth', @@ -40,10 +40,15 @@ 'django.contrib.messages', 'django.contrib.staticfiles', - 'oidc_op', + 'oidc_provider', ] +if 'oidc_provider' in INSTALLED_APPS: + from . oidc_provider_settings import OIDCOP_CONF + + + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -85,7 +90,7 @@ } } -AUTH_USER_MODEL = "unical_accounts.User" +AUTH_USER_MODEL = "accounts.User" # Password validation # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators @@ -109,13 +114,9 @@ # https://docs.djangoproject.com/en/2.2/topics/i18n/ LANGUAGE_CODE = 'en-us' - TIME_ZONE = 'UTC' - USE_I18N = True - USE_L10N = True - USE_TZ = True @@ -125,14 +126,12 @@ STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, 'data/static') -OIDCENDPOINT_CONFIG = 'example/oidc_op.conf.yaml' - # LOGGING LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'formatters': { - 'default': { + 'verbose': { # exact format is not important, this is the minimum information 'format': '%(asctime)s %(name)-12s %(levelname)-8s %(message)s', }, @@ -153,30 +152,59 @@ }, 'console': { 'formatter': 'detailed', - 'level': 'INFO', + 'level': 'DEBUG', 'class': 'logging.StreamHandler', }, }, 'loggers': { + 'django_test': { + 'handlers': ['console'], + 'level': 'DEBUG', + }, 'django': { 'handlers': ['console', 'mail_admins'], 'level': 'ERROR', - 'propagate': False, + 'propagate': True, }, - 'oidc_op': { - 'handlers': ['console', 'mail_admins'], - 'level': 'DEBUG', - 'propagate': False, + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': True, }, - 'oidcendpoint': { + # 'django.server': { + # 'handlers': ['console'], + # 'level': 'INFO', + # 'propagate': False, + # }, + 'oidc_provider': { 'handlers': ['console', 'mail_admins'], 'level': 'DEBUG', - 'propagate': False, + 'propagate': True, }, - 'oidcmsg': { + 'oidc_op': { 'handlers': ['console', 'mail_admins'], 'level': 'DEBUG', 'propagate': False, }, + # 'oidcop.endpoint_context': { + # 'handlers': ['console', 'mail_admins'], + # 'level': 'DEBUG', + # 'propagate': False, + # }, + # 'oidcop.sso_db': { + # 'handlers': ['console', 'mail_admins'], + # 'level': 'DEBUG', + # 'propagate': False, + # }, + # 'oidcop.session': { + # 'handlers': ['console', 'mail_admins'], + # 'level': 'DEBUG', + # 'propagate': False, + # }, + # 'oidcmsg': { + # 'handlers': ['console', 'mail_admins'], + # 'level': 'INFO', + # 'propagate': False, + # }, } } diff --git a/example/django_op/example/example/urls.py b/example/django_op/example/example/urls.py index 1d9a86ed..69c9bef1 100644 --- a/example/django_op/example/example/urls.py +++ b/example/django_op/example/example/urls.py @@ -25,6 +25,6 @@ urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) -if 'oidc_op' in settings.INSTALLED_APPS: - import oidc_op.urls - urlpatterns += path('', include((oidc_op.urls, 'oidc_op',))), +if 'oidc_provider' in settings.INSTALLED_APPS: + import oidc_provider.urls + urlpatterns += path('', include((oidc_provider.urls, 'oidc_op',))), diff --git a/example/django_op/example/oidc_op b/example/django_op/example/oidc_op deleted file mode 120000 index 108427ed..00000000 --- a/example/django_op/example/oidc_op +++ /dev/null @@ -1 +0,0 @@ -../oidc_op/ \ No newline at end of file diff --git a/example/django_op/example/oidc_provider b/example/django_op/example/oidc_provider new file mode 120000 index 00000000..6e773bec --- /dev/null +++ b/example/django_op/example/oidc_provider @@ -0,0 +1 @@ +../oidc_provider \ No newline at end of file diff --git a/example/django_op/example/run.bash b/example/django_op/example/run.bash new file mode 100644 index 00000000..113f6617 --- /dev/null +++ b/example/django_op/example/run.bash @@ -0,0 +1,7 @@ +#!/bin/bash + +./manage.py migrate +./manage.py collectstatic --no-input + +gunicorn example.wsgi -b0.0.0.0:8000 --keyfile=./data/oidc_op/certs/key.pem --certfile=./data/oidc_op/certs/cert.pem --reload --timeout 3600 --capture-output --enable-stdio-inheritance + diff --git a/example/django_op/example/unical_accounts/__init__.py b/example/django_op/example/unical_accounts/__init__.py deleted file mode 100644 index a4956ea5..00000000 --- a/example/django_op/example/unical_accounts/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -default_app_config = 'unical_accounts.apps.Unical_AccountsConfig' - diff --git a/example/django_op/oidc-op.dev.notes.md b/example/django_op/oidc-op.dev.notes.md new file mode 100644 index 00000000..83dbc9d3 --- /dev/null +++ b/example/django_op/oidc-op.dev.notes.md @@ -0,0 +1,260 @@ +# Provider discovery + +```` +endpoint +http_info +req_args.__dict__['_dict'] +current_app.server.endpoint_context.cdb +current_app.server.endpoint_context.session_manager.dump() + + +{'db': {}, 'salt': 'P3e1EPrBvoml1VDE8hBHXzALYI0AsMUP'} +```` + +# useful hints ... +- http_info +- req_args.to_json() +- req_args.__dict__['_dict'] + + +# Registration + +Dynamic client registration endpoint + +```` +endpoint +http_info +req_args.__dict__['_dict'] +current_app.server.endpoint_context.cdb +current_app.server.endpoint_context.session_manager.dump() + + + + +{'86M1io6O2Vdy': + {'client_id': '86M1io6O2Vdy', + 'client_salt': 'ehXmVjYE', + 'registration_access_token': 'lRail9TKK3Cj4kZdSt3KDorKVxyQvVGL', + 'registration_client_uri': 'https://127.0.0.1:5000/registration_api?client_id=86M1io6O2Vdy', + 'client_id_issued_at': 1619384394, + 'client_secret': '9f9a5b6dc23daca606c3766a1c6a0de29a2009b007be3d1da7ff8ca5', + 'client_secret_expires_at': 1621976394, + 'application_type': 'web', + 'response_types': ['code'], + 'contacts': ['ops@example.com'], + 'token_endpoint_auth_method': 'client_secret_basic', + 'post_logout_redirect_uris': [('https://127.0.0.1:8090/session_logout/local', '')], + 'jwks_uri': 'https://127.0.0.1:8090/static/jwks.json', + 'frontchannel_logout_uri': 'https://127.0.0.1:8090/fc_logout/local', + 'frontchannel_logout_session_required': True, + 'backchannel_logout_uri': 'https://127.0.0.1:8090/bc_logout/local', + 'grant_types': ['authorization_code'], + 'redirect_uris': [('https://127.0.0.1:8090/authz_cb/local', {})] + } +} + + + +{'db': {}, 'salt': 'P3e1EPrBvoml1VDE8hBHXzALYI0AsMUP'} +```` + +# Authorization endpont + +```` +http_info + +endpoint; current_app.server.endpoint_context.session_manager.dump() + + + +```` + + +Session dump +```` +{'eWM0Hi7tcdJ5': {'client_id': 'eWM0Hi7tcdJ5', 'client_salt': 'mb45L2cF', 'registration_access_token': 'Tob3Jw0hZ29yqd2HMJj7VhdF98G6jnqu', 'registration_client_uri': 'https://127.0.0.1:5000/registration_api?client_id=eWM0Hi7tcdJ5', 'client_id_issued_at': 1619260359, 'client_secret': 'a7439bd659c5058dbe667a1a5f6c837336f31102d35d435e9f090a2e', 'client_secret_expires_at': 1621852359, 'application_type': 'web', 'response_types': ['code'], 'contacts': ['ops@example.com'], 'token_endpoint_auth_method': 'client_secret_basic', 'post_logout_redirect_uris': [('https://127.0.0.1:8090/session_logout/local', '')], 'jwks_uri': 'https://127.0.0.1:8090/static/jwks.json', 'frontchannel_logout_uri': 'https://127.0.0.1:8090/fc_logout/local', 'frontchannel_logout_session_required': True, 'backchannel_logout_uri': 'https://127.0.0.1:8090/bc_logout/local', 'grant_types': ['authorization_code'], 'redirect_uris': [('https://127.0.0.1:8090/authz_cb/local', {})]}} +{'db': {}, 'salt': 'P3e1EPrBvoml1VDE8hBHXzALYI0AsMUP'} +```` + +# Token endpoint + +```` +http_info + +endpoint; current_app.server.endpoint_context.session_manager.dump() + +# current_app.server.endpoint_context.cdb not changes from the previous ... + +# session dump +{ + "db": { + "diana": [ + "oidcop.session.info.UserSessionInfo", + { + "subordinate": [ + "86M1io6O2Vdy" + ], + "revoked": false, + "type": "UserSessionInfo", + "extra_args": {}, + "user_id": "diana" + } + ], + "diana;;86M1io6O2Vdy": [ + "oidcop.session.info.ClientSessionInfo", + { + "subordinate": [ + "fcc1c962a60911eb9d4d57d896f78a5d" + ], + "revoked": false, + "type": "ClientSessionInfo", + "extra_args": {}, + "client_id": "86M1io6O2Vdy" + } + ], + "diana;;86M1io6O2Vdy;;fcc1c962a60911eb9d4d57d896f78a5d": [ + "oidcop.session.grant.Grant", + { + "expires_at": 1619427939, + "issued_at": 1619384739, + "not_before": 0, + "revoked": false, + "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" + ] + } + }, + "used": 2, + "authentication_event": { + "oidcop.authn_event.AuthnEvent": { + "uid": "diana", + "authn_info": "oidcop.user_authn.authn_context.INTERNETPROTOCOLPASSWORD", + "authn_time": 1619384739, + "valid_until": 1619388339 + } + }, + "authorization_request": { + "oidcmsg.oidc.AuthorizationRequest": { + "redirect_uri": "https://127.0.0.1:8090/authz_cb/local", + "scope": "openid profile email address phone", + "response_type": "code", + "nonce": "TXwiaGM9I8kEB4BbC4nqHNWc", + "state": "uKZM2ciKxWbg4x4xtsltzoy4PvjoQf4T", + "code_challenge": "WYVBXCNsPiDTe0lClNPG69qRB_yl6mJ2Lwop9XWjhYA", + "code_challenge_method": "S256", + "client_id": "86M1io6O2Vdy" + } + }, + "claims": { + "userinfo": { + "sub": null, + "name": null, + "given_name": null, + "family_name": null, + "middle_name": null, + "nickname": null, + "profile": null, + "picture": null, + "website": null, + "gender": null, + "birthdate": null, + "zoneinfo": null, + "locale": null, + "updated_at": null, + "preferred_username": null, + "email": null, + "email_verified": null, + "address": null, + "phone_number": null, + "phone_number_verified": null + }, + "introspection": {}, + "id_token": {}, + "access_token": {} + }, + "issued_token": [ + { + "expires_at": 0, + "issued_at": 1619384739, + "not_before": 0, + "revoked": false, + "usage_rules": { + "supports_minting": [ + "access_token", + "refresh_token", + "id_token" + ], + "max_usage": 1 + }, + "used": 1, + "claims": {}, + "id": "fcc1c963a60911eb9d4d57d896f78a5d", + "name": "AuthorizationCode", + "resources": [], + "scope": [], + "type": "authorization_code", + "value": "Z0FBQUFBQmdoZG1qdTlNZ0hzNGZzcVhZNnRJTXE2bkZZNGlVeXZTaDYwWlV4Vm1yMnFQWUZSSVFmb25HQjluQy1ZVWNXTWJlZ082OE03dVB1NmdBVG8xbkwxV1BWRTBZVkIzYXctY0xhTDB6c2hXUzhmeTRBNE9Ua3RxVVlmU0dDSElPeUJRb1VHQndtT21PR25nRWx3QXdoSG1DdklFM0REdjhWa2I2bWNtQzhFazdrRzBybWd4VV9oX19hcEt4MDZ3Uk5lNGpvbXllMVVmNkt4VXNRaW1FVHRTdS13ajVxczVibmtaXzRhXzhMcW9DOEFXVGtZND0=" + }, + { + "expires_at": 0, + "issued_at": 1619384739, + "not_before": 0, + "revoked": false, + "usage_rules": {}, + "used": 0, + "based_on": "Z0FBQUFBQmdoZG1qdTlNZ0hzNGZzcVhZNnRJTXE2bkZZNGlVeXZTaDYwWlV4Vm1yMnFQWUZSSVFmb25HQjluQy1ZVWNXTWJlZ082OE03dVB1NmdBVG8xbkwxV1BWRTBZVkIzYXctY0xhTDB6c2hXUzhmeTRBNE9Ua3RxVVlmU0dDSElPeUJRb1VHQndtT21PR25nRWx3QXdoSG1DdklFM0REdjhWa2I2bWNtQzhFazdrRzBybWd4VV9oX19hcEt4MDZ3Uk5lNGpvbXllMVVmNkt4VXNRaW1FVHRTdS13ajVxczVibmtaXzRhXzhMcW9DOEFXVGtZND0=", + "claims": {}, + "id": "fcc4fc72a60911eb9d4d57d896f78a5d", + "name": "AccessToken", + "resources": [], + "scope": [], + "type": "access_token", + "value": "eyJhbGciOiJFUzI1NiIsImtpZCI6IlNWUXpPV1ZVUm1oNWIxcHVVVmx1UlY4dGVVUlpVVlZTZFhkcFdVUTJTbTVMY1U0M01EWm1WV2REVlEifQ.eyJzY29wZSI6IFsib3BlbmlkIiwgInByb2ZpbGUiLCAiZW1haWwiLCAiYWRkcmVzcyIsICJwaG9uZSJdLCAiYXVkIjogWyI4Nk0xaW82TzJWZHkiXSwgInNpZCI6ICJkaWFuYTs7ODZNMWlvNk8yVmR5OztmY2MxYzk2MmE2MDkxMWViOWQ0ZDU3ZDg5NmY3OGE1ZCIsICJ0dHlwZSI6ICJUIiwgImlzcyI6ICJodHRwczovLzEyNy4wLjAuMTo1MDAwIiwgImlhdCI6IDE2MTkzODQ3MzksICJleHAiOiAxNjE5Mzg4MzM5fQ.Brva_I8bBM5z_1ZxFBWSRFN3U95y_YQxnLG5-51NrUmu862M-KSj4kd5v5vFGHiHF0iFvBuDLD6pSZL1RHXHCg" + } + ], + "resources": [ + "86M1io6O2Vdy" + ], + "scope": [ + "openid", + "profile", + "email", + "address", + "phone" + ], + "sub": "93be77e1b212f1643e0ee9dd5e477e2a2a231dc6ca22dd3273345e63eb156a23" + } + ], + "8ea62b28f57646fe8db31b4bdea0e262": [ + "oidcop.session.info.SessionInfo", + { + "subordinate": [], + "revoked": false, + "type": "", + "extra_args": {} + } + ] + }, + "salt": "1Kih63fBe5ympYSWi5z2aVXXCVKxqMvN" +} + +```` + +# Userinfo endpoint + +```` + +{'db': {'diana': ['oidcop.session.info.UserSessionInfo', {'subordinate': ['eWM0Hi7tcdJ5'], 'revoked': False, 'type': 'UserSessionInfo', 'extra_args': {}, 'user_id': 'diana'}], 'diana;;eWM0Hi7tcdJ5': ['oidcop.session.info.ClientSessionInfo', {'subordinate': ['c75b0e0ea4e811eba57a51f2252cef26'], 'revoked': False, 'type': 'ClientSessionInfo', 'extra_args': {}, 'client_id': 'eWM0Hi7tcdJ5'}], 'diana;;eWM0Hi7tcdJ5;;c75b0e0ea4e811eba57a51f2252cef26': ['oidcop.session.grant.Grant', {'expires_at': 0, 'issued_at': 1619260524, 'not_before': 0, 'revoked': False, 'usage_rules': {}, 'used': 2, 'authentication_event': {'oidcop.authn_event.AuthnEvent': {'uid': 'diana', 'authn_info': 'oidcop.user_authn.authn_context.INTERNETPROTOCOLPASSWORD', 'authn_time': 1619260524, 'valid_until': 1619264124}}, 'authorization_request': {'oidcmsg.oidc.AuthorizationRequest': {'redirect_uri': 'https://127.0.0.1:8090/authz_cb/local', 'scope': 'openid profile email address phone', 'response_type': 'code', 'nonce': 'xgR3dwSaW6s2q7sJ7Ar5KGEJ', 'state': 'AxNY1bnoRd5xCmTYDQIGRlq3XUCMEXoP', 'code_challenge': 'tfXn2btZVqbzkrSkyPUw1jAjtQXH2M2fUgLyqSMS0ak', 'code_challenge_method': 'S256', 'client_id': 'eWM0Hi7tcdJ5'}}, 'claims': {}, 'issued_token': [{'expires_at': 0, 'issued_at': 1619260524, 'not_before': 0, 'revoked': False, 'usage_rules': {'supports_minting': ['access_token', 'refresh_token'], 'max_usage': 1}, 'used': 1, 'claims': {}, 'id': 'c75b0e0fa4e811eba57a51f2252cef26', 'name': 'AuthorizationCode', 'resources': [], 'scope': [], 'type': 'authorization_code', 'value': 'Z0FBQUFBQmdnX1JzTEZ0ZDV0LWpic1RvYm95cEUtNG1BTV9XTzZyaFV1RW1rM2ppMnNCVThzb3RfemVMQ0hSd296Y1VyVF9OUXBXNGVRNjVjYkRqMl9leF9sQ2xnY3h3ZWh4X1FFeXFLMlhDZE9NTWtEcFZkU3RXNURTbTRrZ1Q5dEh5TWZrVlhmYnU2N0dwenBlM2J1WlNpYzY4cWRjTHUzYXZvWEc2TG0zSEtnY3ZKMGlvOFF4X19pWks0Zl9DUTRuU09ndnRNRTdJRmtNZ2NqU09aWDcxUlhjdl8tZmd6Z1NNcWViS1FjQjdnMGlZN21xcVJnRT0='}, {'expires_at': 0, 'issued_at': 1619260756, 'not_before': 0, 'revoked': False, 'usage_rules': {}, 'used': 0, 'based_on': 'Z0FBQUFBQmdnX1JzTEZ0ZDV0LWpic1RvYm95cEUtNG1BTV9XTzZyaFV1RW1rM2ppMnNCVThzb3RfemVMQ0hSd296Y1VyVF9OUXBXNGVRNjVjYkRqMl9leF9sQ2xnY3h3ZWh4X1FFeXFLMlhDZE9NTWtEcFZkU3RXNURTbTRrZ1Q5dEh5TWZrVlhmYnU2N0dwenBlM2J1WlNpYzY4cWRjTHUzYXZvWEc2TG0zSEtnY3ZKMGlvOFF4X19pWks0Zl9DUTRuU09ndnRNRTdJRmtNZ2NqU09aWDcxUlhjdl8tZmd6Z1NNcWViS1FjQjdnMGlZN21xcVJnRT0=', 'claims': {}, 'id': '519d54e6a4e911eba57a51f2252cef26', 'name': 'AccessToken', 'resources': [], 'scope': [], 'type': 'access_token', 'value': 'eyJhbGciOiJFUzI1NiIsImtpZCI6IlNWUXpPV1ZVUm1oNWIxcHVVVmx1UlY4dGVVUlpVVlZTZFhkcFdVUTJTbTVMY1U0M01EWm1WV2REVlEifQ.eyJzY29wZSI6IFtdLCAiYXVkIjogW10sICJzaWQiOiAiZGlhbmE7O2VXTTBIaTd0Y2RKNTs7Yzc1YjBlMGVhNGU4MTFlYmE1N2E1MWYyMjUyY2VmMjYiLCAidHR5cGUiOiAiVCIsICJpc3MiOiAiaHR0cHM6Ly8xMjcuMC4wLjE6NTAwMCIsICJpYXQiOiAxNjE5MjYwNzU2LCAiZXhwIjogMTYxOTI2NDM1Nn0.j-YSAF7M6naaq2w8ntPOi-55shCIpWWFKmluYS18wkPrp5L5NFViuhmhLRY1CHr_xtbWv944Ud06m0RKP7Gd0Q'}], 'resources': [], 'scope': [], 'sub': '8fb4f8ee2bad3d54e58fcc2bb4f56200391427fb587bbcb95ca535cd818fd914'}], 'c6171c52f7dd4dcfa51e28f9af5833fd': ['oidcop.session.info.SessionInfo', {'subordinate': [], 'revoked': False, 'type': '', 'extra_args': {}}]}, 'salt': 'P3e1EPrBvoml1VDE8hBHXzALYI0AsMUP'} +```` diff --git a/example/django_op/oidc_op/__init__.py b/example/django_op/oidc_op/__init__.py deleted file mode 100644 index 154a4637..00000000 --- a/example/django_op/oidc_op/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from . application import oidcendpoint_application - - -oidcendpoint_app = oidcendpoint_application() diff --git a/example/django_op/oidc_op/admin.py b/example/django_op/oidc_op/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/example/django_op/oidc_op/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/example/django_op/oidc_op/application.py b/example/django_op/oidc_op/application.py deleted file mode 100644 index 8b7183d5..00000000 --- a/example/django_op/oidc_op/application.py +++ /dev/null @@ -1,41 +0,0 @@ -import os - -from cryptojwt.key_jar import init_key_jar -from django.conf import settings -from oidcendpoint.endpoint_context import EndpointContext -from urllib.parse import urlparse - -from . configure import Configuration - - -def init_oidc_op_endpoints(app): - _config = app.srv_config.op - _server_info_config = _config['server_info'] - - _kj_args = {k:v for k,v in _server_info_config['jwks'].items() - if k != 'uri_path'} - _kj = init_key_jar(**_kj_args) - iss = _server_info_config['issuer'] - - # make sure I have a set of keys under my 'real' name - _kj.import_jwks_as_json(_kj.export_jwks_as_json(True, ''), iss) - _kj.verify_ssl = _config['server_info'].get('verify_ssl', False) - - endpoint_context = EndpointContext(_server_info_config, keyjar=_kj, - cwd=settings.BASE_DIR) - - return endpoint_context - - -def oidc_provider_init_app(config, name='oidc_op', **kwargs): - name = name or __name__ - app = type('OIDCAppEndpoint', (object,), {"srv_config": config}) - # Initialize the oidc_provider after views to be able to set correct urls - app.endpoint_context = init_oidc_op_endpoints(app) - return app - - -def oidcendpoint_application(config_file=settings.OIDCENDPOINT_CONFIG): - config = Configuration.create_from_config_file(config_file) - app = oidc_provider_init_app(config) - return app diff --git a/example/django_op/oidc_op/apps.py b/example/django_op/oidc_op/apps.py deleted file mode 100644 index 28699aa0..00000000 --- a/example/django_op/oidc_op/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class OidcOpConfig(AppConfig): - name = 'oidc_op' diff --git a/example/django_op/oidc_op/configure.py b/example/django_op/oidc_op/configure.py deleted file mode 100644 index 1b78fdd4..00000000 --- a/example/django_op/oidc_op/configure.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Configuration management for IDP""" - -import json -import os -import sys -from typing import Dict - -from cryptojwt.jwk.hmac import SYMKey -from cryptojwt.jwx import key_from_jwk_dict -from django.conf import settings - -from oidcop.logging import configure_logging -from oidcop.utils import load_yaml_config - -# TODO: check this -try: - from secrets import token_urlsafe as rnd_token -except ImportError: - from oidcop import rndstr as rnd_token - - -class Configuration: - """OP Configuration""" - - def __init__(self, conf: Dict) -> None: - self.logger = configure_logging(settings.LOGGING) - - # OIDC provider configuration - self.conf = conf - self.op = conf.get('op') - - # TODO: here manage static clients without dyn registration enabled - # self.oidc_clients = conf.get('oidc_clients', {}) - - # session key - self.session_jwk = conf.get('session_jwk') - if self.session_jwk is not None: - self.logger.debug("Reading session signer from %s", self.session_jwk) - try: - with open(self.session_jwk) as jwk_file: - jwk_dict = json.loads(jwk_file.read()) - self.session_key = key_from_jwk_dict(jwk_dict).k - except Exception: - self.logger.critical("Failed reading session signer from %s", - self.session_jwk) - sys.exit(-1) - else: - self.logger.debug("Generating random session signer") - self.session_key = SYMKey(key=rnd_token(32)).k - - # set OP session key - if self.op is not None: - if self.op['server_info'].get('password') is None: - key = self.session_key - self.op['server_info']['password'] = key - self.logger.debug("Set server password to %s", key) - - # templates environment - self.template_dir = os.path.abspath(conf.get('template_dir', 'templates')) - - - @classmethod - def create_from_config_file(cls, filename: str): - """Load configuration as YAML""" - return cls(load_yaml_config(filename)) diff --git a/example/django_op/oidc_op/models.py b/example/django_op/oidc_op/models.py deleted file mode 100644 index 71a83623..00000000 --- a/example/django_op/oidc_op/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/example/django_op/oidc_op/tests.py b/example/django_op/oidc_op/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/example/django_op/oidc_op/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/example/django_op/oidc_op/urls.py b/example/django_op/oidc_op/urls.py deleted file mode 100644 index 04a01de1..00000000 --- a/example/django_op/oidc_op/urls.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.urls import path - -from . import views - -app_name = 'oidc_op' - -urlpatterns = [ - path('.well-known/', views.well_known, name="oidc_op_well_known"), - path('registration', views.registration, name="oidc_op_registration"), - path('registration_api', views.registration_api, name="oidc_op_registration_api"), - - path('authorization', views.authorization, name="oidc_op_authorization"), - - path('verify/oidc_user_login/', views.verify_user, name="oidc_op_verify_user"), - path('token', views.token, name="oidc_op_token"), - path('userinfo', views.userinfo, name="oidc_op_userinfo"), - - path('session', views.session_endpoint, name="oidc_op_session"), - # logout - path('verify_logout', views.verify_logout, name="oidc_op_verify_logout"), - path('post_logout', views.post_logout, name="oidc_op_post_logout"), - path('rp_logout', views.rp_logout, name="oidc_op_rp_logout"), -] diff --git a/example/django_op/oidc_op/views.py b/example/django_op/oidc_op/views.py deleted file mode 100644 index c86c6cec..00000000 --- a/example/django_op/oidc_op/views.py +++ /dev/null @@ -1,289 +0,0 @@ -import base64 -import logging -import json -import os - -from django.conf import settings -from django.http import (HttpResponse, - HttpResponseBadRequest, - HttpResponseRedirect, - JsonResponse) -from django.views.decorators.csrf import csrf_exempt -from django.shortcuts import render, render_to_response -from django.urls import reverse -from django.utils.translation import gettext as _ -from oidcendpoint.authn_event import create_authn_event -from oidcendpoint.exception import FailedAuthentication -from oidcendpoint.oidc.token import AccessToken -from oidcmsg.oauth2 import ResponseMessage -from oidcmsg.oidc import AccessTokenRequest -from oidcmsg.oidc import AuthorizationRequest -from urllib import parse as urlib_parse -from urllib.parse import urlparse - -from oidc_op import oidcendpoint_app - - -logger = logging.getLogger(__name__) - - -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) - - -def add_cookie(resp, cookie_spec): - if isinstance(cookie_spec, list): - for _spec in cookie_spec: - _add_cookie(resp, _spec) - - -def do_response(endpoint, req_args, error='', **args): - info = endpoint.do_response(request=req_args, error=error, **args) - - logger = oidcendpoint_app.srv_config.logger - logger.debug('do_response: {}'.format(info)) - - try: - _response_placement = info['response_placement'] - except KeyError: - _response_placement = endpoint.response_placement - - logger.debug('response_placement: {}'.format(_response_placement)) - - if error: - if _response_placement == 'body': - logger.info('Error Response: {}'.format(info['response'])) - resp = HttpResponse(info['response'], status=400) - else: # _response_placement == 'url': - logger.info('Redirect to: {}'.format(info['response'])) - resp = HttpResponseRedirect(info['response']) - else: - if _response_placement == 'body': - logger.info('Response: {}'.format(info['response'])) - resp = HttpResponse(info['response'], status=200) - else: # _response_placement == 'url': - logger.info('Redirect to: {}'.format(info['response'])) - resp = HttpResponseRedirect(info['response']) - - for key, value in info['http_headers']: - # set response headers - resp[key] = value - - if 'cookie' in info: - add_cookie(resp, info['cookie']) - - return resp - - -def service_endpoint(request, endpoint): - """ - TODO: documentation here - """ - logger = oidcendpoint_app.srv_config.logger - logger.info('At the "{}" endpoint'.format(endpoint.endpoint_name)) - - # if hasattr(request, 'debug') and request.debug: - # import pdb; pdb.set_trace() - - authn = request.headers.get('Authorization', {}) - pr_args = {'auth': authn} - if authn: - logger.debug('request.headers["Authorization"] => {}'.format(pr_args)) - - if request.method == 'GET': - data = {k:v for k,v in request.GET.items()} - elif request.body: - data = request.body \ - if isinstance(request.body, str) else \ - request.body.decode() - # - if authn: - data = {k:v[0] for k,v in urlib_parse.parse_qs(data).items()} - else: - data = {k:v for k,v in request.POST.items()} - - # for .well-known resources like provider-config no data are submitted - # if not data: - # ... not possible in this implementation - - logger.debug('Request arguments [{}]: {}'.format(request.method, data)) - try: - req_args = endpoint.parse_request(data, **pr_args) - except Exception as err: - logger.error(err) - return JsonResponse(json.dumps({ - 'error': 'invalid_request', - 'error_description': str(err), - 'method': request.method - }), status=400) - - logger.info('request: {}'.format(req_args)) - if isinstance(req_args, ResponseMessage) and 'error' in req_args: - return JsonResponse(req_args.__dict__, status=400) - - if request.COOKIES: - logger.debug(request.COOKIES) - # TODO: cookie - kwargs = {'cookie': request.COOKIES} - else: - kwargs = {} - - try: - if isinstance(endpoint, AccessToken): - args = endpoint.process_request(AccessTokenRequest(**req_args), - **kwargs) - else: - args = endpoint.process_request(req_args, **kwargs) - except Exception as err: - message = '{}'.format(err) - logger.error(message) - return JsonResponse(json.dumps({ - 'error': 'invalid_request', - 'error_description': str(err) - }), status=400) - - logger.info('Response args: {}'.format(args)) - if 'redirect_location' in args: - return HttpResponseRedirect(args['redirect_location']) - if 'http_response' in args: - return HttpResponse(args['http_response'], status=200) - - return do_response(endpoint, req_args, **args) - - -def well_known(request, service): - """ - /.well-known/ - """ - if service == 'openid-configuration': - _endpoint = oidcendpoint_app.endpoint_context.endpoint['provider_config'] - # TODO - # if service == 'openid-federation': - # _endpoint = oidcendpoint_app.endpoint_context.endpoint['provider_info'] - elif service == 'webfinger': - _endpoint = oidcendpoint_app.endpoint_context.endpoint['webfinger'] - else: - return HttpResponseBadRequest('Not supported', status=400) - - return service_endpoint(request, _endpoint) - - -@csrf_exempt -def registration(request): - logger.info('registration request') - _endpoint = oidcendpoint_app.endpoint_context.endpoint['registration'] - return service_endpoint(request, _endpoint) - - -@csrf_exempt -def registration_api(): - logger.info('registration API') - return service_endpoint(request, - oidcendpoint_app.endpoint_context.endpoint['registration_api']) - - -def authorization(request): - _endpoint = oidcendpoint_app.endpoint_context.endpoint['authorization'] - return service_endpoint(request, _endpoint) - - -@csrf_exempt -def verify_user(request): - """csrf is not needed because it uses oidc token in the post - """ - token = request.POST.get('token') - if not token: - return HttpResponse('Access forbidden: invalid token.', status=403) - - authn_method = oidcendpoint_app.endpoint_context.\ - authn_broker.get_method_by_id('user') - - kwargs = dict([(k, v) for k, v in request.POST.items()]) - user = authn_method.verify(**kwargs) - if not user: - return HttpResponse('Authentication failed', status=403) - - auth_args = authn_method.unpack_token(kwargs['token']) - authz_request = AuthorizationRequest().from_urlencoded(auth_args['query']) - - # salt size can be customized in settings.OIDC_OP_AUTHN_SALT_SIZE - salt_size = getattr(settings, 'OIDC_OP_AUTHN_SALT_SIZE', 4) - authn_event = create_authn_event( - uid=user.username, - salt=base64.b64encode(os.urandom(salt_size)).decode(), - authn_info=auth_args['authn_class_ref'], - authn_time=auth_args['iat']) - - endpoint = oidcendpoint_app.endpoint_context.endpoint['authorization'] - args = endpoint.authz_part2(user=user.username, request=authz_request, - authn_event=authn_event) - - if isinstance(args, ResponseMessage) and 'error' in args: - return HttpResponse(args.to_json(), status=400) - - response = do_response(endpoint, request, **args) - return response - - -@csrf_exempt -def token(request): - logger.info('token request') - _endpoint = oidcendpoint_app.endpoint_context.endpoint['token'] - return service_endpoint(request, _endpoint) - - -@csrf_exempt -def userinfo(request): - logger.info('userinfo request') - _endpoint = oidcendpoint_app.endpoint_context.endpoint['userinfo'] - # if not hasattr(request, 'debug'): - # request.debug = 0 - # request.debug +=1 - return service_endpoint(request, _endpoint) - - -######## -# LOGOUT -######## -def session_endpoint(request): - return service_endpoint(request, - oidcendpoint_app.endpoint_context.endpoint['session']) - -@csrf_exempt -def rp_logout(request): - _endp = oidcendpoint_app.endpoint_context.endpoint['session'] - _info = _endp.unpack_signed_jwt(request.POST['sjwt']) - alla = request.POST.get('logout') - - _iframes = _endp.do_verified_logout(alla=alla, **_info) - if _iframes: - d = dict(frames=" ".join(_iframes), - size=len(_iframes), - timeout=5000, - postLogoutRedirectUri=_info['redirect_uri']) - res = render_to_response('frontchannel_logout.html', d) - - else: - res = HttpResponseRedirect(_info['redirect_uri']) - _kakor = _endp.kill_cookies() - _add_cookie(res, _kakor) - - return res - -def verify_logout(request): - part = urlparse(oidcendpoint_app.endpoint_context.issuer) - d = dict(op=part.hostname, - do_logout='rp_logout', - sjwt=request.GET['sjwt'] or request.POST['sjwt']) - return render_to_response('logout.html', d) - - -def post_logout(request): - return render_to_response('post_logout.html') diff --git a/example/django_op/oidc_provider/__init__.py b/example/django_op/oidc_provider/__init__.py new file mode 100644 index 00000000..4bd5dbb5 --- /dev/null +++ b/example/django_op/oidc_provider/__init__.py @@ -0,0 +1 @@ +default_app_config = 'oidc_provider.apps.OidcOpConfig' diff --git a/example/django_op/oidc_provider/admin.py b/example/django_op/oidc_provider/admin.py new file mode 100644 index 00000000..7a3c7456 --- /dev/null +++ b/example/django_op/oidc_provider/admin.py @@ -0,0 +1,214 @@ +import logging + +from django import forms +from django.contrib import admin +from django.contrib.sessions.models import Session +from django.utils.safestring import mark_safe + +from . models import * +from . utils import decode_token + + +logger = logging.getLogger(__name__) + + +class OidcRPContactModelForm(forms.ModelForm): + class Meta: + model = OidcRPContact + fields = ('__all__') + + +class OidcRPContactInline(admin.TabularInline): + model = OidcRPContact + form = OidcRPContactModelForm + extra = 0 + + +class OidcRPRedirectUriModelForm(forms.ModelForm): + class Meta: + model = OidcRPRedirectUri + fields = ('__all__') + + +class OidcRPRedirectUriInline(admin.TabularInline): + model = OidcRPRedirectUri + form = OidcRPRedirectUriModelForm + extra = 0 + + +class OidcRPGrantTypeModelForm(forms.ModelForm): + class Meta: + model = OidcRPGrantType + fields = ('__all__') + + +class OidcRPGrantTypeInline(admin.TabularInline): + model = OidcRPGrantType + form = OidcRPGrantTypeModelForm + extra = 0 + + +class OidcRPResponseTypeModelForm(forms.ModelForm): + class Meta: + model = OidcRPResponseType + fields = ('__all__') + + +class OidcRPResponseTypeInline(admin.TabularInline): + model = OidcRPResponseType + form = OidcRPResponseTypeModelForm + extra = 0 + + +class OidcRPScopeModelForm(forms.ModelForm): + class Meta: + model = OidcRPScope + fields = ('__all__') + + +class OidcRPScopeInline(admin.TabularInline): + model = OidcRPScope + form = OidcRPScopeModelForm + extra = 0 + + +@admin.register(OidcRelyingParty) +class OidcRelyingPartyAdmin(admin.ModelAdmin): + list_filter = ('created', 'modified', 'is_active') + list_display = ('client_id', 'created', + 'last_seen', 'is_active') + search_fields = ('client_id',) + list_editable = ('is_active',) + inlines = (OidcRPScopeInline, + OidcRPResponseTypeInline, + OidcRPGrantTypeInline, + OidcRPContactInline, + OidcRPRedirectUriInline) + fieldsets = ( + (None, { + 'fields': ( + ('client_id', 'client_secret',), + ('client_salt', 'jwks_uri'), + ('registration_client_uri',), + ('registration_access_token',), + ('application_type', + 'token_endpoint_auth_method'), + ('is_active', ) + ) + }, + ), + ('Temporal values', + { + 'fields': ( + (('client_id_issued_at', + 'client_secret_expires_at', + 'last_seen')), + + ), + + }, + ), + ) + + # def save_model(self, request, obj, form, change): + # res = False + # msg = '' + # try: + # json.dumps(obj.as_pysaml2_mdstore_row()) + # res = obj.validate() + # super(MetadataStoreAdmin, self).save_model(request, obj, form, change) + # except Exception as excp: + # obj.is_valid = False + # obj.save() + # msg = str(excp) + + # if not res: + # messages.set_level(request, messages.ERROR) + # _msg = _("Storage {} is not valid, if 'mdq' at least a " + # "valid url must be inserted. " + # "If local: at least a file or a valid path").format(obj.name) + # if msg: _msg = _msg + '. ' + msg + # messages.add_message(request, messages.ERROR, _msg) + + +@admin.register(Session) +class SessionAdmin(admin.ModelAdmin): + def _session_data(self, obj): + return obj.get_decoded() + list_display = ['session_key', '_session_data', 'expire_date'] + + +@admin.register(OidcSession) +class OidcSessionAdmin(admin.ModelAdmin): + list_filter = ('created', 'modified', 'valid_until') + list_display = ('client', 'state', 'sso', 'created') + search_fields = ('state', 'sso__user__username') + readonly_fields = ('sid', 'client', 'sso', 'state', 'valid_until', 'info_session_preview', + 'access_token_preview', 'id_token_preview') + + fieldsets = ( + (None, { + 'fields': ( + ('client', ), + ('sso', ), + ('state',), + ('sid',), + ('valid_until',), + ) + }, + ), + ('Session info', + { + 'classes': ('collapse',), + 'fields': ('info_session_preview',), + } + ), + + ('Token previews', + { + 'classes': ('collapse',), + 'fields': ( + ('access_token_preview'), + ('id_token_preview'), + ) + + }, + ), + ) + + def info_session_preview(self, obj): + msg = json.loads(obj.session_info or '{}') + dumps = json.dumps(msg, indent=2) + return mark_safe(dumps.replace('\n', '
').replace('\s', ' ')) + info_session_preview.short_description = 'Info Session preview' + + def access_token_preview(self, obj): + try: + msg = decode_token(obj.session_info or {}, 'access_token') + dumps = json.dumps(msg.to_dict(), indent=2) + return mark_safe(dumps.replace('\n', '
').replace('\s', ' ')) + except Exception as e: + logger.tracelog(e) + access_token_preview.short_description = 'Access Token preview' + + def id_token_preview(self, obj): + try: + msg = decode_token(obj.session_info or {}, 'id_token') + dumps = json.dumps(msg.to_dict(), indent=2) + return mark_safe(dumps.replace('\n', '
').replace('\s', ' ')) + except Exception as e: + logger.tracelog(e) + id_token_preview.short_description = 'ID Token preview' + + class Media: + js = ('js/textarea_autosize.js',) + # css = {'default': ('css/textarea_large.css',)} + + +@admin.register(OidcSessionSso) +class OidcSessionSsoAdmin(admin.ModelAdmin): + list_filter = ('created', 'modified') + list_display = ('user', + 'sub', 'created') + search_fields = ('user',) + readonly_fields = ('sub', 'user') diff --git a/example/django_op/oidc_provider/application.py b/example/django_op/oidc_provider/application.py new file mode 100644 index 00000000..7dba7f2a --- /dev/null +++ b/example/django_op/oidc_provider/application.py @@ -0,0 +1,49 @@ +import logging +import os + +from django.conf import settings +from oidcop.endpoint_context import EndpointContext +from oidcop.server import Server + +from urllib.parse import urlparse +from oidcop.configure import Configuration + +folder = os.path.dirname(os.path.realpath(__file__)) +logger = logging.getLogger(__name__) + + +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 + + server = Server(_server_info_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(config, name='oidc_op', **kwargs): + name = name or __name__ + app = type('OIDCAppEndpoint', (object,), {"srv_config": config}) + # Initialize the oidc_provider after views to be able to set correct urls + app.endpoint_context = init_oidc_op_endpoints(app) + return app + + +def oidcop_application(conf = settings.OIDCOP_CONF): + config = Configuration(conf = conf) + app = oidc_provider_init_app(config) + return app diff --git a/example/django_op/oidc_provider/apps.py b/example/django_op/oidc_provider/apps.py new file mode 100644 index 00000000..a25c1655 --- /dev/null +++ b/example/django_op/oidc_provider/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OidcOpConfig(AppConfig): + name = 'oidc_provider' + verbose_name = "OpenID Connect Provider" diff --git a/example/django_op/oidc_provider/migrations/0001_initial.py b/example/django_op/oidc_provider/migrations/0001_initial.py new file mode 100644 index 00000000..c4a063df --- /dev/null +++ b/example/django_op/oidc_provider/migrations/0001_initial.py @@ -0,0 +1,155 @@ +# Generated by Django 3.1.1 on 2021-04-27 14:11 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OidcRelyingParty', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('client_id', models.CharField(max_length=255, unique=True)), + ('client_salt', models.CharField(blank=True, max_length=255, null=True)), + ('registration_access_token', models.CharField(blank=True, max_length=255, null=True)), + ('registration_client_uri', models.URLField(blank=True, max_length=255, null=True)), + ('client_id_issued_at', models.DateTimeField(blank=True, null=True)), + ('client_secret', models.CharField(blank=True, help_text='It is not needed for Clients selecting a token_endpoint_auth_method of private_key_jwt', max_length=255, null=True)), + ('client_secret_expires_at', models.DateTimeField(blank=True, help_text='REQUIRED if client_secret is issued', null=True)), + ('application_type', models.CharField(blank=True, default='web', max_length=255, null=True)), + ('token_endpoint_auth_method', models.CharField(blank=True, choices=[('client_secret_post', 'client_secret_post'), ('client_secret_basic', 'client_secret_basic'), ('client_secret_jwt', 'client_secret_jwt'), ('private_key_jwt', 'private_key_jwt')], default='client_secret_basic', max_length=33, null=True)), + ('jwks_uri', models.URLField(blank=True, max_length=255, null=True)), + ('is_active', models.BooleanField(default=True, verbose_name='active')), + ('last_seen', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'Relying Party', + 'verbose_name_plural': 'Relying Parties', + }, + ), + migrations.CreateModel( + name='OidcSessionSso', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('sub', models.CharField(blank=True, max_length=255, null=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'SSO Session SSO', + 'verbose_name_plural': 'SSO Sessions SSO', + }, + ), + migrations.CreateModel( + name='OidcSession', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('user_uid', models.CharField(max_length=120)), + ('state', models.CharField(blank=True, max_length=255, null=True)), + ('session_info', models.TextField(blank=True, null=True)), + ('grant', models.TextField(blank=True, null=True)), + ('grant_id', models.CharField(blank=True, max_length=255, null=True)), + ('issued_at', models.DateTimeField(blank=True, null=True)), + ('valid_until', models.DateTimeField(blank=True, null=True)), + ('code', models.CharField(blank=True, max_length=1024, null=True)), + ('sid', models.CharField(blank=True, max_length=255, null=True)), + ('sub', models.CharField(blank=True, max_length=255, null=True)), + ('client', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.oidcrelyingparty')), + ('sso', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.oidcsessionsso')), + ], + options={ + 'verbose_name': 'SSO Session', + 'verbose_name_plural': 'SSO Sessions', + }, + ), + migrations.CreateModel( + name='OidcRPRedirectUri', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('uri', models.CharField(blank=True, max_length=254, null=True)), + ('values', models.CharField(blank=True, max_length=254, null=True)), + ('type', models.CharField(choices=[('redirect_uris', 'redirect_uris'), ('post_logout_redirect_uris', 'post_logout_redirect_uris')], max_length=33)), + ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.oidcrelyingparty')), + ], + options={ + 'verbose_name': 'Relying Party URI', + 'verbose_name_plural': 'Relying Parties URIs', + }, + ), + migrations.CreateModel( + name='OidcRPScope', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('scope', models.CharField(blank=True, max_length=254, null=True)), + ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.oidcrelyingparty')), + ], + options={ + 'verbose_name': 'Relying Party Scope', + 'verbose_name_plural': 'Relying Parties Scopes', + 'unique_together': {('client', 'scope')}, + }, + ), + migrations.CreateModel( + name='OidcRPResponseType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('response_type', models.CharField(choices=[('code', 'code'), ('token', 'token'), ('id_token', 'id_token'), ('code token', 'code token'), ('code id_token', 'code id_token'), ('id_token token', 'id_token token'), ('code id_token token', 'code id_token token'), ('none', 'none')], max_length=60)), + ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.oidcrelyingparty')), + ], + options={ + 'verbose_name': 'Relying Party Response Type', + 'verbose_name_plural': 'Relying Parties Response Types', + 'unique_together': {('client', 'response_type')}, + }, + ), + migrations.CreateModel( + name='OidcRPGrantType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('grant_type', models.CharField(choices=[('authorization_code', 'authorization_code'), ('implicit', 'implicit'), ('urn:ietf:params:oauth:grant-type:jwt-bearer', 'urn:ietf:params:oauth:grant-type:jwt-bearer'), ('refresh_token', 'refresh_token')], max_length=60)), + ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.oidcrelyingparty')), + ], + options={ + 'verbose_name': 'Relying Party GrantType', + 'verbose_name_plural': 'Relying Parties GrantTypes', + 'unique_together': {('client', 'grant_type')}, + }, + ), + migrations.CreateModel( + name='OidcRPContact', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('contact', models.CharField(blank=True, max_length=254, null=True)), + ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.oidcrelyingparty')), + ], + options={ + 'verbose_name': 'Relying Party Contact', + 'verbose_name_plural': 'Relying Parties Contacts', + 'unique_together': {('client', 'contact')}, + }, + ), + ] diff --git a/example/django_op/oidc_op/migrations/__init__.py b/example/django_op/oidc_provider/migrations/__init__.py similarity index 100% rename from example/django_op/oidc_op/migrations/__init__.py rename to example/django_op/oidc_provider/migrations/__init__.py diff --git a/example/django_op/oidc_provider/models.py b/example/django_op/oidc_provider/models.py new file mode 100644 index 00000000..b8fb1aba --- /dev/null +++ b/example/django_op/oidc_provider/models.py @@ -0,0 +1,582 @@ +import datetime +import json + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.db import models +from django.utils import timezone +from oidcop.utils import load_yaml_config + + +OIDC_RESPONSE_TYPES = settings.OIDCOP_CONF['op']['server_info'][ + 'endpoint']['authorization']['kwargs']['response_types_supported'] + +OIDC_TOKEN_AUTHN_METHODS = settings.OIDCOP_CONF['op']['server_info'][ + 'endpoint']['token']['kwargs']['client_authn_method'] + +OIDC_GRANT_TYPES = settings.OIDCOP_CONF['op']['server_info']['capabilities']['grant_types_supported'] + +TIMESTAMP_FIELDS = ['client_id_issued_at', 'client_secret_expires_at'] + +# configured in oidcop +# it's not a fixed value, it depends by clients. Here it just references JWTConnect-Python-OidcRP +OIDC_OP_STATE_VALUE_LEN = 32 +OIDC_OP_SID_VALUE_LEN = 56 +OIDC_OP_SUB_VALUE_LEN = 64 + + +# TODO: these test should be improved once oidcop will have specialized objects as values instead of simple strings +def is_state(value): + # USELESS: state is always generated by client/RP! + return len(value) == OIDC_OP_STATE_VALUE_LEN + + +def is_sid(value): + return len(value) == OIDC_OP_SID_VALUE_LEN + + +def is_sub(value): + return len(value) == OIDC_OP_SUB_VALUE_LEN + + +def is_code(value): + # Z0FBQUFBQmZEc1Z5Z1dMRVptX1J6d3AwTDVMdkVtbU1Rcm41VkVVbm03N3pwY21qYlpXc1M0ME1TU25fVlZMdm9MVnFKSW1zb3E4TW1aS0MzeVk4OWF2VjYtZ3FmZ0FXQkluUnVuSEJyWFhtcDFhOEdpTnFiVTdJME1qTFZoWWM2X3lQaGY0VGI0QWZNVUNJc3p6RnRMMWlOUUZzc0gtV3BsdVJvcTBIR3hsbk5SSmV1NVJ0M1N0UXcwV3JLeUR3N1NHYU54U21XVEFpYnBCSnBjN0dYeXFETVByT0J3YnZTSmlqblZSb3JXQmtuazFYdkU3cnNMaz0= + return len(value) > 256 + + +def get_client_by_id(client_id): + client = OidcRelyingParty.objects.filter( + client_id = client_id, + is_active = True + + ) + if client: + return client.last() + + +class TimeStampedModel(models.Model): + """ + An abstract base class model that provides self-updating + ``created`` and ``modified`` fields. + """ + created = models.DateTimeField(auto_now_add=True, editable=False) + modified = models.DateTimeField(auto_now=True, editable=False) + + class Meta: + abstract = True + + +class OidcRelyingParty(TimeStampedModel): + """ + See: https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata + + unique if available (check on save): + client_secret should be + registration_access_token unique if available + + issued -> number representing the number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time + client_salt -> must be autogenerated on save + """ + _TOKEN_AUTH_CHOICES = ((i, i) for i in OIDC_TOKEN_AUTHN_METHODS) + + client_id = models.CharField( + max_length=255, blank=False, null=False, unique=True + ) + client_salt = models.CharField( + max_length=255, blank=True, null=True + ) + registration_access_token = models.CharField( + max_length=255, blank=True, null=True + ) + registration_client_uri = models.URLField( + max_length=255, blank=True, null=True + ) + client_id_issued_at = models.DateTimeField(blank=True, null=True) + client_secret = models.CharField( + max_length=255, + blank=True, null=True, + help_text=('It is not needed for Clients ' + 'selecting a token_endpoint_auth_method ' + 'of private_key_jwt') + ) + client_secret_expires_at = models.DateTimeField( + blank=True, null=True, + help_text=('REQUIRED if client_secret is issued') + ) + application_type = models.CharField( + max_length=255, blank=True, + null=True, default='web' + ) + token_endpoint_auth_method = models.CharField( + choices=_TOKEN_AUTH_CHOICES, + max_length=33, + blank=True, null=True, + default="client_secret_basic" + ) + jwks_uri = models.URLField(max_length=255, blank=True, null=True) + post_logout_redirect_uris = models.CharField( + max_length=254, blank=True, null=True + ) + redirect_uris = models.CharField( + max_length=254, blank=True, null=True + ) + is_active = models.BooleanField(('active'), default=True) + last_seen = models.DateTimeField(blank=True, null=True) + + @property + def allowed_scopes(self): + scopes = self.oidcrpscope_set.filter(client=self) + if scopes: + return [i.scope for i in scopes] + else: + return ['openid'] + + @allowed_scopes.setter + def allowed_scopes(self, values): + for i in values: + scope = self.oidcrpscope_set.create(client=self, scope=i) + + @property + def contacts(self): + return [elem.contact + for elem in self.oidcrpcontact_set.filter(client=self)] + + @contacts.setter + def contacts(self, values): + old = self.oidcrpcontact_set.filter(client=self) + old.delete() + if isinstance(values, str): + value = [values] + for value in values: + self.oidcrpcontact_set.create(client=self, + contact=value) + + @property + def grant_types(self): + return [elem.grant_type + for elem in + self.oidcrpgranttype_set.filter(client=self)] + + @grant_types.setter + def grant_types(self, values): + old = self.oidcrpgranttype_set.filter(client=self) + old.delete() + if isinstance(values, str): + value = [values] + for value in values: + self.oidcrpgranttype_set.create(client=self, + grant_type=value) + + @property + def response_types(self): + return [ + elem.response_type + for elem in + self.oidcrpresponsetype_set.filter(client=self) + ] + + @response_types.setter + def response_types(self, values): + old = self.oidcrpresponsetype_set.filter(client=self) + old.delete() + if isinstance(values, str): + value = [values] + for value in values: + self.oidcrpresponsetype_set.create( + client=self, response_type=value + ) + + @property + def post_logout_redirect_uris(self): + l = [] + for elem in self.oidcrpredirecturi_set.\ + filter(client=self, type='post_logout_redirect_uris'): + l.append((elem.uri, json.loads(elem.values))) + return l + + @post_logout_redirect_uris.setter + def post_logout_redirect_uris(self, values): + old = self.oidcrpredirecturi_set.filter(client=self) + old.delete() + for value in values: + args = json.dumps(value[1] if value[1] else []) + self.oidcrpredirecturi_set.create( + client=self, + uri=value[0], + values=args, + type='post_logout_redirect_uris' + ) + + @property + def redirect_uris(self): + l = [] + for elem in self.oidcrpredirecturi_set.filter( + client=self, type='redirect_uris'): + l.append((elem.uri, json.loads(elem.values))) + return l + + @redirect_uris.setter + def redirect_uris(self, values): + old = self.oidcrpredirecturi_set.filter(client=self) + old.delete() + for value in values: + self.oidcrpredirecturi_set.create(client=self, + uri=value[0], + values=json.dumps(value[1]), + type='redirect_uris') + + class Meta: + verbose_name = ('Relying Party') + verbose_name_plural = ('Relying Parties') + + def copy(self): + """ + Compability with rohe approach based on dictionaries + """ + d = {k: v for k, v in self.__dict__.items() if k[0] != '_'} + disabled = ('created', 'modified', 'is_active', 'last_seen') + for dis in disabled: + d.pop(dis) + for key in TIMESTAMP_FIELDS: + if d.get(key): + d[key] = int(datetime.datetime.timestamp(d[key])) + + d['contacts'] = self.contacts + d['grant_types'] = self.grant_types + d['response_types'] = self.response_types + d['post_logout_redirect_uris'] = self.post_logout_redirect_uris + d['redirect_uris'] = self.redirect_uris + d['allowed_scopes'] = self.allowed_scopes + return d + + def __str__(self): + return '{}'.format(self.client_id) + + +class OidcRPResponseType(TimeStampedModel): + client = models.ForeignKey(OidcRelyingParty, on_delete=models.CASCADE) + response_type = models.CharField(choices=[(i, i) for i in OIDC_RESPONSE_TYPES], + max_length=60) + + class Meta: + verbose_name = ('Relying Party Response Type') + verbose_name_plural = ('Relying Parties Response Types') + unique_together = ('client', 'response_type') + + def __str__(self): + return '{}, [{}]'.format(self.client, self.response_type) + + +class OidcRPGrantType(TimeStampedModel): + client = models.ForeignKey(OidcRelyingParty, + on_delete=models.CASCADE) + grant_type = models.CharField(choices=[(i, i) + for i in OIDC_GRANT_TYPES], + max_length=60) + + class Meta: + verbose_name = ('Relying Party GrantType') + verbose_name_plural = ('Relying Parties GrantTypes') + unique_together = ('client', 'grant_type') + + def __str__(self): + return '{}, [{}]'.format(self.client, self.grant_type) + + +class OidcRPContact(TimeStampedModel): + client = models.ForeignKey(OidcRelyingParty, + on_delete=models.CASCADE) + contact = models.CharField(max_length=254, + blank=True, null=True,) + + class Meta: + verbose_name = ('Relying Party Contact') + verbose_name_plural = ('Relying Parties Contacts') + unique_together = ('client', 'contact') + + def __str__(self): + return '{}, [{}]'.format(self.client, self.contact) + + +class OidcRPRedirectUri(TimeStampedModel): + client = models.ForeignKey(OidcRelyingParty, + on_delete=models.CASCADE) + uri = models.CharField(max_length=254, + blank=True, null=True) + values = models.CharField(max_length=254, + blank=True, null=True) + type = models.CharField(choices=(('redirect_uris', 'redirect_uris'), + ('post_logout_redirect_uris', + 'post_logout_redirect_uris')), + max_length=33) + + class Meta: + verbose_name = ('Relying Party URI') + verbose_name_plural = ('Relying Parties URIs') + + def __str__(self): + return '{} [{}] {}'.format(self.client, self.uri, self.type) + + +class OidcRPScope(TimeStampedModel): + client = models.ForeignKey(OidcRelyingParty, + on_delete=models.CASCADE) + scope = models.CharField(max_length=254, + blank=True, null=True,) + + class Meta: + verbose_name = ('Relying Party Scope') + verbose_name_plural = ('Relying Parties Scopes') + unique_together = ('client', 'scope') + + def __str__(self): + return '{}, [{}]'.format(self.client, self.scope) + + +class OidcSessionSso(TimeStampedModel): + """ + """ + + user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, + blank=True, null=True) + sub = models.CharField(max_length=255, + blank=True, null=True) + + class Meta: + verbose_name = ('SSO Session SSO') + verbose_name_plural = ('SSO Sessions SSO') + + def get_session(self): + return OidcSession.objects.filter(sso=self).first() + + @property + def state(self): + session = self.get_session() + if session: + return session.state or '' + return '' + + @property + def username(self): + if self.user: + return self.user.username or '' + return '' + + def __iter__(self): + session = self.get_session() + yield session.sid + + def __contains__(self, k): + if getattr(self, k, None): + return True + else: + return False + + def __delitem__(self, name): + OidcSession.objects.filter(sso=self).delete() + self.delete() + + def __getitem__(self, name): + if is_sid(name): + if OidcSession.objects.filter(sid=name, sso=self): + return self + elif name == 'sid': + return self.sid + else: + if OidcSession.objects.filter(state=name, sso=self): + return self + return getattr(self, name) + + def get(self, name, default=None): + return self.__getattribute__(name) + + def __getattribute__(self, name): + if name == 'state': + return self + elif name == 'uid': + return self.user.username + elif name == 'sid': + return self + else: + return models.Model.__getattribute__(self, name) + + def __setattribute__(self, name, value): + if name == 'state': + self.sid = value + return + elif name == 'uid': + user = get_user_model().objects.filter(username=value[0]).first() + self.user = user + self.save() + return + elif name == 'sub': + self.sub = value[0] + self.save() + else: + return models.Model.__setattribute__(self, name, value) + + def __setitem__(self, key, value): + return self.__setattribute__(key, value) + + def append(self, value): + """multiple sid to a sso + """ + if is_sid(value): + if not isinstance(value, list): + value = [value] + + session = self.get_session() + session.sid = value[0] if isinstance(value, list) else value + session.save() + else: + # import pdb; pdb.set_trace() + _msg = '{} .append({}) with missing handler!' + logger.warn(_msg.format(self.__class__.name, value)) + + def __str__(self): + return 'user: {} - sub: {}'.format(self.username, + self.sub) + + +class OidcSession(TimeStampedModel): + """ + Store the session information in this model + """ + user_uid = models.CharField(max_length=120, + blank=False, null=False) + state = models.CharField(max_length=255, + blank=True, null=True) + client = models.ForeignKey(OidcRelyingParty, on_delete=models.CASCADE, + blank=True, null=True) + session_info = models.TextField(blank=True, null=True) + + grant = models.TextField(blank=True, null=True) + grant_id = models.CharField(max_length=255, + blank=True, null=True) + + issued_at = models.DateTimeField(blank=True, null=True) + valid_until = models.DateTimeField(blank=True, null=True) + + sso = models.ForeignKey(OidcSessionSso, on_delete=models.CASCADE, + blank=True, null=True) + code = models.CharField(max_length=1024, + blank=True, null=True) + sid = models.CharField(max_length=255, + blank=True, null=True) + sub = models.CharField(max_length=255, + blank=True, null=True) + + class Meta: + verbose_name = ('SSO Session') + verbose_name_plural = ('SSO Sessions') + + + @classmethod + def create_by(cls, **data): + + if data.get('client_id'): + client = get_client_by_id(data['client_id']) + data['client'] = client + data.pop('client_id') + + if data.get('session_info'): + data['session_info'] = json.dumps(data['session_info'].__dict__) + + res = cls.objects.create(**data) + return res + + + @classmethod + def get_by_sid(cls, value): + sids = cls.objects.filter(sid=value, + valid_until__gt=timezone.localtime()) + if sids: + return sids.last() + + + @classmethod + def get_session_by(cls, **data): + + if data.get('client_id'): + client = get_client_by_id(data['client_id']) + data.pop('client_id') + data['client'] = client + + + + data['valid_until__gt'] = timezone.localtime() + res = cls.objects.filter(**data) + if res: + return res.last() + + + @classmethod + def get_by_client_id(self, uid): + res = cls.objects.filter(uid=value, + valid_until__gt=timezone.localtime()) + if res: + return self.session_info + + + def set_grant(self, grant): + """ + {'issued_at': 1615403213, 'not_before': 0, 'expires_at': 0, + 'revoked': False, 'used': 0, 'usage_rules': {}, 'scope': [], + 'authorization_details': None, + 'authorization_request': , + 'authentication_event': , + 'claims': {}, 'resources': [], 'issued_token': [], + 'id': 'c695a5e881d311eb905343ee297b1c98', + 'sub': '204176ab8fe8917ee4788683bcee4ebc04bfe1ab659485ec61b2b2b4108c5272', + 'token_map': { + 'authorization_code': , + 'access_token': , + 'refresh_token': } + } + """ + self.issued_at = timezone.make_aware(timezone.datetime.fromtimestamp(grant.issued_at)) + self.sub = grant.sub + self.grant_id = grant.id + + grant.authorization_request = grant.authorization_request.to_json() + grant.authentication_event = grant.authentication_event.to_json() + # breakpoint() + # grant.token_map['authorization_code'] = grant.token_map['authorization_code'].to_json() + # grant.token_map['access_token'] = grant.token_map['access_token'].to_json() + # grant.token_map['refresh_token'] = grant.token_map['refresh_token'].to_json() + grant.token_map.pop('authorization_code') + grant.token_map.pop('access_token') + grant.token_map.pop('refresh_token') + + self.grant = grant.to_json() + self.save() + return grant + + @classmethod + def get_by_session_id(cls, user_uid, client_id, grant_id): + grant = cls.objects.filter(user_uid = user_uid, + client__client_id = client_id, + grant_id = grant_id) + if grant: + return grant.last() + + + + def copy(self): + return dict(sid=self.sid or [], + state=self.state or '', + session_info=self.session_info) + + + def append(self, value): + """Not used, only back compatibility + """ + + + def __iter__(self): + for i in (self.sid,): + yield i + + + def __str__(self): + return 'state: {}'.format(self.state or '') diff --git a/example/django_op/oidc_provider/tests.py b/example/django_op/oidc_provider/tests.py new file mode 100644 index 00000000..49290204 --- /dev/null +++ b/example/django_op/oidc_provider/tests.py @@ -0,0 +1,2 @@ + +# Create your tests here. diff --git a/example/django_op/oidc_provider/tests/01_client_db.py b/example/django_op/oidc_provider/tests/01_client_db.py new file mode 100644 index 00000000..3653f310 --- /dev/null +++ b/example/django_op/oidc_provider/tests/01_client_db.py @@ -0,0 +1,102 @@ +import datetime +import logging +import json +import random +import string +import pytz + +from django.test import TestCase +from oidc_provider.db_interfaces import OidcClientDatabase +from oidc_provider.models import TIMESTAMP_FIELDS, OidcRelyingParty + + +logger = logging.getLogger('django_test') + + +def randomString(stringLength=10): + """Generate a random string of fixed length """ + letters = string.ascii_lowercase + return ''.join(random.choice(letters) for i in range(stringLength)) + + +CLIENT_ID = randomString() +CLIENT_TEST = {'client_id': '{}'.format(CLIENT_ID), + 'client_salt': '6flfsj0Z', + 'registration_access_token': 'z3PCMmC1HZ1QmXeXGOQMJpWQNQynM4xY', + 'registration_client_uri': 'https://127.0.0.1:8000/registration_api?client_id={}'.format(CLIENT_ID), + 'client_id_issued_at': 1575460012, + 'client_secret': '19cc69b70d0108f630e52f72f7a3bd37ba4e11678ad1a7434e9818e1', + 'client_secret_expires_at': 1575892012, + 'application_type': 'web', + 'contacts': ['ops@example.com'], + 'token_endpoint_auth_method': 'client_secret_basic', + 'jwks_uri': 'https://127.0.0.1:8099/static/jwks.json', + 'redirect_uris': [('https://127.0.0.1:8099/authz_cb/django_oidc_op', {})], + 'post_logout_redirect_uris': [('https://127.0.0.1:8099', None)], + 'response_types': ['code'], + 'grant_types': ['authorization_code'] + } + + +class TestRP(TestCase): + rp = randomString().upper() + cdb = OidcClientDatabase() + now = pytz.utc.localize(datetime.datetime.utcnow()) + + def setUp(self): + self.client_obj = OidcRelyingParty.objects.create(client_id=self.rp, + client_secret_expires_at=self.now, + client_id_issued_at=self.now, + is_active=True) + self.client = self.cdb[self.rp] + print('Created and fetched RP: {}'.format(self.client)) + + def test_get_set_client_db(self): + for key in TIMESTAMP_FIELDS: + value = self.client[key] + assert isinstance(value, int) or \ + isinstance(value, float) + + # test_set_timestamp + for key in TIMESTAMP_FIELDS: + dt_value = self.now+datetime.timedelta(minutes=-60) + self.client[key] = datetime.datetime.timestamp(dt_value) + assert isinstance(self.client[key], int) or \ + isinstance(self.client[key], float) + + # contacts + vt = ['testami@ora.it'] + self.client['contacts'] = vt + assert self.client['contacts'] == vt + # self.client_obj.contacts == vt + + # grant_types + vt = ['authorization_code'] + self.client['grant_types'] = vt + assert self.client['grant_types'] == vt + # self.client_obj.grant_type == vt + + # response_types + vt = ['code'] + self.client['response_types'] = vt + assert self.client['response_types'] == vt + # self.client_obj.response_types == vt + + # post_logout_redirect_uris + vt = [('https://127.0.0.1:8099', None)] + self.client['post_logout_redirect_uris'] = vt + assert self.client['post_logout_redirect_uris'] == vt + # self.client_obj.post_logout_redirect_uris == vt + + # redirect_uris + vt = [('https://127.0.0.1:8099/authz_cb/django_oidc_op', {})] + self.client['redirect_uris'] = vt + assert self.client['redirect_uris'] == vt + # self.client_obj.redirect_uris == vt + + logger.info(json.dumps(self.client.copy(), indent=2)) + + def test_create_as_dict(self): + logger.info('Test creare ad Dict') + self.cdb[CLIENT_ID] = CLIENT_TEST + logger.info(json.dumps(self.cdb[CLIENT_ID], indent=2)) diff --git a/example/django_op/oidc_provider/tests/__init__.py b/example/django_op/oidc_provider/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/django_op/oidc_provider/urls.py b/example/django_op/oidc_provider/urls.py new file mode 100644 index 00000000..5d58e825 --- /dev/null +++ b/example/django_op/oidc_provider/urls.py @@ -0,0 +1,33 @@ +from django.urls import path + +from . import views + +app_name = 'oidc_provider' + + +urlpatterns = [ + path('.well-known/', views.well_known, + name="oidc_op_well_known"), + path('registration', views.registration, + name="oidc_op_registration"), + path('registration_api', views.registration_api, + name="oidc_op_registration_api"), + + path('authorization', views.authorization, + name="oidc_op_authorization"), + + path('verify/oidc_user_login/', views.verify_user, + name="oidc_op_verify_user"), + path('token', views.token, name="oidc_op_token"), + path('userinfo', views.userinfo, name="oidc_op_userinfo"), + + + path('check_session_iframe', views.check_session_iframe, + name="oidc_op_check_session_iframe"), + path('session', views.session_endpoint, name="oidc_op_session"), + # logout + path('verify_logout', views.verify_logout, + name="oidc_op_verify_logout"), + path('post_logout', views.post_logout, name="oidc_op_post_logout"), + path('rp_logout', views.rp_logout, name="oidc_op_rp_logout"), +] diff --git a/example/django_op/oidc_op/users.py b/example/django_op/oidc_provider/users.py similarity index 78% rename from example/django_op/oidc_op/users.py rename to example/django_op/oidc_provider/users.py index dbc4c331..65c584bb 100644 --- a/example/django_op/oidc_op/users.py +++ b/example/django_op/oidc_provider/users.py @@ -4,32 +4,29 @@ from django.contrib.auth import get_user_model from django.template.loader import render_to_string -from oidcendpoint.util import instantiate -from oidcendpoint.user_authn.user import (create_signed_jwt, - verify_signed_jwt) -from oidcendpoint.user_authn.user import UserAuthnMethod +from oidcop.user_authn.user import (create_signed_jwt, LABELS) +from oidcop.user_authn.user import UserAuthnMethod class UserPassDjango(UserAuthnMethod): """ - see oidcendpoint.authn_context - oidcendpoint.endpoint_context + see oidcop.authn_context + oidcop.endpoint_context https://docs.djangoproject.com/en/2.2/ref/templates/api/#rendering-a-context """ # TODO: get this though settings conf url_endpoint = "/verify/user_pass_django" - def __init__(self, # template_handler=render_to_string, template="oidc_login.html", - endpoint_context=None, verify_endpoint='', **kwargs): + server_get=None, verify_endpoint='', **kwargs): """ template_handler is only for backwards compatibility it will be always replaced by Django's default """ - super(UserPassDjango, self).__init__(endpoint_context=endpoint_context) + super(UserPassDjango, self).__init__(server_get=server_get) self.kwargs = kwargs self.kwargs.setdefault("page_header", "Log in") @@ -50,9 +47,8 @@ def __init__(self, self.action = verify_endpoint or self.url_endpoint self.kwargs['action'] = self.action - def __call__(self, **kwargs): - _ec = self.endpoint_context + _ec = self.server_get('endpoint_context') # Stores information need afterwards in a signed JWT that then # appears as a hidden input in the form jws = create_signed_jwt(_ec.issuer, _ec.keyjar, **kwargs) @@ -62,13 +58,9 @@ def __call__(self, **kwargs): _kwargs = self.kwargs.copy() for attr in ['policy', 'tos', 'logo']: _uri = '{}_uri'.format(attr) - try: - _kwargs[_uri] = kwargs[_uri] - except KeyError: - pass - else: - _label = '{}_label'.format(attr) - _kwargs[_label] = LABELS[_uri] + _kwargs[_uri] = kwargs.get(_uri) + _label = '{}_label'.format(attr) + _kwargs[_label] = LABELS[_uri] return self.template_handler(self.template, _kwargs) @@ -76,7 +68,8 @@ def verify(self, *args, **kwargs): username = kwargs["username"] password = kwargs["password"] - user = authenticate(username=username, password=password) + user = authenticate(username=username, + password=password) if username: return user @@ -112,18 +105,21 @@ def filter(self, user, user_info_claims=None): for key, restr in user_info_claims.items(): if key in self.claims_map: # manage required and optional: TODO extends this approach - if not hasattr(user, self.claims_map[key]) and restr == {"essential": True}: + if not hasattr(user, self.claims_map.get(key)) and \ + restr == {"essential": True}: missing.append(key) continue else: optional.append(key) # uattr = getattr(user, self.claims_map[key], None) - if not uattr: continue + if not uattr: + continue result[key] = uattr() if callable(uattr) else uattr return result - def __call__(self, user_id, client_id, user_info_claims=None, **kwargs): + def __call__(self, user_id, client_id, + user_info_claims=None, **kwargs): """ user_id = username client_id = client id, ex: 'mHwpZsDeWo5g' @@ -133,10 +129,7 @@ def __call__(self, user_id, client_id, user_info_claims=None, **kwargs): # Todo: raise exception here, this wouldn't be possible. return {} - try: - return self.filter(user, user_info_claims) - except KeyError: - return {} + return self.filter(user, user_info_claims) def search(self, **kwargs): for uid, args in self.db.items(): diff --git a/example/django_op/oidc_provider/utils.py b/example/django_op/oidc_provider/utils.py new file mode 100644 index 00000000..1700178d --- /dev/null +++ b/example/django_op/oidc_provider/utils.py @@ -0,0 +1,31 @@ +import datetime +import json +import pytz + +from oidcmsg.message import Message +from cryptojwt.key_jar import KeyJar + +from . views import oidcop_app + + +def timestamp2dt(value): + return int(datetime.datetime.timestamp(value)) + + +def dt2timestamp(value): + pytz.utc.localize(datetime.datetime.fromtimestamp(value)) + + +def decode_token(txt, attr_name='access_token', verify_sign=True): + issuer = oidcop_app.srv_config.conf['op']['server_info']['issuer'] + jwks_path = oidcop_app.srv_config.conf['OIDC_KEYS']['private_path'] + jwks = json.loads(open(jwks_path).read()) + + key_jar = KeyJar() + key_jar.import_jwks(jwks, issuer=issuer) + + jwt = json.loads(txt) + msg = Message().from_jwt(jwt.get(attr_name, ''), + keyjar=key_jar, + verify=verify_sign) + return msg diff --git a/example/django_op/oidc_provider/views.py b/example/django_op/oidc_provider/views.py new file mode 100644 index 00000000..89b7f023 --- /dev/null +++ b/example/django_op/oidc_provider/views.py @@ -0,0 +1,351 @@ +import base64 +import logging +import json +import os +import urllib + +from django.conf import settings +from django.http import (HttpResponse, + HttpResponseBadRequest, + HttpResponseRedirect, + JsonResponse) +from django.http.request import QueryDict +from django.views.decorators.csrf import csrf_exempt +from django.shortcuts import render +from oidcop.authn_event import create_authn_event +from oidcop.exception import UnAuthorizedClient +from oidcop.exception import UnAuthorizedClientScope # experimental +from oidcop.exception import InvalidClient +from oidcop.exception import UnknownClient +from oidcop.session.token import AccessToken +from oidcop.oidc.token import Token +from oidcmsg.oauth2 import ResponseMessage +from oidcmsg.oidc import AccessTokenRequest +from oidcmsg.oidc import AuthorizationRequest +from urllib import parse as urlib_parse +from urllib.parse import urlparse + + +from . application import oidcop_application +oidcop_app = oidcop_application() + +logger = logging.getLogger(__name__) +IGNORE = ["cookie", "user-agent"] + + +def _add_cookie(resp, cookie_spec): + 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) + + +def do_response(endpoint, req_args, error='', **args): + info = endpoint.do_response(request=req_args, error=error, **args) + _response_placement = info.get('response_placement') + if not _response_placement: + _response_placement = endpoint.response_placement + + info_response = info['response'] + # Debugging things + try: + response_params = json.dumps(json.loads(info_response), indent=2) + logger.debug('Response params: {}\n'.format(response_params)) + except: + url, args = urllib.parse.splitquery(info_response) + response_params = urllib.parse.parse_qs(args) + resp = json.dumps(response_params, indent=2) + logger.debug('Response params: {}\n{}\n\n'.format(url, resp)) + # end debugging + + if error: + if _response_placement == 'body': + logger.debug('Error Response [Body]: {}'.format(info_response)) + resp = HttpResponse(info_response, status=400) + else: # _response_placement == 'url': + logger.debug('Redirect to: {}'.format(info_response)) + resp = HttpResponseRedirect(info_response) + else: + if _response_placement == 'body': + #logger.debug('Response [Body]: {}'.format(info_response)) + resp = HttpResponse(info_response, status=200) + else: # _response_placement == 'url': + #logger.debug('Redirect to: {}'.format(info_response)) + resp = HttpResponseRedirect(info_response) + + for key, value in info['http_headers']: + # set response headers + resp[key] = value + + if 'cookie' in info: + add_cookie(resp, info['cookie']) + + return resp + + +def fancy_debug(request): + """ + fancy logging of JWT things + """ + _headers = json.dumps(dict(request.headers), indent=2) + logger.debug('Request Headers: {}\n\n'.format(_headers)) + + _get = json.dumps(dict(request.GET), indent=2) + if request.GET: + logger.debug('Request arguments GET: {}\n'.format(_get)) + if request.POST or request.body: + _post = request.POST or request.body + if isinstance(_post, bytes): + _post = json.dumps(json.loads(_post.decode()), indent=2) + elif isinstance(_post, QueryDict): + _post = json.dumps({k: v for k, v in _post.items()}, + indent=2) + logger.debug('Request arguments POST: {}\n'.format(_post)) + + +def service_endpoint(request, endpoint): + """ + TODO: documentation here + """ + logger.info('\n\nRequest at the "{}" endpoint'.format(endpoint.name)) + # if logger.level == 0: + # fancy_debug(request) + + http_info = { + "headers": {k.lower(): v for k, v in request.headers.items() if k not in IGNORE}, + "method": request.method, + "url": request.get_raw_uri(), + # name is not unique + "cookie": [{"name": k, "value": v} for k, v in request.COOKIES.items()] + } + + if request.method == 'GET': + data = {k: v for k, v in request.GET.items()} + elif request.body: + data = request.body \ + if isinstance(request.body, str) else \ + request.body.decode() + # + if 'authorization' in http_info.get('headers', ()): + data = {k: v[0] for k, v in urlib_parse.parse_qs(data).items()} + else: + data = {k: v for k, v in request.POST.items()} + + req_args = endpoint.parse_request(data, http_info=http_info) + + try: + if isinstance(endpoint, Token): + args = endpoint.process_request( + AccessTokenRequest(**req_args), http_info=http_info + ) + else: + args = endpoint.process_request(req_args, http_info=http_info) + except (InvalidClient, UnknownClient, UnAuthorizedClient) as err: + logger.error(err) + return JsonResponse(json.dumps({ + 'error': 'unauthorized_client', + 'error_description': str(err) + }), safe=False, status=400) + except Exception as err: + logger.error(err) + return JsonResponse(json.dumps({ + 'error': 'invalid_request', + 'error_description': str(err), + 'method': request.method + }), safe=False, status=400) + + if isinstance(req_args, ResponseMessage) and 'error' in req_args: + return JsonResponse(req_args.__dict__, status=400) + + if 'redirect_location' in args: + return HttpResponseRedirect(args['redirect_location']) + if 'http_response' in args: + return HttpResponse(args['http_response'], status=200) + + return do_response(endpoint, req_args, **args) + + +def well_known(request, service): + """ + /.well-known/ + """ + if service == 'openid-configuration': + _endpoint = oidcop_app.endpoint_context.endpoint['provider_config'] + # TODO fedservice integration here + # if service == 'openid-federation': + # _endpoint = oidcop_app.endpoint_context.endpoint['provider_info'] + elif service == 'webfinger': + _endpoint = oidcop_app.endpoint_context.endpoint['webfinger'] + else: + return HttpResponseBadRequest('Not supported', status=400) + return service_endpoint(request, _endpoint) + + +@csrf_exempt +def registration(request): + logger.info('registration request') + _endpoint = oidcop_app.endpoint_context.endpoint['registration'] + return service_endpoint(request, _endpoint) + + +@csrf_exempt +def registration_api(): + logger.info('registration API') + return service_endpoint( + request, + oidcop_app.endpoint_context.endpoint['registration_api'] + ) + + +def authorization(request): + _endpoint = oidcop_app.endpoint_context.endpoint['authorization'] + return service_endpoint(request, _endpoint) + + +@csrf_exempt +def verify_user(request): + """csrf is not needed because it uses oidc token in the post + """ + token = request.POST.get('token') + if not token: + return HttpResponse('Access forbidden: invalid token.', status=403) + authn_method = oidcop_app.endpoint_context.endpoint_context.authn_broker.get_method_by_id('user') + + kwargs = dict([(k, v) for k, v in request.POST.items()]) + user = authn_method.verify(**kwargs) + if not user: + return HttpResponse('Authentication failed', status=403) + + auth_args = authn_method.unpack_token(kwargs['token']) + authz_request = AuthorizationRequest().from_urlencoded(auth_args['query']) + + # salt size can be customized in settings.OIDC_OP_AUTHN_SALT_SIZE + salt_size = getattr(settings, 'OIDC_OP_AUTHN_SALT_SIZE', 4) + authn_event = create_authn_event( + uid=user.username, + salt=base64.b64encode(os.urandom(salt_size)).decode(), + authn_info=auth_args['authn_class_ref'], + authn_time=auth_args['iat'] + ) + + endpoint = oidcop_app.endpoint_context.endpoint['authorization'] + # cinfo = endpoint.endpoint_context.cdb[authz_request["client_id"]] + + # {'session_id': 'diana;;client_3;;38044288819611eb905343ee297b1c98', 'identity': {'uid': 'diana'}, 'user': 'diana'} + client_id = authz_request["client_id"] + _token_usage_rules = endpoint.server_get("endpoint_context").authn_broker.get_method_by_id('user') + + session_manager = oidcop_app.endpoint_context.endpoint_context.session_manager + _session_id = session_manager.create_session( + authn_event=authn_event, + auth_req=authz_request, + user_id=user.username, + client_id=client_id, + token_usage_rules=_token_usage_rules + ) + + try: + args = endpoint.authz_part2(user=user.username, + session_id = _session_id, + request=authz_request, + authn_event=authn_event) + except ValueError as excp: + msg = 'Something wrong with your Session ... {}'.format(excp) + return HttpResponse(msg, status=403) + + if isinstance(args, ResponseMessage) and 'error' in args: + return HttpResponse(args.to_json(), status=400) + + response = do_response(endpoint, request, **args) + return response + + +@csrf_exempt +def token(request): + logger.info('token request') + _endpoint = oidcop_app.endpoint_context.endpoint['token'] + return service_endpoint(request, _endpoint) + + +@csrf_exempt +def userinfo(request): + logger.info('userinfo request') + _endpoint = oidcop_app.endpoint_context.endpoint['userinfo'] + return service_endpoint(request, _endpoint) + + +######## +# LOGOUT +######## +def session_endpoint(request): + return service_endpoint( + request, + oidcop_app.endpoint_context.endpoint['session'] + ) + + +def check_session_iframe(request): + if request.method == 'GET': + req_args = request.GET + elif request.method == 'POST': + req_args = json.loads(request.POST) + else: + req_args = dict([(k, v) for k, v in request.body.items()]) + + if req_args: + # will contain client_id and origin + if req_args['origin'] != oidcop_app.endpoint_context.conf['issuer']: + return 'error' + if req_args['client_id'] != current_app.endpoint_context.cdb: + return 'error' + return 'OK' + + logger.debug('check_session_iframe: {}'.format(req_args)) + res = render(request, template_name='check_session_iframe.html') + return res + + +@csrf_exempt +def rp_logout(request): + _endp = oidcop_app.endpoint_context.endpoint['session'] + _info = _endp.unpack_signed_jwt(request.POST['sjwt']) + alla = None # request.POST.get('logout') + + _iframes = _endp.do_verified_logout(alla=alla, **_info) + if _iframes: + d = dict(frames=" ".join(_iframes), + size=len(_iframes), + timeout=5000, + postLogoutRedirectUri=_info['redirect_uri']) + res = render(request, 'frontchannel_logout.html', d) + + else: + res = HttpResponseRedirect(_info['redirect_uri']) + try: + _endp.kill_cookies() + except AttributeError: + logger.debug('Cookie not implemented or not working.') + #_add_cookie(res, _kakor) + return res + + +def verify_logout(request): + part = urlparse(oidcop_app.endpoint_context.conf['issuer']) + d = dict(op=part.hostname, + do_logout='rp_logout', + sjwt=request.GET['sjwt'] or request.POST['sjwt']) + return render(request, 'logout.html', d) + + +def post_logout(request): + return render(request, 'post_logout.html') diff --git a/example/django_op/requirements.txt b/example/django_op/requirements.txt index 64166d39..263f1c08 100644 --- a/example/django_op/requirements.txt +++ b/example/django_op/requirements.txt @@ -1,3 +1,10 @@ -Django>=2.2.4 +Django>3.1,<4.0 oidcop gunicorn +filelock + +git+https://github.com/IdentityPython/JWTConnect-Python-CryptoJWT +git+https://github.com/IdentityPython/JWTConnect-Python-OidcMsg +git+https://github.com/IdentityPython/JWTConnect-Python-OidcService +git+https://github.com/IdentityPython/oidcendpoint.git +git+https://github.com/IdentityPython/JWTConnect-Python-OidcRP diff --git a/example/django_op/snippets/db_interfaces.py b/example/django_op/snippets/db_interfaces.py new file mode 100644 index 00000000..196acc72 --- /dev/null +++ b/example/django_op/snippets/db_interfaces.py @@ -0,0 +1,287 @@ +import datetime +import json +import logging +import pytz + +from django.contrib.auth import get_user_model +from django.utils import timezone +from oidcop.session.database import Database +from oidcop.session.info import UserSessionInfo +from . models import (OidcRelyingParty, + OidcRPResponseType, + OidcRPGrantType, + OidcRPContact, + OidcRPRedirectUri, + OidcSession, + OidcSessionSso, + TIMESTAMP_FIELDS, + is_state, + is_sid, + is_sub, + is_code) + + +logger = logging.getLogger(__name__) + + +class OidcClientDb(object): + """ + Adaptation of a Django model as if it were a dict + """ + model = OidcRelyingParty + + def __init__(self, *args, **kwargs): + pass + + def __contains__(self, key): + if self.model.objects.filter(client_id=key).first(): + return 1 + + def __iter__(self): + values = self.model.objects.all().values_list('client_id') + self.clients = [cid[0] for cid in values] + for value in (self.clients): + yield value + + def get(self, key, excp=None, as_obj=False): + client = self.model.objects.filter(client_id=key, + is_active=True).first() + if not client: + return excp + + # set last_seen + client.last_seen = timezone.localtime() + client.save() + if as_obj: + return client + return client.copy() + + def __getitem__(self, key): + value = self.get(key) + if not value: + raise KeyError + return value + + def keys(self): + return self.model.objects.values_list('client_id', flat=True) + + def __setitem__(self, key, value): + return self.set(key, value) + + def set(self, key, value): + dv = value.copy() + + for k, v in dv.items(): + if isinstance(v, int) or isinstance(v, float): + if k in TIMESTAMP_FIELDS: + dt = datetime.datetime.fromtimestamp(v) + dv[k] = pytz.utc.localize(dt) + + client = None + # if the client already exists + if dv.get('id'): + client = self.model.objects.\ + filter(pk=dv['id']).first() + + if dv.get('client_id'): + client = self.model.objects.\ + filter(client_id=dv['client_id']).first() + + if not client: + client_id = dv.pop('client_id') + client = self.model.objects.create(client_id=client_id) + + for k, v in dv.items(): + setattr(client, k, v) + + client.save() + + def __str__(self): + return self.__dict__ + + +class OidcSessionDb(Database): + """ + Adaptation of a Django model as if it were a dict + + This class acts like a NoSQL storage but stores informations + into a pure Django DB model + """ + + def __init__(self, conf_db=None, session_db=None, sso_db=None, cdb=None): + self.conf_db = conf_db + self.db = session_db or OidcSession + self.sso_db = sso_db or OidcSessionSso + self.cdb = cdb or OidcClientDb() + + def get_valid_sessions(self): + return self.db.objects.filter().exclude(valid_until__lte=timezone.localtime()) + + def get_by_sid(self, value): + session = self.db.get_by_sid(value) + if session: + return session + + def get_by_state(self, value): + session = self.get_valid_sessions().filter(state=value) + if session: + return session.last() + + def create_by_state(self, state): + return self.db.objects.create(state=state) + + def __iter__(self): + self.elems = self.keys() + for value in (self.elems): + yield value + + def __getitem__(self, *args, **kwargs): + return self.get(*args, **kwargs) + + def get(self, key, excp=None): + if is_sid(key): + elem = self.db.get_by_sid(key) + elif is_code(key): + elem = self.get_valid_sessions().filter(code=key).last() + else: + # state is unpredictable, it's client side. + # elem = self.get_valid_sessions().filter(state=key).last() + elem = self.get_valid_sessions().filter(uid=key).last() + + if not elem: + return + elif elem.sid and elem.sid == key: + return json.loads(elem.session_info) + # elif elem.state == key: + elif elem.uid == key: + return elem.sso.sid + + def set_session_info(self, info_dict): + # info_dict = {'user_id': 'wert', 'subordinate': [], 'revoked': False, 'type': 'UserSessionInfo'} + + session = self.get_valid_sessions().get( + state=info_dict['authn_req']['state']) + session.session_info = json.dumps(info_dict) + session.code = info_dict.get('code') + authn_event = info_dict.get('authn_event') + valid_until = authn_event.get('valid_until') + if valid_until: + dt = datetime.datetime.fromtimestamp(valid_until) + session.valid_until = pytz.utc.localize(dt) + + client_id = info_dict.get('client_id') + session.client = self.cdb.get(key=client_id, as_obj=True) + session.save() + + def set(self, key, value): + if is_sid(key): + # info_dict = {'code': 'Z0FBQUFBQmZESFowazFBWWJteTNMOTZQa25KZmV0N1U1VzB4VEZCVEN3SThQVnVFRWlSQ2FrODhpb3Yyd3JMenJQT01QWGpuMnJZQmQ4YVh3bF9sbUxqMU43VG1RQ01BbW9JdV8tbTNNSzREMUk2U2N4YXVwZ3ZWQ1ZvbXdFanRsbWJIaWQyVWZON0N5LU9mUlhZUGgwdFRDQkpRZ3dSR0lVQjBBT0s4OHc3REJOdUlPUGVOUU9ZRlZvU3FBdVU2LThUUWNhRDVocl9QWEswMmo3Y2VtLUNvWklsX0ViN1NfWFRJWksxSXhxNVVNQW9ySngtc2RCST0=', 'oauth_state': 'authz', 'client_id': 'Mz2LUfvqCbRQ', 'authn_req': {'redirect_uri': 'https://127.0.0.1:8099/authz_cb/django_oidc_op', 'scope': 'openid profile email address phone', 'response_type': 'code', 'nonce': 'mpuLL5IxgDvFDGAqlE05LwHO', 'state': 'eOzFkkGFHLT16zO6SqpOmc2rv6DZmf3g', 'code_challenge': 'lAs7I04g1Qh8mhTG8wxV0BfmrhzrSrl1ASp04C3Zmog', 'code_challenge_method': 'S256', 'client_id': 'Mz2LUfvqCbRQ'}, 'authn_event': {'uid': 'wert', 'salt': 'fc7AGQ==', 'authn_info': 'oidcendpoint.user_authn.authn_context.INTERNETPROTOCOLPASSWORD', 'authn_time': 1594652276, 'valid_until': 1594655876}} + info_dict = value + self.set_session_info(info_dict) + logger.debug('Session DB - set - {}'.format(session.copy())) + + def __setitem__(self, sid, instance): + if is_sid(sid): + try: + instance.to_json() + except ValueError: + json.dumps(instance) + except AttributeError: + # it's a dict + pass + + self.set_session_info(instance) + + elif isinstance(instance, UserSessionInfo): + # {'_dict': {'user_id': 'wert', 'subordinate': [], 'revoked': False, 'type': 'UserSessionInfo'}, 'lax': False, 'jwt': None, 'jws_header': None, 'jwe_header': None, 'verify_ssl': True} + self.set_session_info(instance.__dict__['_dict']) + + else: + logger.error('{} tries __setitem__ {} in {}'.format(sid, + instance, + self.__class__.__name__)) + + def __delitem__(self, key): + if is_sid(key): + ses = self.db.get_by_sid(key) + if ses: + ses.sso.delete() + ses.delete() + + +class OidcSsoDb(object): + """ + Adaptation of a Django model as if it were a dict + + This class acts like a NoSQL storage but stores informations + into a pure Django DB model + """ + + def __init__(self, db_conf={}, db=None, session_handler=None): + self._db = db or OidcSessionSso + self._db_conf = db_conf + self.session_handler = session_handler or db_conf.get( + 'session_hanlder') or OidcSessionDb() + + def _get_or_create(self, sid): + sso = self._db.objects.filter(sid=sid).first() + if not sso: + sso = self._db.objects.create(sid=sid) + return sso + + def __setitem__(self, k, value): + if isinstance(value, dict): + if value.get('state'): + session = self.session_handler.create_by_state(k) + session.sid = value['state'][0] \ + if isinstance(value['state'], list) else value + sso = self._db.objects.create() + session.sso = sso + session.save() + else: + # it would be quite useless for this implementation ... + # k = '81c58c4037ab1939423ab4fb8b472fdd5fc3a3939e4debc81f52ed37' + # value = + pass + + + def set(self, k, v): + logging.info('{}:{} - already there'.format(k, v)) + + + def get(self, k, default): + session = self.session_handler.get_by_state(k) + if session: + return session + + if is_sub(k): + # sub + return self._db.objects.filter(sub=k).last() or {} + elif is_sid(k): + # sid + session = self.session_handler.get_by_sid(k) + return session.sso if session else {} + else: + logger.debug(("{} can't find any attribute " + "with this name as attribute: {}").format(self, k)) + user = get_user_model().objects.filter(username=k).first() + if user: + logger.debug( + 'Tryng to match to a username: Found {}'.format(user)) + return self._db.objects.filter(user=user).last() + else: + return {} + + def __delitem__(self, name): + self.delete(name) + + def delete(self, name): + session = self.session_handler.get_by_state(name) + + if is_sid(name): + session = self.session_handler.get_by_sid(name) + elif is_sub(name): + sso = self._db.objects.filter(sub=name) + sso.delete() + if session: + session.delete() diff --git a/example/django_op/snippets/msg_test.py b/example/django_op/snippets/msg_test.py new file mode 100644 index 00000000..922e571d --- /dev/null +++ b/example/django_op/snippets/msg_test.py @@ -0,0 +1,39 @@ +import json +import requests + +from cryptojwt.key_jar import KeyJar +from oidcmsg.message import Message + + +issuer = "https://127.0.0.1:8000" +issuer_metadata_url = '{}/.well-known/openid-configuration'.format(issuer) +issuer_metadata = requests.get(issuer_metadata_url, verify=False).text +issuer_jwks_uri = json.loads(issuer_metadata)['jwks_uri'] + +jwks_raw = requests.get(issuer_jwks_uri, verify=False).text +jwks = json.loads(jwks_raw) + + +# built keyjar +key_jar = KeyJar() +key_jar.import_jwks(jwks, issuer=issuer) + +# it depends by JWT, is the signing key identifier could be extracted from it then it should be verified, otherwise not +# see difference between at and unverifiable_at +verify_sign = 1 + +at = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImJXdG9SekV4VXkxak9GVXlSV2hwZUdkbFREWlBaME55TW1ka05ERlFaakJSUzJreVQwaExVazVJUVEifQ.eyJzdWIiOiAiODAzMjcwNDJiOTZiOWYxYzAwZDlkMDRkYjgxNmU4NGFmNGUzNjE2ZGIxZDA2OTRiMTNhYjg2ZjQ5ZmQyNTFiZiIsICJhdXRoX3RpbWUiOiAxNTc2MDU4MTc4LCAiYWNyIjogIm9pZGNlbmRwb2ludC51c2VyX2F1dGhuLmF1dGhuX2NvbnRleHQuSU5URVJORVRQUk9UT0NPTFBBU1NXT1JEIiwgImVtYWlsIjogImdpdXNlcHBlLmRlbWFyY29AdW5pY2FsLml0IiwgIm5vbmNlIjogImFFbERSTUNBUWhHZWVBVUs1b0RyRG1PdSIsICJpc3MiOiAiaHR0cHM6Ly8xMjcuMC4wLjE6ODAwMCIsICJpYXQiOiAxNTc2MDU4MTc4LCAiZXhwIjogMTU3NjA1ODQ3OCwgImF1ZCI6IFsiejl3VU90dG1GM3FjIl19.lAKU1F_77T11gNcDvRMHy3hEJANaCkjTtbyR5D7DmOFOTKTYBWIT_ZqEmGnBmGpDBhnGcwbupFavltAOq03PGyJYu-Bxk3ktqTCjfcLTQroE-o3KTx_xYjVDh60p3RAAK9iD5ligmH2I5vFtFl-5ckTl2tX9Zn_IAqJrpuCIvgwBHzje-61upXKyXzBthOKa2dWAlOMHXYKlFV_4mhnr3Q3RNP8hmgFkltD3d-INo8tlysRQebuxnQ7LizQtOIhWACioHZosfpQIu2_L89PiTdcUuFeAeRNa_kUe9Oztk7ffCcDk2Xqa5C-8gxpgHTX6STEa59jeG07q7_s4pj7Fig" +unverifiable_at = "eyJhbGciOiJFUzI1NiIsImtpZCI6IlQwZGZTM1ZVYUcxS1ZubG9VVTQwUXpJMlMyMHpjSHBRYlMxdGIzZ3hZVWhCYzNGaFZWTlpTbWhMTUEifQ.eyJzaWQiOiAiNzUyYTc2NWZlMjg4MGE4NmU2OTlkNTcxZWM4YTE2MWE2NTkzZjVhMmJlNzdkMDkzZDRmNzU2MmUiLCAidHR5cGUiOiAiVCIsICJzdWIiOiAiODAzMjcwNDJiOTZiOWYxYzAwZDlkMDRkYjgxNmU4NGFmNGUzNjE2ZGIxZDA2OTRiMTNhYjg2ZjQ5ZmQyNTFiZiIsICJlbWFpbCI6ICJnaXVzZXBwZS5kZW1hcmNvQHVuaWNhbC5pdCIsICJnZW5kZXIiOiAibWFsZSIsICJpc3MiOiAiIiwgImlhdCI6IDE1NzYwNTI3ODMsICJleHAiOiAxNTc2MDU2MzgzLCAiYXVkIjogWyJ6OXdVT3R0bUYzcWMiLCAiaHR0cHM6Ly8xMjcuMC4wLjE6ODAwMCJdfQ.xjkeVPF8fcHZVVZa3wYHxatyoVizML10iD579T_HB1tj7Cm_JNRdKB0nUoq7HddfeeNG911YNWHHl0lD9TQ2gA" + +m = Message().from_jwt(at, keyjar=key_jar, verify=verify_sign) + + +print('Access Token header: ', m.jws_header) +print('Access Token payload: ', m) + +t = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImJXdG9SekV4VXkxak9GVXlSV2hwZUdkbFREWlBaME55TW1ka05ERlFaakJSUzJreVQwaExVazVJUVEifQ.eyJzdWIiOiAiODAzMjcwNDJiOTZiOWYxYzAwZDlkMDRkYjgxNmU4NGFmNGUzNjE2ZGIxZDA2OTRiMTNhYjg2ZjQ5ZmQyNTFiZiIsICJhdXRoX3RpbWUiOiAxNTc2MDUyNzc3LCAiYWNyIjogIm9pZGNlbmRwb2ludC51c2VyX2F1dGhuLmF1dGhuX2NvbnRleHQuSU5URVJORVRQUk9UT0NPTFBBU1NXT1JEIiwgImVtYWlsIjogImdpdXNlcHBlLmRlbWFyY29AdW5pY2FsLml0IiwgIm5vbmNlIjogIlRKdnFjYW85RjRubkp5bTBPZnlNeXlBbSIsICJpc3MiOiAiaHR0cHM6Ly8xMjcuMC4wLjE6ODAwMCIsICJpYXQiOiAxNTc2MDUyNzgzLCAiZXhwIjogMTU3NjA1MzA4MywgImF1ZCI6IFsiejl3VU90dG1GM3FjIl19.kQq0WBXRTUcKY7RI8A0Yb1CyoOds5-EnDrc7mhc4pzp5xig9lOUWaBts6Q5vwoI29-iadPUf62SIcrBuGPAyvAFHC-pqSy-vcXbYFpWN18n9SaL7O2fkQD-PlPl088X6fJ-Muqt_RvkRjY_cwY-VuJNh3_uXXZdsLnUimsLI3E4q6aCMkoaqDajbq35L40_xcjRhmQHh9Rn_4pIfnRGKZdsQICYJ6ivLSO5dxxydDtyA1nip1ER8hV3ISj8qfJq1_tFe5JrvcEWXbs9MMmsnlXTTB6X-71ysmzqVcjY2o-CviZGQTwEXwJYY54_u24NUwwM72pWVL97N1ug7nIlusA" +m = Message().from_jwt(t, keyjar=key_jar, verify=verify_sign) + + +print('ID Token header: ', m.jws_header) +print('ID Token payload: ', m) diff --git a/example/django_op/snippets/rp_handler.py b/example/django_op/snippets/rp_handler.py new file mode 100644 index 00000000..27ae4e57 --- /dev/null +++ b/example/django_op/snippets/rp_handler.py @@ -0,0 +1,178 @@ +import os +import re +import requests +import json +import urllib +import urllib3 + +from cryptojwt import KeyJar +from cryptojwt.key_jar import init_key_jar + +from oidcmsg.message import Message +from oidcrp import rp_handler +from oidcrp.util import load_yaml_config + +RPHandler = rp_handler.RPHandler + +def decode_token(bearer_token, keyjar, verify_sign=True): + msg = Message().from_jwt(bearer_token, + keyjar=keyjar, + verify=verify_sign) + return msg.to_dict() + + +def init_oidc_rp_handler(app): + _rp_conf = app.config + + if _rp_conf.get('rp_keys'): + _kj = init_key_jar(**_rp_conf['rp_keys']) + _path = _rp_conf['rp_keys']['public_path'] + # removes ./ and / from the begin of the string + _path = re.sub('^(.)/', '', _path) + else: + _kj = KeyJar() + _path = '' + _kj.httpc_params = _rp_conf['httpc_params'] + hash_seed = app.config.get('hash_seed', "BabyHoldOn") + rph = RPHandler(_rp_conf['base_url'], _rp_conf['clients'], services=_rp_conf['services'], + hash_seed=hash_seed, keyjar=_kj, jwks_path=_path, + httpc_params=_rp_conf['httpc_params']) #, verify_ssl=False) + + return rph + + +def get_rph(config_fname): + config = load_yaml_config(config_file) + app = type('RPApplication', (object,), {"config": config}) + rph = init_oidc_rp_handler(app) + return rph + + +def fancy_print(msg, dict_obj): + print('\n\n{}\n'.format(msg), + json.dumps(dict_obj, indent=2) if dict_obj else '') + + +def run_rp_session(rph, issuer_id, username, password): + # register client (provider info and client registration) + info = rph.begin(issuer_id) + + issuer_fqdn = rph.hash2issuer[issuer_id] + issuer_keyjar = rph.issuer2rp[issuer_fqdn] + + fancy_print("Client registration done...\n" + "Connecting to Authorization url:", + info) + + # info contains the url to the authorization endpoint + # {'url': 'https://127.0.0.1:8000/authorization?redirect_uri=https%3A%2F%2F127.0.0.1%3A8099%2Fauthz_cb%2Fdjango_oidc_op&scope=openid+profile+email+address+phone&response_type=code&nonce=HhDGhvuIoQ9MaVLSqXi3D6r4&state=AdgqZVTxwdHaE9kjRUCLTnI78GpoQq90&code_challenge=v3UDlTl4qOrbA1owsEBdKMHwSubmvheGrjUiBeCQqhk&code_challenge_method=S256&client_id=shoInN4jcqIe', 'state': 'AdgqZVTxwdHaE9kjRUCLTnI78GpoQq90'} + + print('\nStarting connection to Authorization URL') + res = requests.get(info['url'], verify=rph.verify_ssl) + + # this contains the html form + # res.text + + #'\n\n\n\n \n Please login\n\n\n\n

Testing log in

\n\n
\n \n\n

\n \n \n

\n\n

\n \n \n

\n\n

\n \n

\n

\n \n

\n

\n \n

\n\n \n
\n\n\n' + try: + auth_code = re.search('value="(?P[a-zA-Z\-\.\_0-9]*)"', res.text).groupdict()['token'] + auth_url = re.search('action="(?P[a-zA-Z0-9\/\-\_\.\:]*)"', res.text).groupdict()['url'] + except Exception as e: + print(res.text) + raise Exception(res.text) + + fancy_print("The Authorization endpoint returns a " + "HTML authentication form with a token", + {'token': auth_code, + 'url': auth_url}) + + fancy_print('Authorization code content: ', + decode_token(auth_code, + keyjar=issuer_keyjar.get_service_context().keyjar +, + verify_sign=False)) + + auth_dict = {'username': username, + 'password': password, + 'token': auth_code} + + auth_url = '/'.join((issuer_fqdn, auth_url)) + print('Credential form submit ...') + req = requests.post(auth_url, + data=auth_dict, verify=rph.verify_ssl, + allow_redirects=False) + print('Credential form submitted!') + + # req is a 302, a redirect to the https://127.0.0.1:8099/authz_cb/django_oidc_op + if req.status_code != 302: + raise Exception(req.content) + rp_final_url = req.headers['Location'] + + fancy_print("The Authorization returns a " + "HttpRedirect (302) to {}".format(rp_final_url), None) + + # what we found later in request_args ... + # code = urllib.parse.parse_qs( urllib.parse.urlparse(rp_final_url).query) + # fancy_print("That contains: ", code) + + ws, args = urllib.parse.splitquery(rp_final_url) + request_args = urllib.parse.parse_qs(args) + + # from list to str + request_args = {k:v[0] for k,v in request_args.items()} + + fancy_print("Going to finalize the session ...", request_args) + + # oidcrp.RPHandler.finalize() will parse the authorization response and depending on the configuration run the needed RP service + result = rph.finalize(request_args['iss'], request_args) + + fancy_print("Got Access Token.", None) + + # Tha't the access token, the bearer used to access to userinfo endpoint + # result['token'] + fancy_print("Bearer Access Token", result['token']) + + # get the keyjar related to the issuer + decoded_access_token = decode_token(result['token'], + keyjar=issuer_keyjar.get_service_context().keyjar) + fancy_print("Access Token", decoded_access_token) + + # id_token + # import pdb; pdb.set_trace() + # result['id_token'].to_dict() + fancy_print("ID Token", result['id_token'].to_dict()) + + # userinfo + result['userinfo'].to_dict() + fancy_print("Userinfo endpoint result:", result['userinfo'].to_dict()) + + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument('-conf', required=True, + help="settings file where RP configuration is") + parser.add_argument('-u', required=True, + help="username") + parser.add_argument('-p', required=True, + help="password") + parser.add_argument('-iss', required=True, + help="The issuer Id you want to " + "requests authorization. Example: " + "django_oidc_op") + args = parser.parse_args() + + # 'django-oidc-op/example/data/oidc_rp/conf.django.yaml' + config_file = args.conf + rph = get_rph(config_file) + + rph.verify_ssl = rph.httpc_params['verify'] + if not rph.verify_ssl: + urllib3.disable_warnings() + + # select the OP you want to, example: "django_oidc_op" + issuer_id = args.iss + run_rp_session(rph, issuer_id, args.u, args.p) + +# python3 snippets/rp_handler.py -conf example/data/oidc_rp/conf.django.yaml -u user -p pass -iss django_oidc_op 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