Skip to content
Snippets Groups Projects
Commit edd4f4ca authored by Julian's avatar Julian
Browse files

OpenID Connect Core 1.0 and Discovery 1.0 support

Limited to OpenID provider conformance profiles "Basic" and "Config":

- Support for features mandatory to implement for all OpenID Providers,
  not the feature set for Dynamic OpenID Providers
- Only Authorization Code Flow, no support for Implicit/Hybrid Flow
- Only code response type, no support for token/id_token
- Server metadata is served at /.well-known/openid-configuration

Additional/optional features:

- Support for "claims" parameter
- Support for standard scopes "profile" and "email"
- Support for non-standard scope/claim "groups" (in violation of RFC 9068)

Compatability with existing (working) uffd client setups: Authorization
requests without the "openid" scope behave the same as before  Prior to this
change authorization requests with the "openid" scope were rejected by uffd.

This change adds direct dependencies to pyjwt and cryptography. Prior to this
change both were already transitive dependencies of oauthlib.
parent 94ba8b9c
No related branches found
No related tags found
No related merge requests found
...@@ -15,7 +15,8 @@ Please note that we refer to Debian packages here and **not** pip packages. ...@@ -15,7 +15,8 @@ Please note that we refer to Debian packages here and **not** pip packages.
- python3-qrcode - python3-qrcode
- python3-fido2 (version 0.5.0 or 0.9.1, optional) - python3-fido2 (version 0.5.0 or 0.9.1, optional)
- python3-prometheus-client (optional, needed for metrics) - python3-prometheus-client (optional, needed for metrics)
- python3-oauthlib - python3-jwt
- python3-cryptography
- python3-flask-babel - python3-flask-babel
- python3-argon2 - python3-argon2
- python3-itsdangerous (also a dependency of python3-flask) - python3-itsdangerous (also a dependency of python3-flask)
...@@ -97,6 +98,8 @@ The services need to be setup to use the following URLs with the Authorization C ...@@ -97,6 +98,8 @@ The services need to be setup to use the following URLs with the Authorization C
* `/oauth2/token`: token request endpoint * `/oauth2/token`: token request endpoint
* `/oauth2/userinfo`: endpoint that provides information about the current user * `/oauth2/userinfo`: endpoint that provides information about the current user
If the service supports server metadata discovery ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)), configuring the base url of your uffd installation or `/.well-known/openid-configuration` as the discovery endpoint should be sufficient.
The only OAuth2 scope supported is `profile`. The userinfo endpoint returns json data with the following structure: The only OAuth2 scope supported is `profile`. The userinfo endpoint returns json data with the following structure:
``` ```
...@@ -114,6 +117,48 @@ The only OAuth2 scope supported is `profile`. The userinfo endpoint returns json ...@@ -114,6 +117,48 @@ The only OAuth2 scope supported is `profile`. The userinfo endpoint returns json
`id` is the numeric (Unix) user id, `name` the display name and `nickname` the loginname of the user. `id` is the numeric (Unix) user id, `name` the display name and `nickname` the loginname of the user.
## OpenID Connect Single-Sign-On Provider
In addition to plain OAuth2, uffd also has basic OpenID Connect support.
Endpoint URLs are the same as for plain OAuth2.
OpenID Connect support is enabled by requesting the `openid` scope.
ID token signing keys are served at `/oauth2/keys`.
See [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) specification for more details.
Supported flows and response types:
* Only Authorization Code Flow with `code` response type
Supported scopes:
* `openid`: Enables OpenID Connect support and returns mandatory `sub` claim
* `profile`: Returns `name` and `preferred_username` claims
* `email`: Returns `email` and `email_verified` claims
* `groups`: Returns non-standard `groups` claim
Supported claims:
* `sub` (string): Decimal encoded numeric (Unix) user id
* `name` (string): Display name
* `preferred_username`(string): Loginname
* `email` (string): Service-specific or primary email address
* `email_verified` (boolean): Verification status of `email` value (always `true`)
* `groups` (array of strings): Names of groups the user is a member of (non-standard)
uffd supports the optional `claims` authorization request parameter for requesting claims individually.
Note that there is a IANA-registered `groups` claim with a syntax borrowed from [SCIM](https://www.rfc-editor.org/rfc/rfc7643.html).
The syntax used by uffd is different and incompatible, although arguably more common for a claim named "groups" in this context.
uffd aims for complience with OpenID provider conformance profiles Basic and Config.
It is, however, not a certified OpenID provider and it has the following limitations:
* Only the `none` value for the `prompt` authorization request parameter is recognized. Other values (`login`, `consent` and `select_account`) are ignored.
* The `max_age` authorization request parameter is not supported and ignored by uffd.
* The `auth_time` claim is not supported and neither returned if the `max_age` authorization request parameter is present nor if it is requested via the `claims` parameter.
* Requesting the `sub` claim with a specific value for the ID Token (or passing the `id_token_hint` authorization request parameter) is only supported if the `prompt` authorization request parameter is set to `none`. The authorization request is rejected otherwise.
## Metrics ## Metrics
Uffd can export metrics in a prometheus compatible way. It needs python3-prometheus-client for this feature to work. Uffd can export metrics in a prometheus compatible way. It needs python3-prometheus-client for this feature to work.
......
...@@ -23,7 +23,8 @@ Depends: ...@@ -23,7 +23,8 @@ Depends:
python3-flask-migrate, python3-flask-migrate,
python3-qrcode, python3-qrcode,
python3-fido2, python3-fido2,
python3-oauthlib, python3-jwt,
python3-cryptography,
python3-flask-babel, python3-flask-babel,
python3-argon2, python3-argon2,
python3-itsdangerous, python3-itsdangerous,
......
...@@ -36,7 +36,8 @@ setup( ...@@ -36,7 +36,8 @@ setup(
'Flask-SQLAlchemy==2.1', 'Flask-SQLAlchemy==2.1',
'qrcode==6.1', 'qrcode==6.1',
'fido2==0.5.0', 'fido2==0.5.0',
'oauthlib==2.1.0', 'cryptography==2.6.1',
'pyjwt==1.7.0',
'Flask-Migrate==2.1.1', 'Flask-Migrate==2.1.1',
'Flask-Babel==0.11.2', 'Flask-Babel==0.11.2',
'alembic==1.0.0', 'alembic==1.0.0',
...@@ -52,11 +53,9 @@ setup( ...@@ -52,11 +53,9 @@ setup(
'cffi # v1.12.2 no longer works with python3.9. Newer versions seem to work fine.', 'cffi # v1.12.2 no longer works with python3.9. Newer versions seem to work fine.',
'chardet==3.0.4', 'chardet==3.0.4',
'click==7.0', 'click==7.0',
'cryptography==2.6.1',
'idna==2.6', 'idna==2.6',
'Jinja2==2.10', 'Jinja2==2.10',
'MarkupSafe==1.1.0', 'MarkupSafe==1.1.0',
'oauthlib==2.1.0',
'pyasn1==0.4.2', 'pyasn1==0.4.2',
'pycparser==2.19', 'pycparser==2.19',
'requests==2.21.0', 'requests==2.21.0',
......
...@@ -60,8 +60,8 @@ class TestFuzzy(MigrationTestCase): ...@@ -60,8 +60,8 @@ class TestFuzzy(MigrationTestCase):
service = Service(name='testservice', access_group=group) service = Service(name='testservice', access_group=group)
oauth2_client = OAuth2Client(service=service, client_id='testclient', client_secret='testsecret', redirect_uris=['http://localhost:1234/callback'], logout_uris=[OAuth2LogoutURI(method='GET', uri='http://localhost:1234/callback')]) oauth2_client = OAuth2Client(service=service, client_id='testclient', client_secret='testsecret', redirect_uris=['http://localhost:1234/callback'], logout_uris=[OAuth2LogoutURI(method='GET', uri='http://localhost:1234/callback')])
db.session.add_all([service, oauth2_client]) db.session.add_all([service, oauth2_client])
db.session.add(OAuth2Grant(user=user, client=oauth2_client, code='testcode', redirect_uri='http://example.com/callback', expires=datetime.datetime.now())) db.session.add(OAuth2Grant(user=user, client=oauth2_client, _code='testcode', redirect_uri='http://example.com/callback', expires=datetime.datetime.now()))
db.session.add(OAuth2Token(user=user, client=oauth2_client, token_type='Bearer', access_token='testcode', refresh_token='testcode', expires=datetime.datetime.now())) db.session.add(OAuth2Token(user=user, client=oauth2_client, token_type='Bearer', _access_token='testcode', _refresh_token='testcode', expires=datetime.datetime.now()))
db.session.add(OAuth2DeviceLoginInitiation(client=oauth2_client, confirmations=[DeviceLoginConfirmation(user=user)])) db.session.add(OAuth2DeviceLoginInitiation(client=oauth2_client, confirmations=[DeviceLoginConfirmation(user=user)]))
db.session.add(PasswordToken(user=user)) db.session.add(PasswordToken(user=user))
db.session.commit() db.session.commit()
......
import unittest
import datetime
import jwt
from uffd.database import db
from uffd.models import OAuth2Key
from tests.utils import UffdTestCase
TEST_JWK = dict(
id='HvOn74G7njK1GoFNe8Dta087casdWMsm06pNhOXRgJU',
created=datetime.datetime(2023, 11, 9, 0, 21, 10),
active=True,
algorithm='RS256',
private_key_jwk='''{
"kty": "RSA",
"key_ops": ["sign"],
"n": "vrznqUy8Xamph6s0Z02fFMIyjwLAMio35i9DXYjXP1ZQwSZ3SsIh3m2ablMnlu8PVlnYUzoj8rXyAWND0FSfWoQQxv1rq15pllKueddLoJsv321N_NRB8beGsLrsndw8QO0q3RWqV9O3kqhlTMjgj6bquX42wLaXrPLJyfbT3zObBsToG4UxpOyly84aklJXU5wIs0cbmjbfd8Xld38BG8Oh7Ozy5b93vPpJW6rudZRxU6QYC0r9bFFLIHJWrR4bzQMLGoJ63xjPOCl4WNpOYc9B7PNgnWTLXlFd51Hw9CaT2MRWsKNCSU77f6nZkfjWa1IsQdF0I48m46qgq7bEOOl9DbThbCnpblWrctdyg6du-OvCyVmkAo1KGtANl0027pgqUI_9HBMi33y3UPQm1ALHXIyIDBZtExH3lD6MMK3XGJfUxZuIOBndK-PXm5Fed52bgLOcf-24X6aHFn-8oyDVIj9OHkKWjy7jtKdmqZc4pBdVuCaMCYzj8iERWA3H",
"e": "AQAB",
"d": "G7yoH5mLcZTA6ia-byCoN-zpofGvdga9AZnxPO0vsq6K_cY_O2gxuVZ3n6reAKKbuLNGCbb_D_Dffs4q8rprlfkgi3TCLzXX5Zv5HWTD7a4Y7xpxEzQ2sWo-iagVIqZVPh0pyjliqnTyUWnFmWiY0gBe9UHianHjFVZqe8E2HFOKgW3UUbQz0keg8JtJ3T9gzZrM38KWbqhOJO0VVSRAoANPTSnumfRsUCyWywrMtIfgAbQaKazqX3xkOsAF1L-iNfd6slzPvRyIQVflVDMdfKnsu-lHiKJ0DK_lg9f55T5FymgcXsq43EKBQ2H4v2dafIm-vtWx_TRZWj_msD32BEPBA-zTqh_oP1r6a3DZh4DBtWY3vzSiuhAC0erlRs-hRTX_e9ET5fUbJnmNxjnxQD9zZmwq4ujMK6KFnHct8t77Qxj3a-wDR_XyDJ4_EKYqHlcVHfxGNBSvIdjuZJkPJnVpVtfCtpyamQIR4u5oNV7fIwYe_tFnw0Y90rGoJMzB",
"p": "-A-FnH21HJ7GPWUm9k3mxsxSchy89QEUCZZiH6EcB4ZP8wJsxrQsUSIHCR74YmZEI3Ulsum1Ql4x50k7Q2sNh9SnwKvmctjksehGy4yCrdunAqjqyz3wFwGaKWnhn3frkiqH5ATjkOoc8qHz8saa7reeVClj47ZWyy-Nl559ycLMs0rI1N_THzO07C3jSbJhyPj0yeygAflsRqqnNvEQ6ps1VLiqf9G5jfSvUUn5DyKIpep9iGo29caGSIPIy_2h",
"q": "xNe1-QWskxOcY_GiHpFWdvzqr1o9fxg5whgpNcGi3caokw2iNHRYut4cbVvFFBlv_9B5QCl9WVfR2ADG0AtvkvUxEZqCdxEvcqjIANeRLKHDjW5kMuPS0_fcskFP-r7mCM9SBfPplfMVCF5nuNWf5LzNopWfsTChIDD1rSpPjItNYuwLXszm_3R81HHHeQLcyvoMxLCmeLy5TXX2hXOMHh2IMZCXAHopJmLJUVnQ48kr5jd2l0kLbmx3aBqdccJn",
"dp": "MLS7g1KbcRcrzXpDADGjkn0j4wwJfgHMMWW5toQnwMJ6iDh9qzZNTVDlGMFf-9IgpuWllU-WK4XbPpJ-dGpcqcLzfT1DbmFv5g65d9YLAqASVs9b6rQqpBnIb0E-79TYCEcZj4f2NsoBDRMHly-v1BdxmwzVdCylNhgMMS0Jfcgl8T5J2KJqDcJVT9piumGwGYnoZo1zjW-v9uAjHQKQU8BN5Git8ZL4YAsfMVLY-EPLmOhF5bcVO4TTcQGPN56B",
"dq": "HiiSl-G3rB0QE_v8g8Ruw_JCHrWrwGI8zzEWd0cApgv-3fDzzieZRKAtKNArpMW09DPDsAHrU5nx669KxqtJ3_EzIGhU3ttCMsYLRp3Af18VcADe1zEypwlNxf3dvCQtaGIjRgg13KSOr2aPa7FHOyt2MhfMjMBPn3gA3BQkdfsN0z8pCtBIABGf4ojAMBkxLOQcurH5_3uixGxzZcTrTd3mdPmbORZ-YYQ3JgCl0ZCL6kzLHaiyWKvDq66QOtK3",
"qi": "ySqD9cUxbq3wkCsPQId_YfQLIqb5RK_JJIMjtBOdTdo4aT5tmodYCSmjBmhrYXjDWtyJdelvPfdSfgncHJhf8VgkZ8TPvUeaQwsQFBwB5llwpdb72eEEJrmG1SVwNMoFCLXdNT3ACad16cUDMnWmklH0X07OzdxGOBnGhgLZUs4RbPjLH7OpYTyQqVy2L8vofqJR42cfePZw8WQM4k0PPbhralhybExIkSCmaQyYbACZ5k0OVQErEqnj4elglA0h"
}''',
public_key_jwk='''{
"kty": "RSA",
"key_ops": ["verify"],
"n": "vrznqUy8Xamph6s0Z02fFMIyjwLAMio35i9DXYjXP1ZQwSZ3SsIh3m2ablMnlu8PVlnYUzoj8rXyAWND0FSfWoQQxv1rq15pllKueddLoJsv321N_NRB8beGsLrsndw8QO0q3RWqV9O3kqhlTMjgj6bquX42wLaXrPLJyfbT3zObBsToG4UxpOyly84aklJXU5wIs0cbmjbfd8Xld38BG8Oh7Ozy5b93vPpJW6rudZRxU6QYC0r9bFFLIHJWrR4bzQMLGoJ63xjPOCl4WNpOYc9B7PNgnWTLXlFd51Hw9CaT2MRWsKNCSU77f6nZkfjWa1IsQdF0I48m46qgq7bEOOl9DbThbCnpblWrctdyg6du-OvCyVmkAo1KGtANl0027pgqUI_9HBMi33y3UPQm1ALHXIyIDBZtExH3lD6MMK3XGJfUxZuIOBndK-PXm5Fed52bgLOcf-24X6aHFn-8oyDVIj9OHkKWjy7jtKdmqZc4pBdVuCaMCYzj8iERWA3H",
"e": "AQAB"
}''',
)
class TestOAuth2Key(UffdTestCase):
def setUp(self):
super().setUp()
db.session.add(OAuth2Key(**TEST_JWK))
db.session.add(OAuth2Key(
id='1e9gdk7',
created=datetime.datetime(2014, 11, 8, 0, 0, 0),
active=True,
algorithm='RS256',
private_key_jwk='invalid',
public_key_jwk='''{
"kty":"RSA",
"n":"w7Zdfmece8iaB0kiTY8pCtiBtzbptJmP28nSWwtdjRu0f2GFpajvWE4VhfJAjEsOcwYzay7XGN0b-X84BfC8hmCTOj2b2eHT7NsZegFPKRUQzJ9wW8ipn_aDJWMGDuB1XyqT1E7DYqjUCEOD1b4FLpy_xPn6oV_TYOfQ9fZdbE5HGxJUzekuGcOKqOQ8M7wfYHhHHLxGpQVgL0apWuP2gDDOdTtpuld4D2LK1MZK99s9gaSjRHE8JDb1Z4IGhEcEyzkxswVdPndUWzfvWBBWXWxtSUvQGBRkuy1BHOa4sP6FKjWEeeF7gm7UMs2Nm2QUgNZw6xvEDGaLk4KASdIxRQ",
"e":"AQAB"
}'''
))
db.session.commit()
self.key = OAuth2Key.query.get('HvOn74G7njK1GoFNe8Dta087casdWMsm06pNhOXRgJU')
self.key_oidc_spec = OAuth2Key.query.get('1e9gdk7')
def test_private_key(self):
self.key.private_key
def test_public_key(self):
self.key.private_key
def test_public_key_jwks_dict(self):
self.assertEqual(self.key.public_key_jwks_dict, {
"kid": "HvOn74G7njK1GoFNe8Dta087casdWMsm06pNhOXRgJU",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "vrznqUy8Xamph6s0Z02fFMIyjwLAMio35i9DXYjXP1ZQwSZ3SsIh3m2ablMnlu8PVlnYUzoj8rXyAWND0FSfWoQQxv1rq15pllKueddLoJsv321N_NRB8beGsLrsndw8QO0q3RWqV9O3kqhlTMjgj6bquX42wLaXrPLJyfbT3zObBsToG4UxpOyly84aklJXU5wIs0cbmjbfd8Xld38BG8Oh7Ozy5b93vPpJW6rudZRxU6QYC0r9bFFLIHJWrR4bzQMLGoJ63xjPOCl4WNpOYc9B7PNgnWTLXlFd51Hw9CaT2MRWsKNCSU77f6nZkfjWa1IsQdF0I48m46qgq7bEOOl9DbThbCnpblWrctdyg6du-OvCyVmkAo1KGtANl0027pgqUI_9HBMi33y3UPQm1ALHXIyIDBZtExH3lD6MMK3XGJfUxZuIOBndK-PXm5Fed52bgLOcf-24X6aHFn-8oyDVIj9OHkKWjy7jtKdmqZc4pBdVuCaMCYzj8iERWA3H",
"e": "AQAB"
})
def test_encode_jwt(self):
jwtdata = self.key.encode_jwt({'aud': 'test', 'foo': 'bar'})
self.assertEqual(
jwt.get_unverified_header(jwtdata),
# typ is optional, x5u/x5c/jku/jwk are discoraged by OIDC Core 1.0 spec section 2
{'kid': self.key.id, 'alg': self.key.algorithm, 'typ': 'JWT'}
)
self.assertEqual(
OAuth2Key.decode_jwt(jwtdata, audience='test'),
{'aud': 'test', 'foo': 'bar'}
)
self.key.active = False
with self.assertRaises(jwt.exceptions.InvalidKeyError):
self.key.encode_jwt({'aud': 'test', 'foo': 'bar'})
def test_oidc_hash(self):
# Example from OIDC Core 1.0 spec A.3
self.assertEqual(
self.key.oidc_hash(b'jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y'),
'77QmUPtjPfzWtF2AnpK9RQ'
)
# Example from OIDC Core 1.0 spec A.4
self.assertEqual(
self.key.oidc_hash(b'Qcb0Orv1zh30vL1MPRsbm-diHiMwcLyZvn1arpZv-Jxf_11jnpEX3Tgfvk'),
'LDktKdoQak3Pk0cnXxCltA'
)
# Example from OIDC Core 1.0 spec A.6
self.assertEqual(
self.key.oidc_hash(b'jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y'),
'77QmUPtjPfzWtF2AnpK9RQ'
)
self.assertEqual(
self.key.oidc_hash(b'Qcb0Orv1zh30vL1MPRsbm-diHiMwcLyZvn1arpZv-Jxf_11jnpEX3Tgfvk'),
'LDktKdoQak3Pk0cnXxCltA'
)
def test_decode_jwt(self):
# Example from OIDC Core 1.0 spec A.2
jwt_data = (
'eyJraWQiOiIxZTlnZGs3IiwiYWxnIjoiUlMyNTYifQ.ewogImlz'
'cyI6ICJodHRwOi8vc2VydmVyLmV4YW1wbGUuY29tIiwKICJzdWIiOiAiMjQ4'
'Mjg5NzYxMDAxIiwKICJhdWQiOiAiczZCaGRSa3F0MyIsCiAibm9uY2UiOiAi'
'bi0wUzZfV3pBMk1qIiwKICJleHAiOiAxMzExMjgxOTcwLAogImlhdCI6IDEz'
'MTEyODA5NzAsCiAibmFtZSI6ICJKYW5lIERvZSIsCiAiZ2l2ZW5fbmFtZSI6'
'ICJKYW5lIiwKICJmYW1pbHlfbmFtZSI6ICJEb2UiLAogImdlbmRlciI6ICJm'
'ZW1hbGUiLAogImJpcnRoZGF0ZSI6ICIwMDAwLTEwLTMxIiwKICJlbWFpbCI6'
'ICJqYW5lZG9lQGV4YW1wbGUuY29tIiwKICJwaWN0dXJlIjogImh0dHA6Ly9l'
'eGFtcGxlLmNvbS9qYW5lZG9lL21lLmpwZyIKfQ.rHQjEmBqn9Jre0OLykYNn'
'spA10Qql2rvx4FsD00jwlB0Sym4NzpgvPKsDjn_wMkHxcp6CilPcoKrWHcip'
'R2iAjzLvDNAReF97zoJqq880ZD1bwY82JDauCXELVR9O6_B0w3K-E7yM2mac'
'AAgNCUwtik6SjoSUZRcf-O5lygIyLENx882p6MtmwaL1hd6qn5RZOQ0TLrOY'
'u0532g9Exxcm-ChymrB4xLykpDj3lUivJt63eEGGN6DH5K6o33TcxkIjNrCD'
'4XB1CKKumZvCedgHHF3IAK4dVEDSUoGlH9z4pP_eWYNXvqQOjGs-rDaQzUHl'
'6cQQWNiDpWOl_lxXjQEvQ'
)
self.assertEqual(
OAuth2Key.decode_jwt(jwt_data, options={'verify_exp': False, 'verify_aud': False}),
{
"iss": "http://server.example.com",
"sub": "248289761001",
"aud": "s6BhdRkqt3",
"nonce": "n-0S6_WzA2Mj",
"exp": 1311281970,
"iat": 1311280970,
"name": "Jane Doe",
"given_name": "Jane",
"family_name": "Doe",
"gender": "female",
"birthdate": "0000-10-31",
"email": "janedoe@example.com",
"picture": "http://example.com/janedoe/me.jpg"
}
)
with self.assertRaises(jwt.exceptions.InvalidKeyError):
# {"alg":"RS256"} -> no key id
OAuth2Key.decode_jwt('eyJhbGciOiJSUzI1NiJ9.' + jwt_data.split('.', 1)[-1])
with self.assertRaises(jwt.exceptions.InvalidKeyError):
# {"kid":"XXXXX","alg":"RS256"} -> unknown key id
OAuth2Key.decode_jwt('eyJraWQiOiJYWFhYWCIsImFsZyI6IlJTMjU2In0.' + jwt_data.split('.', 1)[-1])
OAuth2Key.query.get('1e9gdk7').active = False
with self.assertRaises(jwt.exceptions.InvalidKeyError):
# not active
OAuth2Key.decode_jwt(jwt_data)
def test_generate_rsa_key(self):
key = OAuth2Key.generate_rsa_key()
self.assertEqual(key.algorithm, 'RS256')
This diff is collapsed.
"""OpenID Connect Support
Revision ID: 01fdd7820f29
Revises: a9b449776953
Create Date: 2023-11-09 16:52:20.860871
"""
from alembic import op
import sqlalchemy as sa
import datetime
import secrets
import math
import logging
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend # Only required for Buster
import jwt
# pyjwt v1.7.x compat (Buster/Bullseye)
if not hasattr(jwt, 'get_algorithm_by_name'):
jwt.get_algorithm_by_name = lambda name: jwt.algorithms.get_default_algorithms()[name]
# revision identifiers, used by Alembic.
revision = '01fdd7820f29'
down_revision = 'a9b449776953'
branch_labels = None
depends_on = None
logger = logging.getLogger('alembic.runtime.migration.01fdd7820f29')
def token_with_alphabet(alphabet, nbytes=None):
'''Return random text token that consists of characters from `alphabet`'''
if nbytes is None:
nbytes = max(secrets.DEFAULT_ENTROPY, 32)
nbytes_per_char = math.log(len(alphabet), 256)
nchars = math.ceil(nbytes / nbytes_per_char)
return ''.join([secrets.choice(alphabet) for _ in range(nchars)])
def token_urlfriendly(nbytes=None):
'''Return random text token that is urlsafe and works around common parsing bugs'''
alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
return token_with_alphabet(alphabet, nbytes=nbytes)
def upgrade():
logger.info('Generating 3072 bit RSA key pair (RS256) for OpenID Connect support ...')
private_key = rsa.generate_private_key(public_exponent=65537, key_size=3072, backend=default_backend())
meta = sa.MetaData(bind=op.get_bind())
oauth2_key = op.create_table('oauth2_key',
sa.Column('id', sa.String(length=64), nullable=False),
sa.Column('created', sa.DateTime(), nullable=False),
sa.Column('active', sa.Boolean(create_constraint=False), nullable=False),
sa.Column('algorithm', sa.String(length=32), nullable=False),
sa.Column('private_key_jwk', sa.Text(), nullable=False),
sa.Column('public_key_jwk', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2_key'))
)
algorithm = jwt.get_algorithm_by_name('RS256')
op.bulk_insert(oauth2_key, [{
'id': token_urlfriendly(),
'created': datetime.datetime.utcnow(),
'active': True,
'algorithm': 'RS256',
'private_key_jwk': algorithm.to_jwk(private_key),
'public_key_jwk': algorithm.to_jwk(private_key.public_key()),
}])
with op.batch_alter_table('oauth2grant', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_oauth2grant_code'))
oauth2grant = sa.Table('oauth2grant', meta,
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('client_db_id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=255), nullable=False),
sa.Column('redirect_uri', sa.String(length=255), nullable=False),
sa.Column('expires', sa.DateTime(), nullable=False),
sa.Column('_scopes', sa.Text(), nullable=False),
sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2grant_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2grant_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2grant'))
)
with op.batch_alter_table('oauth2grant', copy_from=oauth2grant) as batch_op:
batch_op.add_column(sa.Column('nonce', sa.Text(), nullable=True))
batch_op.add_column(sa.Column('claims', sa.Text(), nullable=True))
batch_op.alter_column('redirect_uri', existing_type=sa.VARCHAR(length=255), nullable=True)
oauth2token = sa.Table('oauth2token', meta,
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('client_db_id', sa.Integer(), nullable=False),
sa.Column('token_type', sa.String(length=40), nullable=False),
sa.Column('access_token', sa.String(length=255), nullable=False),
sa.Column('refresh_token', sa.String(length=255), nullable=False),
sa.Column('expires', sa.DateTime(), nullable=False),
sa.Column('_scopes', sa.Text(), nullable=False),
sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2token_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2token_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2token')),
sa.UniqueConstraint('access_token', name=op.f('uq_oauth2token_access_token')),
sa.UniqueConstraint('refresh_token', name=op.f('uq_oauth2token_refresh_token'))
)
with op.batch_alter_table('oauth2token', copy_from=oauth2token) as batch_op:
batch_op.add_column(sa.Column('claims', sa.Text(), nullable=True))
def downgrade():
meta = sa.MetaData(bind=op.get_bind())
oauth2token = sa.Table('oauth2token', meta,
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('client_db_id', sa.Integer(), nullable=False),
sa.Column('token_type', sa.String(length=40), nullable=False),
sa.Column('access_token', sa.String(length=255), nullable=False),
sa.Column('refresh_token', sa.String(length=255), nullable=False),
sa.Column('expires', sa.DateTime(), nullable=False),
sa.Column('_scopes', sa.Text(), nullable=False),
sa.Column('claims', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2token_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2token_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2token')),
sa.UniqueConstraint('access_token', name=op.f('uq_oauth2token_access_token')),
sa.UniqueConstraint('refresh_token', name=op.f('uq_oauth2token_refresh_token'))
)
with op.batch_alter_table('oauth2token', copy_from=oauth2token) as batch_op:
batch_op.drop_column('claims')
oauth2grant = sa.Table('oauth2grant', meta,
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('client_db_id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=255), nullable=False),
sa.Column('redirect_uri', sa.String(length=255), nullable=True),
sa.Column('nonce', sa.Text(), nullable=True),
sa.Column('expires', sa.DateTime(), nullable=False),
sa.Column('_scopes', sa.Text(), nullable=False),
sa.Column('claims', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2grant_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2grant_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2grant'))
)
with op.batch_alter_table('oauth2grant', copy_from=oauth2grant) as batch_op:
batch_op.alter_column('redirect_uri', existing_type=sa.VARCHAR(length=255), nullable=False)
batch_op.drop_column('claims')
batch_op.drop_column('nonce')
batch_op.create_index(batch_op.f('ix_oauth2grant_code'), ['code'], unique=False)
op.drop_table('oauth2_key')
...@@ -2,7 +2,7 @@ from .api import APIClient ...@@ -2,7 +2,7 @@ from .api import APIClient
from .invite import Invite, InviteGrant, InviteSignup from .invite import Invite, InviteGrant, InviteSignup
from .mail import Mail, MailReceiveAddress, MailDestinationAddress from .mail import Mail, MailReceiveAddress, MailDestinationAddress
from .mfa import MFAType, MFAMethod, RecoveryCodeMethod, TOTPMethod, WebauthnMethod from .mfa import MFAType, MFAMethod, RecoveryCodeMethod, TOTPMethod, WebauthnMethod
from .oauth2 import OAuth2Client, OAuth2RedirectURI, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation from .oauth2 import OAuth2Client, OAuth2RedirectURI, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation, OAuth2Key
from .role import Role, RoleGroup, RoleGroupMap from .role import Role, RoleGroup, RoleGroupMap
from .selfservice import PasswordToken from .selfservice import PasswordToken
from .service import RemailerMode, Service, ServiceUser, get_services from .service import RemailerMode, Service, ServiceUser, get_services
......
import datetime import datetime
import json import json
import secrets
import base64
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Boolean
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
import jwt
from uffd.database import db, CommaSeparatedList from uffd.database import db, CommaSeparatedList
from uffd.tasks import cleanup_task from uffd.tasks import cleanup_task
from uffd.password_hash import PasswordHashAttribute, HighEntropyPasswordHash from uffd.password_hash import PasswordHashAttribute, HighEntropyPasswordHash
from uffd.utils import token_urlfriendly
from .session import DeviceLoginInitiation, DeviceLoginType from .session import DeviceLoginInitiation, DeviceLoginType
from .service import ServiceUser from .service import ServiceUser
# pyjwt v1.7.x compat (Buster/Bullseye)
if not hasattr(jwt, 'get_algorithm_by_name'):
jwt.get_algorithm_by_name = lambda name: jwt.algorithms.get_default_algorithms()[name]
class OAuth2Client(db.Model): class OAuth2Client(db.Model):
__tablename__ = 'oauth2client' __tablename__ = 'oauth2client'
# Inconsistently named "db_id" instead of "id" because of the naming conflict # Inconsistently named "db_id" instead of "id" because of the naming conflict
...@@ -28,17 +36,9 @@ class OAuth2Client(db.Model): ...@@ -28,17 +36,9 @@ class OAuth2Client(db.Model):
redirect_uris = association_proxy('_redirect_uris', 'uri') redirect_uris = association_proxy('_redirect_uris', 'uri')
logout_uris = relationship('OAuth2LogoutURI', cascade='all, delete-orphan') logout_uris = relationship('OAuth2LogoutURI', cascade='all, delete-orphan')
@property
def client_type(self):
return 'confidential'
@property
def default_scopes(self):
return ['profile']
@property @property
def default_redirect_uri(self): def default_redirect_uri(self):
return self.redirect_uris[0] return self.redirect_uris[0] if len(self.redirect_uris) == 1 else None
def access_allowed(self, user): def access_allowed(self, user):
service_user = ServiceUser.query.get((self.service_id, user.id)) service_user = ServiceUser.query.get((self.service_id, user.id))
...@@ -69,28 +69,74 @@ class OAuth2Grant(db.Model): ...@@ -69,28 +69,74 @@ class OAuth2Grant(db.Model):
__tablename__ = 'oauth2grant' __tablename__ = 'oauth2grant'
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
EXPIRES_IN = 100
expires = Column(DateTime, nullable=False, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(seconds=OAuth2Grant.EXPIRES_IN))
user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
user = relationship('User') user = relationship('User')
client_db_id = Column(Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) client_db_id = Column(Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
client = relationship('OAuth2Client') client = relationship('OAuth2Client')
code = Column(String(255), index=True, nullable=False) _code = Column('code', String(255), nullable=False, default=token_urlfriendly)
redirect_uri = Column(String(255), nullable=False) code = property(lambda self: f'{self.id}-{self._code}')
expires = Column(DateTime, nullable=False, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(seconds=100)) redirect_uri = Column(String(255), nullable=True)
nonce = Column(Text(), nullable=True)
scopes = Column('_scopes', CommaSeparatedList(), nullable=False, default=tuple()) scopes = Column('_scopes', CommaSeparatedList(), nullable=False, default=tuple())
_claims = Column('claims', Text(), nullable=True)
@property
def claims(self):
return json.loads(self._claims) if self._claims is not None else None
@claims.setter
def claims(self, value):
self._claims = json.dumps(value) if value is not None else None
@property
def service_user(self):
service_user = ServiceUser.query.get((self.client.service_id, self.user.id))
if service_user is None:
raise Exception('ServiceUser lookup failed')
return service_user
@hybrid_property @hybrid_property
def expired(self): def expired(self):
if self.expires is None: if self.expires is None:
return False return False
return self.expires < datetime.datetime.utcnow() return self.expires < datetime.datetime.utcnow()
@classmethod
def get_by_authorization_code(cls, code):
# pylint: disable=protected-access
if '-' not in code:
return None
grant_id, grant_code = code.split('-', 2)
grant = cls.query.filter_by(id=grant_id, expired=False).first()
if not grant or not secrets.compare_digest(grant._code, grant_code):
return None
if grant.user.is_deactivated or not grant.client.access_allowed(grant.user):
return None
return grant
def make_token(self, **kwargs):
return OAuth2Token(
user=self.user,
client=self.client,
scopes=self.scopes,
claims=self.claims,
**kwargs
)
@cleanup_task.delete_by_attribute('expired') @cleanup_task.delete_by_attribute('expired')
class OAuth2Token(db.Model): class OAuth2Token(db.Model):
__tablename__ = 'oauth2token' __tablename__ = 'oauth2token'
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
EXPIRES_IN = 3600
expires = Column(DateTime, nullable=False, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(seconds=OAuth2Token.EXPIRES_IN))
user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
user = relationship('User') user = relationship('User')
...@@ -98,19 +144,46 @@ class OAuth2Token(db.Model): ...@@ -98,19 +144,46 @@ class OAuth2Token(db.Model):
client = relationship('OAuth2Client') client = relationship('OAuth2Client')
# currently only bearer is supported # currently only bearer is supported
token_type = Column(String(40), nullable=False) token_type = Column(String(40), nullable=False, default='bearer')
access_token = Column(String(255), unique=True, nullable=False) _access_token = Column('access_token', String(255), unique=True, nullable=False, default=token_urlfriendly)
refresh_token = Column(String(255), unique=True, nullable=False) access_token = property(lambda self: f'{self.id}-{self._access_token}')
expires = Column(DateTime, nullable=False) _refresh_token = Column('refresh_token', String(255), unique=True, nullable=False, default=token_urlfriendly)
refresh_token = property(lambda self: f'{self.id}-{self._refresh_token}')
scopes = Column('_scopes', CommaSeparatedList(), nullable=False, default=tuple()) scopes = Column('_scopes', CommaSeparatedList(), nullable=False, default=tuple())
_claims = Column('claims', Text(), nullable=True)
@property
def claims(self):
return json.loads(self._claims) if self._claims is not None else None
@claims.setter
def claims(self, value):
self._claims = json.dumps(value) if value is not None else None
@property
def service_user(self):
service_user = ServiceUser.query.get((self.client.service_id, self.user.id))
if service_user is None:
raise Exception('ServiceUser lookup failed')
return service_user
@hybrid_property @hybrid_property
def expired(self): def expired(self):
return self.expires < datetime.datetime.utcnow() return self.expires < datetime.datetime.utcnow()
def set_expires_in_seconds(self, seconds): @classmethod
self.expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds) def get_by_access_token(cls, access_token):
expires_in_seconds = property(fset=set_expires_in_seconds) # pylint: disable=protected-access
if '-' not in access_token:
return None
token_id, token_secret = access_token.split('-', 2)
token = cls.query.filter_by(id=token_id, expired=False).first()
if not token or not secrets.compare_digest(token._access_token, token_secret):
return None
if token.user.is_deactivated or not token.client.access_allowed(token.user):
return None
return token
class OAuth2DeviceLoginInitiation(DeviceLoginInitiation): class OAuth2DeviceLoginInitiation(DeviceLoginInitiation):
__mapper_args__ = { __mapper_args__ = {
...@@ -122,3 +195,93 @@ class OAuth2DeviceLoginInitiation(DeviceLoginInitiation): ...@@ -122,3 +195,93 @@ class OAuth2DeviceLoginInitiation(DeviceLoginInitiation):
@property @property
def description(self): def description(self):
return self.client.service.name return self.client.service.name
class OAuth2Key(db.Model):
__tablename__ = 'oauth2_key'
id = Column(String(64), primary_key=True, default=token_urlfriendly)
created = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
active = Column(Boolean(create_constraint=False), default=True, nullable=False)
algorithm = Column(String(32), nullable=False)
private_key_jwk = Column(Text(), nullable=False)
public_key_jwk = Column(Text(), nullable=False)
def __init__(self, **kwargs):
if kwargs.get('algorithm') and kwargs.get('private_key') \
and not kwargs.get('private_key_jwk') \
and not kwargs.get('public_key_jwk'):
algorithm = jwt.get_algorithm_by_name(kwargs['algorithm'])
private_key = kwargs.pop('private_key')
kwargs['private_key_jwk'] = algorithm.to_jwk(private_key)
kwargs['public_key_jwk'] = algorithm.to_jwk(private_key.public_key())
super().__init__(**kwargs)
@property
def private_key(self):
# pylint: disable=protected-access,import-outside-toplevel
# cryptography performs expensive checks when loading RSA private keys.
# Since we only load keys we generated ourselves with help of cryptography,
# these checks are unnecessary.
import cryptography.hazmat.backends.openssl
cryptography.hazmat.backends.openssl.backend._rsa_skip_check_key = True
res = jwt.get_algorithm_by_name(self.algorithm).from_jwk(self.private_key_jwk)
cryptography.hazmat.backends.openssl.backend._rsa_skip_check_key = False
return res
@property
def public_key(self):
return jwt.get_algorithm_by_name(self.algorithm).from_jwk(self.public_key_jwk)
@property
def public_key_jwks_dict(self):
res = json.loads(self.public_key_jwk)
res['kid'] = self.id
res['alg'] = self.algorithm
res['use'] = 'sig'
# RFC7517 4.3 "The "use" and "key_ops" JWK members SHOULD NOT be used together [...]"
res.pop('key_ops', None)
return res
def encode_jwt(self, payload):
if not self.active:
raise jwt.exceptions.InvalidKeyError(f'Key {self.id} not active')
return jwt.encode(payload, key=self.private_key, algorithm=self.algorithm, headers={'kid': self.id})
# Hash algorithm for at_hash/c_hash from OpenID Connect Core 1.0 section 3.1.3.6
def oidc_hash(self, value):
# pylint: disable=import-outside-toplevel
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend # Only required for Buster
hash_alg = jwt.get_algorithm_by_name(self.algorithm).hash_alg
digest = hashes.Hash(hash_alg(), backend=default_backend())
digest.update(value)
return base64.urlsafe_b64encode(
digest.finalize()[:hash_alg.digest_size // 2]
).decode('ascii').rstrip('=')
@classmethod
def get_preferred_key(cls, algorithm='RS256'):
return cls.query.filter_by(active=True, algorithm=algorithm).order_by(OAuth2Key.created.desc()).first()
@classmethod
def get_available_algorithms(cls):
return ['RS256']
@classmethod
def decode_jwt(cls, data, algorithms=('RS256',), **kwargs):
headers = jwt.get_unverified_header(data)
if 'kid' not in headers:
raise jwt.exceptions.InvalidKeyError('JWT without kid')
kid = headers['kid']
key = cls.query.get(kid)
if not key:
raise jwt.exceptions.InvalidKeyError(f'Key {kid} not found')
if not key.active:
raise jwt.exceptions.InvalidKeyError(f'Key {kid} not active')
return jwt.decode(data, key=key.public_key, algorithms=algorithms, **kwargs)
@classmethod
def generate_rsa_key(cls, public_exponent=65537, key_size=3072):
# pylint: disable=import-outside-toplevel
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend # Only required for Buster
return cls(algorithm='RS256', private_key=rsa.generate_private_key(public_exponent=public_exponent, key_size=key_size, backend=default_backend()))
...@@ -116,6 +116,16 @@ class ServiceUser(db.Model): ...@@ -116,6 +116,16 @@ class ServiceUser(db.Model):
return remailer.build_v2_address(self.service_id, self.user_id) return remailer.build_v2_address(self.service_id, self.user_id)
return self.real_email return self.real_email
# User.primary_email and ServiceUser.service_email can only be set to
# verified addresses, so this should always return True
@property
def email_verified(self):
if self.effective_remailer_mode != RemailerMode.DISABLED:
return True
if self.has_email_preferences and self.service_email:
return self.service_email.verified
return self.user.primary_email.verified
@classmethod @classmethod
def filter_query_by_email(cls, query, email): def filter_query_by_email(cls, query, email):
'''Filter query of ServiceUser by ServiceUser.email''' '''Filter query of ServiceUser by ServiceUser.email'''
......
This diff is collapsed.
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment