diff --git a/README.md b/README.md
index f1cd4f309c75460fd5cb38f7bc7943caa70685e5..2c51d402193a9a58c7dcf7f5f9c51b156088bf4a 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,8 @@ Please note that we refer to Debian packages here and **not** pip packages.
 - python3-qrcode
 - python3-fido2 (version 0.5.0 or 0.9.1, optional)
 - python3-prometheus-client (optional, needed for metrics)
-- python3-oauthlib
+- python3-jwt
+- python3-cryptography
 - python3-flask-babel
 - python3-argon2
 - 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
 * `/oauth2/token`: token request endpoint
 * `/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:
 
 ```
@@ -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.
 
+## 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
 
 Uffd can export metrics in a prometheus compatible way. It needs python3-prometheus-client for this feature to work.
diff --git a/debian/control b/debian/control
index 0e498d6a15f3d2fe548a96bc6204fcc630f7fc79..a9a87dd55c0dc858213f318844900b24cbe18172 100644
--- a/debian/control
+++ b/debian/control
@@ -23,7 +23,8 @@ Depends:
  python3-flask-migrate,
  python3-qrcode,
  python3-fido2,
- python3-oauthlib,
+ python3-jwt,
+ python3-cryptography,
  python3-flask-babel,
  python3-argon2,
  python3-itsdangerous,
diff --git a/setup.py b/setup.py
index 794373b3a024cd8586babf297ac09b7651dacb1f..189b301027b6a4d242ad3e766b07d7160f5cfd0f 100644
--- a/setup.py
+++ b/setup.py
@@ -36,7 +36,8 @@ setup(
 		'Flask-SQLAlchemy==2.1',
 		'qrcode==6.1',
 		'fido2==0.5.0',
-		'oauthlib==2.1.0',
+		'cryptography==2.6.1',
+		'pyjwt==1.7.0',
 		'Flask-Migrate==2.1.1',
 		'Flask-Babel==0.11.2',
 		'alembic==1.0.0',
@@ -52,11 +53,9 @@ setup(
 		'cffi # v1.12.2 no longer works with python3.9. Newer versions seem to work fine.',
 		'chardet==3.0.4',
 		'click==7.0',
-		'cryptography==2.6.1',
 		'idna==2.6',
 		'Jinja2==2.10',
 		'MarkupSafe==1.1.0',
-		'oauthlib==2.1.0',
 		'pyasn1==0.4.2',
 		'pycparser==2.19',
 		'requests==2.21.0',
diff --git a/tests/migrations/test_fuzzy.py b/tests/migrations/test_fuzzy.py
index 7200267b7ba403c61850cf91d1ff2fe175be898b..226e75ee6d31dcbeba220975937355c26ddc7f34 100644
--- a/tests/migrations/test_fuzzy.py
+++ b/tests/migrations/test_fuzzy.py
@@ -60,8 +60,8 @@ class TestFuzzy(MigrationTestCase):
 		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')])
 		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(OAuth2Token(user=user, client=oauth2_client, token_type='Bearer', access_token='testcode', refresh_token='testcode', 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(OAuth2DeviceLoginInitiation(client=oauth2_client, confirmations=[DeviceLoginConfirmation(user=user)]))
 		db.session.add(PasswordToken(user=user))
 		db.session.commit()
diff --git a/tests/models/test_oauth2.py b/tests/models/test_oauth2.py
new file mode 100644
index 0000000000000000000000000000000000000000..52b66299c2d2e550e70e762e33cc02653b3bed5a
--- /dev/null
+++ b/tests/models/test_oauth2.py
@@ -0,0 +1,158 @@
+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')
diff --git a/tests/views/test_oauth2.py b/tests/views/test_oauth2.py
index 42067d583ce6fd870e85a729ea877166c4745f58..2451af51519eb23b17ac0d2ab8225523ad7ae0d9 100644
--- a/tests/views/test_oauth2.py
+++ b/tests/views/test_oauth2.py
@@ -1,13 +1,16 @@
+import unittest
 from urllib.parse import urlparse, parse_qs
 
+import jwt
 from flask import url_for, session
 
 from uffd.database import db
 from uffd.password_hash import PlaintextPasswordHash
 from uffd.remailer import remailer
-from uffd.models import DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation, RemailerMode
+from uffd.models import DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation, RemailerMode, OAuth2Key
 
 from tests.utils import dump, UffdTestCase
+from tests.models.test_oauth2 import TEST_JWK
 
 class TestViews(UffdTestCase):
 	def setUpDB(self):
@@ -69,6 +72,9 @@ class TestViews(UffdTestCase):
 		self.assertTrue(oauth2_client.client_secret.verify('testsecret'))
 
 	def test_authorization_without_redirect_uri(self):
+		client = OAuth2Client.query.filter_by(client_id='test').one()
+		client.redirect_uris.remove('http://localhost:5009/callback2')
+		db.session.commit()
 		self.login_as('user')
 		r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', scope='profile'), follow_redirects=False)
 		self.assert_authorization(r)
@@ -84,6 +90,15 @@ class TestViews(UffdTestCase):
 		r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', scope='', redirect_uri='http://localhost:5009/callback'), follow_redirects=False)
 		self.assert_authorization(r)
 
+	def test_authorization_access_denied(self):
+		client = OAuth2Client.query.filter_by(client_id='test').one()
+		client.service.limit_access = True
+		db.session.commit()
+		self.login_as('user')
+		r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback', scope='profile'), follow_redirects=False)
+		self.assertEqual(r.status_code, 403)
+		dump('oauth2_authorization_access_denied', r)
+
 	def test_authorization_invalid_scope(self):
 		self.login_as('user')
 		r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback', scope='invalid'), follow_redirects=False)
@@ -190,7 +205,7 @@ class TestViews(UffdTestCase):
 	def test_token_invalid_code(self):
 		r = self.client.post(path=url_for('oauth2.token'),
 			data={'grant_type': 'authorization_code', 'code': 'abcdef', 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'test', 'client_secret': 'testsecret'}, follow_redirects=True)
-		self.assertIn(r.status_code, [400, 401]) # oauthlib behaviour changed between v2.1.0 and v3.1.0
+		self.assertEqual(r.status_code, 400)
 		self.assertEqual(r.content_type, 'application/json')
 		self.assertEqual(r.json['error'], 'invalid_grant')
 
@@ -203,7 +218,7 @@ class TestViews(UffdTestCase):
 		r = self.client.post(path=url_for('oauth2.token'),
 			data={'grant_type': 'authorization_code', 'code': code, 'redirect_uri': 'http://localhost:5009/callback'},
 			headers={'Authorization': f'Basic dGVzdDp0ZXN0c2VjcmV0'}, follow_redirects=True)
-		self.assertIn(r.status_code, [400, 401]) # oauthlib behaviour changed between v2.1.0 and v3.1.0
+		self.assertEqual(r.status_code, 400)
 		self.assertEqual(r.json['error'], 'invalid_grant')
 
 	def test_token_invalid_client(self):
@@ -211,12 +226,14 @@ class TestViews(UffdTestCase):
 			data={'grant_type': 'authorization_code', 'code': self.get_auth_code(), 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'invalid_client', 'client_secret': 'invalid_client_secret'}, follow_redirects=True)
 		self.assertEqual(r.status_code, 401)
 		self.assertEqual(r.content_type, 'application/json')
+		self.assertEqual(r.json['error'], 'invalid_client')
 
 	def test_token_unauthorized_client(self):
 		r = self.client.post(path=url_for('oauth2.token'),
 			data={'grant_type': 'authorization_code', 'code': self.get_auth_code(), 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'test'}, follow_redirects=True)
 		self.assertEqual(r.status_code, 401)
 		self.assertEqual(r.content_type, 'application/json')
+		self.assertEqual(r.json['error'], 'invalid_client')
 
 	def test_token_unsupported_grant_type(self):
 		r = self.client.post(path=url_for('oauth2.token'),
@@ -231,15 +248,16 @@ class TestViews(UffdTestCase):
 		db.session.commit()
 		r = self.client.post(path=url_for('oauth2.token'),
 			data={'grant_type': 'authorization_code', 'code': code, 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'test', 'client_secret': 'testsecret'}, follow_redirects=True)
-		self.assertIn(r.status_code, [400, 401]) # oauthlib behaviour changed between v2.1.0 and v3.1.0
+		self.assertEqual(r.status_code, 400)
 		self.assertEqual(r.content_type, 'application/json')
+		self.assertEqual(r.json['error'], 'invalid_grant')
 
 	def test_userinfo_invalid_access_token(self):
 		token = 'invalidtoken'
 		r = self.client.get(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s'%token)], follow_redirects=True)
 		self.assertEqual(r.status_code, 401)
 
-	def test_userinfo_invalid_access_token(self):
+	def test_userinfo_deactivated_user(self):
 		r = self.client.post(path=url_for('oauth2.token'),
 			data={'grant_type': 'authorization_code', 'code': self.get_auth_code(), 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'test', 'client_secret': 'testsecret'}, follow_redirects=True)
 		token = r.json['access_token']
@@ -247,3 +265,680 @@ class TestViews(UffdTestCase):
 		db.session.commit()
 		r = self.client.get(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s'%token)], follow_redirects=True)
 		self.assertEqual(r.status_code, 401)
+
+class TestOIDCConfigurationProfile(UffdTestCase):
+	def setUpDB(self):
+		db.session.add(OAuth2Key(**TEST_JWK))
+
+	def test_discover_spec(self):
+		ISSUER = 'https://sso.example.com'
+		r = self.client.get(base_url=ISSUER, path='/.well-known/openid-configuration')
+
+		# OIDC Discovery 1.0 section 4.2:
+		# > A successful response MUST use the 200 OK HTTP status code and return a
+		# > JSON object using the application/json content type that contains a set
+		# > of Claims as its members that are a subset of the Metadata values defined
+		# > in Section 3.
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(r.content_type, 'application/json')
+		self.assertIsInstance(r.json, dict) # also validates JSON syntax
+
+		# OIDC Discovery 1.0 section 4.2:
+		# > Claims that return multiple values are represented as JSON arrays.
+		# > Claims with zero elements MUST be omitted from the response. 
+		for key, value in r.json.items():
+			if isinstance(value, list):
+				self.assertNotEqual(len(value), 0)
+
+		# OIDC Discovery 1.0 section 3 (REQUIRED metadata values)
+		required_fields = {'issuer', 'authorization_endpoint', 'jwks_uri', 'response_types_supported', 'subject_types_supported', 'id_token_signing_alg_values_supported'}
+		if 'code' in r.json.get('response_types_supported', []):
+			required_fields.add('token_endpoint')
+		if 'authorization_code' in r.json.get('grant_types_supported', ['authorization_code', 'implicit']):
+			required_fields.add('token_endpoint')
+		for field in required_fields:
+			self.assertIn(field, r.json)
+
+		# OIDC Discovery 1.0 section 3 (metadata value types)
+		bool_fields = ('claims_parameter_supported', 'request_parameter_supported', 'request_uri_parameter_supported', 'require_request_uri_registration')
+		list_fields = ('scopes_supported', 'response_types_supported', 'response_modes_supported', 'grant_types_supported', 'acr_values_supported', 'subject_types_supported', 'id_token_signing_alg_values_supported', 'id_token_encryption_alg_values_supported', 'id_token_encryption_enc_values_supported', 'userinfo_signing_alg_values_supported', 'userinfo_encryption_alg_values_supported', 'userinfo_encryption_enc_values_supported', 'request_object_signing_alg_values_supported', 'request_object_encryption_alg_values_supported', 'request_object_encryption_enc_values_supported', 'token_endpoint_auth_methods_supported', 'token_endpoint_auth_signing_alg_values_supported', 'display_values_supported', 'claim_types_supported', 'claims_supported', 'claims_locales_supported', 'ui_locales_supported')
+		https_url_fields = ('issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri', 'registration_endpoint')
+		url_fields = ('service_documentation', 'op_policy_uri', 'op_tos_uri')
+		for field in bool_fields:
+			if field in r.json:
+				self.assertIsInstance(r.json[field], bool)
+		for field in list_fields:
+			if field in r.json:
+				self.assertIsInstance(r.json[field], list)
+		for field in https_url_fields:
+			if field in r.json:
+				self.assertIsInstance(r.json[field], str)
+				self.assertTrue(r.json[field].lower().startswith('https://'))
+		for field in url_fields:
+			if field in r.json:
+				self.assertIsInstance(r.json[field], str)
+				self.assertTrue(r.json[field].lower().startswith('http'))
+
+		# OIDC Discovery 1.0 section 3 (MUSTs on metadata values except https scheme and jwks_uri)
+		self.assertEqual(r.json['issuer'], ISSUER)
+		if 'scopes_supported' in r.json:
+			self.assertIsInstance(r.json['scopes_supported'], list)
+			for item in r.json['scopes_supported']:
+				self.assertIsInstance(item, str)
+				self.assertRegex(item, r'^[!#-\[\]-~]+$') # 1*( %x21 / %x23-5B / %x5D-7E )
+			self.assertIn('openid', r.json['scopes_supported'])
+		self.assertIn('RS256', r.json['id_token_signing_alg_values_supported'])
+		if 'token_endpoint_auth_signing_alg_values_supported' in r.json:
+			self.assertNotIn('none', r.json['token_endpoint_auth_signing_alg_values_supported'])
+
+		# OIDC Discovery 1.0 section 3 (jwks_uri) and RFC7517
+		self.assertTrue(r.json['jwks_uri'].startswith(ISSUER)) # Not a requirement by spec, but technically neccessary for this test
+		r_jwks = self.client.get(base_url=ISSUER, path=r.json['jwks_uri'][len(ISSUER):])
+		self.assertEqual(r_jwks.status_code, 200)
+		# The jwks_uri SHOULD include a Cache-Control header in the response that contains a max-age directive ...
+		self.assertIn('Cache-Control', r_jwks.headers)
+		self.assertIsInstance(r_jwks.json, dict) # also validates JSON syntax
+		self.assertIn('keys', r_jwks.json)
+		self.assertIsInstance(r_jwks.json['keys'], list)
+		has_sign_keys = False
+		has_encrypt_keys = False
+		kids = set()
+		for key in r_jwks.json['keys']:
+			self.assertIn('kty', key)
+			self.assertIsInstance(key['kty'], str)
+			if 'use' in key:
+				self.assertIsInstance(key['use'], str)
+				if key['use'] == 'sig':
+					has_sign_keys = True
+				if key['use'] == 'enc':
+					has_enc_keys = True
+			if 'key_ops' in key:
+				self.assertIsInstance(key['key_ops'], list)
+				self.assertNotIn('use', key) # SHOULD
+				for value in key['key_ops']:
+					self.assertIsInstance(value, str)
+					self.assertEqual(key['key_ops'].count(value), 1)
+				# OIDC: "The JWK Set MUST NOT contain private or symmetric key values."
+				self.assertNotIn('decrypt', key['key_ops'])
+				self.assertNotIn('sign', key['key_ops'])
+				if 'verify' in key['key_ops']:
+					has_sign_keys = True
+				if 'encrypt' in key['key_ops']:
+					has_enc_keys = True
+			if 'alg' in key:
+				self.assertIsInstance(key['alg'], str)
+			if 'kid' in key:
+				self.assertIsInstance(key['kid'], str)
+				self.assertNotIn(key['kid'], kids) # SHOULD
+				kids.add(key['kid'])
+			# ignoring everything X.509 related
+			# TODO: Validate algorithm-specific part of JWK
+		if has_sign_keys and has_encrypt_keys:
+			for key in r_jwks.json['keys']:
+				self.assertIn('use', key)
+
+class TestOIDCBasicProfile(UffdTestCase):
+	def setUpDB(self):
+		db.session.add(OAuth2Key(**TEST_JWK))
+		db.session.add(OAuth2Client(service=Service(name='test', limit_access=False), client_id='test', client_secret='testsecret', redirect_uris=['https://service/callback']))
+
+	# Helper
+	def validate_claim_syntax(self, name, value):
+		# Strip language tag
+		if '#' in name:
+			name = name.split('#')[0]
+		str_claims = ('sub', 'name', 'given_name', 'family_name', 'middle_name', 'nickname', 'preferred_username', 'profile', 'picture', 'website', 'email', 'gender', 'birthdate', 'zoneinfo', 'locale', 'phone_number', 'acr')
+		if name in str_claims:
+			self.assertIsInstance(value, str)
+		if name in ('profile', 'picture', 'website'):
+			self.assertTrue(value.lower().startswith('http'))
+		if name in ('email_verified', 'phone_number_verified'):
+			self.assertIsInstance(value, bool)
+		if name in ('updated_at', 'auth_time'):
+			self.assertTrue(isinstance(value, int) or isinstance(value, float))
+		if name == 'address':
+			self.assertIsInstance(value, dict)
+		if name == 'amr':
+			self.assertIsInstance(value, list)
+			for item in value:
+				self.assertIsInstance(item, str)
+
+	def validate_id_token(self, id_token, nonce='testnonce', client_id='test'):
+		headers = jwt.get_unverified_header(id_token)
+		self.assertIn('kid', headers)
+		self.assertIsInstance(headers['kid'], str)
+		# This checks signature and exp
+		data = OAuth2Key.decode_jwt(id_token, options={'verify_aud': False})
+		self.assertIn('iss', data)
+		self.assertIsInstance(data['iss'], str)
+		self.assertIn('sub', data)
+		self.assertIn('aud', data)
+		self.assertIsInstance(data['aud'], str)
+		if client_id is not None:
+			self.assertEqual(data['aud'], client_id)
+		self.assertIn('iat', data)
+		self.assertTrue(isinstance(data['iat'], int) or isinstance(data['iat'], float))
+		if 'nonce' in data:
+			self.assertIsInstance(data['nonce'], str)
+		self.assertEqual(data.get('nonce'), nonce)
+		if 'azp' in data:
+			self.assertIsInstance(data['azp'], str)
+		for name, value in data.items():
+			self.validate_claim_syntax(name, value)
+		return data
+
+	def is_login_page(self, location):
+		url = urlparse(location)
+		return url.netloc in ('localhost', '') and url.path == url_for('session.login')
+
+	def is_callback(self, location):
+		return location.startswith('https://service/callback')
+
+	def do_auth_request(self, client_id='test', state='teststate', nonce='testnonce', redirect_uri='https://service/callback', scope='openid', follow_redirects=True, **kwargs):
+		r = self.client.get(path=url_for('oauth2.authorize', client_id=client_id, state=state, nonce=nonce, redirect_uri=redirect_uri, scope=scope, **kwargs), follow_redirects=False)
+		while follow_redirects and r.status_code == 302 and not self.is_login_page(r.location) and not self.is_callback(r.location):
+			r = self.client.get(path=r.location, follow_redirects=False)
+		return r
+
+	def do_login(self, r, loginname='testuser', password='userpassword'):
+		self.assertEqual(r.status_code, 302)
+		self.assertTrue(self.is_login_page(r.location))
+		self.client.get(path=url_for('session.logout'), follow_redirects=True)
+		args = parse_qs(urlparse(r.location).query)
+		r = self.client.post(path=url_for('session.login', ref=args['ref'][0]), data={'loginname': loginname, 'password': password}, follow_redirects=False)
+		while r.status_code == 302 and not self.is_login_page(r.location) and not self.is_callback(r.location):
+			r = self.client.get(path=r.location, follow_redirects=False)
+		return r
+
+	def validate_auth_response(self, r, state='teststate'):
+		self.assertEqual(r.status_code, 302)
+		self.assertTrue(self.is_callback(r.location))
+		args = parse_qs(urlparse(r.location).query)
+		for key in args:
+			self.assertNotIn(key, ('error', 'error_description', 'error_uri'))
+			self.assertEqual(len(args[key]), 1) # Not generally specified, but still a good check
+		if state is not None:
+			self.assertIn('state', args)
+			self.assertEqual(args['state'], [state])
+		else:
+			self.assertNotIn('state', args)
+		return {key: values[0] for key, values in args.items()}
+
+	def assert_auth_error(self, r, *errors, state='teststate'):
+		self.assertEqual(r.status_code, 302)
+		self.assertTrue(self.is_callback(r.location))
+		args = parse_qs(urlparse(r.location).query)
+		for key in args:
+			self.assertIn(key, ('error', 'error_description', 'error_uri', 'state'))
+			self.assertEqual(len(args[key]), 1)
+		self.assertIn('error', args)
+		if state is not None:
+			self.assertIn('state', args)
+			self.assertEqual(args['state'][0], state)
+		else:
+			self.assertNotIn('state', args)
+		if errors:
+			self.assertIn(args['error'][0], errors)
+		self.assertRegex(args['error'][0], r'^[ -!#-\[\]-~]+$') # 1*( %x20-x21 / %x23-5B / %x5D-7E )
+		if 'error_description' in args:
+			self.assertRegex(args['error_description'][0], r'^[ -!#-\[\]-~]+$') # 1*( %x20-x21 / %x23-5B / %x5D-7E )
+		if 'error_uri' in args:
+			self.assertRegex(args['error_uri'][0], r'^[!#-\[\]-~]+$') # 1*( %x21 / %x23-5B / %x5D-7E )
+
+	def do_token_request(self, client_id='test', client_secret='testsecret', redirect_uri='https://service/callback', **kwargs):
+		data = {'redirect_uri': redirect_uri, 'client_id': client_id, 'client_secret': client_secret}
+		data.update(kwargs)
+		return self.client.post(path=url_for('oauth2.token'), data=data, follow_redirects=True)
+
+	def validate_token_response(self, r, nonce='testnonce', client_id='test'):
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(r.content_type, 'application/json')
+		self.assertIn('Cache-Control', r.headers)
+		self.assertEqual(r.headers['Cache-Control'].lower(), 'no-store')
+		for key in r.json:
+			self.assertNotIn(key, ('error', 'error_description', 'error_uri'))
+		self.assertIn('access_token', r.json)
+		self.assertIsInstance(r.json['access_token'], str)
+		self.assertIn('token_type', r.json)
+		self.assertIsInstance(r.json['token_type'], str)
+		# OIDC Core 1.0 section 3.1.3.3:
+		# > The OAuth 2.0 token_type response parameter value MUST be Bearer,
+		# > [...] unless another Token Type has been negotiated with the Client.
+		self.assertEqual(r.json['token_type'].lower(), 'bearer')
+		if 'expires_in' in r.json:
+			self.assertTrue(isinstance(r.json['expires_in'], int) or isinstance(data['expires_in'], float))
+		if 'refresh_token' in r.json:
+			self.assertIsInstance(r.json['refresh_token'], str)
+		if 'scope' in r.json:
+			self.assertIsInstance(r.json['scope'], str)
+			# scope       = scope-token *( SP scope-token )
+			# scope-token = 1*( %x21 / %x23-5B / %x5D-7E )
+			self.assertRegex(r.json['scope'], r'^[!#-\[\]-~]+( [!#-\[\]-~]+)*$')
+		# OIDC Core 1.0 section 3.1.3.3:
+		# > All Token Responses that contain tokens, secrets, or other sensitive
+		# > information MUST include the following HTTP response header fields and values:
+		# >   Cache-Control: no-store
+		self.assertIn('id_token', r.json)
+		return self.validate_id_token(r.json['id_token'], nonce=nonce, client_id=client_id)
+
+	def assert_token_error(self, r, *errors):
+		self.assertEqual(r.content_type, 'application/json')
+		if r.json.get('error', '') == 'invalid_client':
+			self.assertEqual(r.status_code, 401)
+		else:
+			self.assertEqual(r.status_code, 400)
+		for key in r.json:
+			self.assertIn(key, ('error', 'error_description', 'error_uri'))
+		self.assertIn('error', r.json)
+		if errors:
+			self.assertIn(r.json['error'], errors)
+		self.assertRegex(r.json['error'], r'^[ -!#-\[\]-~]+$') # 1*( %x20-x21 / %x23-5B / %x5D-7E )
+		if 'error_description' in r.json:
+			self.assertRegex(r.json['error_description'], r'^[ -!#-\[\]-~]+$') # 1*( %x20-x21 / %x23-5B / %x5D-7E )
+		if 'error_uri' in r.json:
+			self.assertRegex(r.json['error_uri'], r'^[!#-\[\]-~]+$') # 1*( %x21 / %x23-5B / %x5D-7E )
+
+	def do_userinfo_request(self, access_token):
+		return self.client.get(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s'%access_token)], follow_redirects=True)
+
+	def validate_userinfo_response(self, r):
+		self.assertEqual(r.status_code, 200)
+		# We ignore everything related to encrypted/signed JWT userinfo here
+		self.assertEqual(r.content_type, 'application/json')
+		self.assertIn('sub', r.json)
+		for name, value in r.json.items():
+			self.validate_claim_syntax(name, value)
+
+	def assert_userinfo_error(self, r):
+		self.assertEqual(r.status_code, 401)
+		self.assertEqual(len(r.headers.getlist('WWW-Authenticate')), 1)
+		method, args = (r.headers['WWW-Authenticate'].split(' ', 1) + [''])[:2]
+		args = {item.split('=', 1)[0]: item.split('=', 1)[1].strip(' \n"') for item in args.split(',') if item.strip()}
+		if 'scope' in args:
+			self.assertRegex(args['scope'], r'^[ -!#-\[\]-~]+$') # 1*( %x20-x21 / %x23-5B / %x5D-7E )
+		if 'error' in args:
+			self.assertRegex(args['error'], r'^[ -!#-\[\]-~]+$') # 1*( %x20-x21 / %x23-5B / %x5D-7E )
+		if 'error_description' in args:
+			self.assertRegex(args['error_description'], r'^[ -!#-\[\]-~]+$') # 1*( %x20-x21 / %x23-5B / %x5D-7E )
+
+	def test(self):
+		self.login_as('user')
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'])
+		id_token = self.validate_token_response(r)
+		self.assertEqual(id_token['sub'], '10000')
+		r = self.do_userinfo_request(r.json['access_token'])
+		self.validate_userinfo_response(r)
+		self.assertEqual(r.json['sub'], '10000')
+
+	def test_notloggedin(self):
+		r = self.do_auth_request(response_type='code')
+		r = self.do_login(r)
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'])
+		id_token = self.validate_token_response(r)
+		self.assertEqual(id_token['sub'], '10000')
+
+	def test_no_state(self):
+		self.login_as('user')
+		r = self.do_auth_request(response_type='code', state=None)
+		args = self.validate_auth_response(r, state=None)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'])
+		id_token = self.validate_token_response(r)
+		self.assertEqual(id_token['sub'], '10000')
+
+	def test_no_nonce(self):
+		self.login_as('user')
+		r = self.do_auth_request(response_type='code', nonce=None)
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'])
+		id_token = self.validate_token_response(r, nonce=None)
+		self.assertEqual(id_token['sub'], '10000')
+
+	def test_redirect_uri(self):
+		self.login_as('user')
+		# No redirect_uri in auth request is fine if there is only one uri registered
+		r = self.do_auth_request(response_type='code', redirect_uri=None)
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'], redirect_uri=None)
+		id_token = self.validate_token_response(r)
+		self.assertEqual(id_token['sub'], '10000')
+		# If auth request has redirect_uri, it is required in token request
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'], redirect_uri=None)
+		self.assert_token_error(r)
+		# If auth request has redirect_uri, it the redirect_uri in the token request must be the same
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'], redirect_uri='https://foobar/callback')
+		self.assert_token_error(r)
+		# Invalid redirect_uri
+		r = self.do_auth_request(response_type='code', redirect_uri='http://foobar/callback')
+		self.assertEqual(r.status_code, 400) # No redirect!
+		# redirect_uri is required in auth request if there is more than a single uri registered
+		client = OAuth2Client.query.one()
+		client.redirect_uris.append('https://service/callback2')
+		db.session.commit()
+		r = self.do_auth_request(response_type='code', redirect_uri=None)
+		self.assertEqual(r.status_code, 400) # No redirect!
+
+	def test_auth_errors(self):
+		# Missing response_type
+		r = self.do_auth_request()
+		self.assert_auth_error(r, 'invalid_request')
+		# Invalid response_type
+		r = self.do_auth_request(response_type='foobar')
+		self.assert_auth_error(r, 'unsupported_response_type')
+		# Missing client_id
+		r = self.do_auth_request(response_type='code', client_id=None)
+		self.assertEqual(r.status_code, 400) # No redirect!
+		# Invalid client_id
+		r = self.do_auth_request(response_type='code', client_id='foobar')
+		self.assertEqual(r.status_code, 400) # No redirect!
+		# Duplicate parameter
+		r = self.do_auth_request(response_type='code', client_id=['test', 'foobar'])
+		self.assertEqual(r.status_code, 400) # No redirect!
+
+	def test_access_denied(self):
+		Service.query.one().limit_access = True
+		db.session.commit()
+		self.login_as('user')
+		r = self.do_auth_request(response_type='code')
+		self.assert_auth_error(r, 'access_denied')
+
+	def test_auth_request_uri(self):
+		self.login_as('user')
+		r = self.do_auth_request(response_type='code', request_uri='https://localhost/myrequest_uri')
+		self.assert_auth_error(r, 'request_uri_not_supported')
+
+	def test_auth_request_unsigned(self):
+		self.login_as('user')
+		request_params = {
+			'response_type': 'code',
+			'client_id': 'test',
+			'redirect_uri': 'http://service/callback',
+			'scope': 'openid',
+			'state': 'teststate',
+			'nonce': 'testnonce',
+			'claims': {
+				'userinfo': {
+					'name': None,
+					'email': {'essential': True},
+					'email_verified': {'essential': True},
+				},
+				'id_token': {
+					'email': None,
+				}
+			}
+		}
+		r = self.do_auth_request(response_type='code', request=jwt.encode(request_params, algorithm='none', key=None))
+		self.assert_auth_error(r, 'request_not_supported')
+
+	def test_token_client_auth(self):
+		self.login_as('user')
+		# Auth via body -> ACCEPT
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'])
+		self.validate_token_response(r)
+		# Auth via header -> ACCEPT
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.client.post(
+			path=url_for('oauth2.token'),
+			data={'redirect_uri': 'https://service/callback', 'grant_type': 'authorization_code', 'code': args['code']},
+			headers={'Authorization': f'Basic dGVzdDp0ZXN0c2VjcmV0'},
+			follow_redirects=True,
+		)
+		self.validate_token_response(r)
+		# Auth via header, but same client id also in body -> ACCEPT
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.client.post(
+			path=url_for('oauth2.token'),
+			data={'redirect_uri': 'https://service/callback', 'grant_type': 'authorization_code', 'client_id': 'test', 'code': args['code']},
+			headers={'Authorization': f'Basic dGVzdDp0ZXN0c2VjcmV0'},
+			follow_redirects=True,
+		)
+		self.validate_token_response(r)
+		# Different client id in body and header -> REJECT
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.client.post(
+			path=url_for('oauth2.token'),
+			data={'redirect_uri': 'https://service/callback', 'grant_type': 'authorization_code', 'client_id': 'XXXX', 'code': args['code']},
+			headers={'Authorization': f'Basic dGVzdDp0ZXN0c2VjcmV0'},
+			follow_redirects=True,
+		)
+		self.assert_token_error(r, 'invalid_request')
+		# Duplicate client id in body -> REJECT
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'], client_id=['test', 'XXXX'])
+		self.assert_token_error(r, 'invalid_request')
+		# Duplicate client secret in body -> REJECT
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'], client_secret=['testsecret', 'XXXXX'])
+		self.assert_token_error(r, 'invalid_request')
+		# Client secret in body and header -> REJECT
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.client.post(
+			path=url_for('oauth2.token'),
+			data={'redirect_uri': 'https://service/callback', 'grant_type': 'authorization_code', 'client_id': 'test', 'client_secret': 'testsecret', 'code': args['code']},
+			headers={'Authorization': f'Basic dGVzdDp0ZXN0c2VjcmV0'},
+			follow_redirects=True,
+		)
+		self.assert_token_error(r, 'invalid_request')
+		# No secret -> REJECT
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'], client_secret=None)
+		self.assert_token_error(r, 'invalid_client')
+		# No client id but secret -> REJECT
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'], client_id=None)
+		self.assert_token_error(r, 'invalid_client')
+		# No client id and no secret -> REJECT
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'], client_id=None, client_secret=None)
+		self.assert_token_error(r, 'invalid_client')
+		# Unknown client id -> REJECT
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'], client_id='XXXX')
+		self.assert_token_error(r, 'invalid_client')
+
+	def test_token_errors(self):
+		self.login_as('user')
+		# Missing grant_type parameter
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(code=args['code'])
+		self.assert_token_error(r, 'invalid_request')
+		# Missing code parameter
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code')
+		self.assert_token_error(r, 'invalid_request')
+		# redirect_uri behaviour is already tested in test_redirect_uri
+		# Invalid grant type
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='foobar', code=args['code'])
+		self.assert_token_error(r, 'unsupported_grant_type')
+		# Duplicate code parameter
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=[args['code'], 'XXXXX'])
+		self.assert_token_error(r, 'invalid_request')
+		# Invalid code parameter
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code='XXXXX')
+		self.assert_token_error(r, 'invalid_grant')
+		# Invalid code parameter
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'].split('-')[0]+'-XXXXX')
+		self.assert_token_error(r, 'invalid_grant')
+		# Code was issued to different client
+		db.session.add(OAuth2Client(service=Service(name='test2', limit_access=False), client_id='test2', client_secret='testsecret2', redirect_uris=['https://service2/callback']))
+		db.session.commit()
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'], client_id='test2', client_secret='testsecret2')
+		self.assert_token_error(r, 'invalid_grant')
+
+	def test_userinfo_auth(self):
+		self.login_as('user')
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'])
+		self.validate_token_response(r)
+		access_token = r.json['access_token']
+		# GET + Bearer
+		r = self.client.get(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s'%access_token)], follow_redirects=True)
+		self.validate_userinfo_response(r)
+		self.assertEqual(r.json['sub'], '10000')
+		# POST + Bearer
+		r = self.client.post(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s'%access_token)], follow_redirects=True)
+		self.validate_userinfo_response(r)
+		self.assertEqual(r.json['sub'], '10000')
+		# "Bearer" is case-insensitive
+		r = self.client.post(path=url_for('oauth2.userinfo'), headers=[('authorization', 'bearer %s'%access_token)], follow_redirects=True)
+		self.validate_userinfo_response(r)
+		self.assertEqual(r.json['sub'], '10000')
+		# Invalid auth scheme
+		r = self.client.post(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Basic dGVzdDp0ZXN0c2VjcmV0')], follow_redirects=True)
+		self.assert_userinfo_error(r)
+		# Invalid bearer token
+		r = self.client.post(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s-XXXXX'%access_token.split('-')[0])], follow_redirects=True)
+		self.assert_userinfo_error(r)
+		r = self.client.post(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer XXXXX')], follow_redirects=True)
+		self.assert_userinfo_error(r)
+		# POST + body
+		r = self.client.post(path=url_for('oauth2.userinfo'), data={'access_token': access_token}, follow_redirects=True)
+		self.validate_userinfo_response(r)
+		self.assertEqual(r.json['sub'], '10000')
+		# GET + query
+		r = self.client.get(path=url_for('oauth2.userinfo', access_token=access_token), follow_redirects=True)
+		self.validate_userinfo_response(r)
+		self.assertEqual(r.json['sub'], '10000')
+		# POST + Bearer + body -> REJECT
+		r = self.client.post(path=url_for('oauth2.userinfo'), data={'access_token': access_token}, headers=[('Authorization', 'Bearer %s'%access_token)], follow_redirects=True)
+		self.assert_userinfo_error(r)
+		# No auth -> REJECT
+		r = self.client.post(path=url_for('oauth2.userinfo'), follow_redirects=True)
+		self.assert_userinfo_error(r)
+
+	def test_scope(self):
+		self.login_as('user')
+		# Scope values used that are not understood by an implementation SHOULD be ignored.
+		r = self.do_auth_request(response_type='code', scope='openid profile email address phone groups foobar')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'])
+		id_token = self.validate_token_response(r)
+		self.assertEqual(id_token['sub'], '10000')
+		r = self.do_userinfo_request(r.json['access_token'])
+		self.validate_userinfo_response(r)
+		self.assertEqual(r.json['sub'], '10000')
+		self.assertEqual(r.json['name'], 'Test User')
+		self.assertEqual(r.json['preferred_username'], 'testuser')
+		self.assertEqual(r.json['email'], 'test@example.com')
+		self.assertEqual(sorted(r.json['groups']), sorted(['users', 'uffd_access']))
+
+	def test_claims(self):
+		self.login_as('user')
+		# Scope values used that are not understood by an implementation SHOULD be ignored.
+		r = self.do_auth_request(response_type='code', claims='{"userinfo": {"name": {"essential": true}}, "id_token": {"preferred_username": {"essential": true}, "email": null}}')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'])
+		id_token = self.validate_token_response(r)
+		self.assertEqual(id_token['sub'], '10000')
+		self.assertEqual(id_token['preferred_username'], 'testuser')
+		self.assertEqual(id_token['email'], 'test@example.com')
+		self.assertNotIn('name', r.json)
+		r = self.do_userinfo_request(r.json['access_token'])
+		self.validate_userinfo_response(r)
+		self.assertEqual(r.json['sub'], '10000')
+		self.assertEqual(r.json['name'], 'Test User')
+		self.assertNotIn('email', r.json)
+
+	def test_prompt_none(self):
+		r = self.do_auth_request(response_type='code', prompt='none')
+		self.assert_auth_error(r, 'login_required')
+		self.login_as('user')
+		r = self.do_auth_request(response_type='code', prompt='none')
+		args = self.validate_auth_response(r)
+		self.assertIn('code', args)
+		# OIDC Core 1.0 section 3.1.2.1.:
+		# > If this parameter contains none with any other value, an error is returned.
+		r = self.do_auth_request(response_type='code', prompt='none login')
+		self.assert_auth_error(r)
+
+	@unittest.skip('prompt=login is not implemented') # MUST
+	def test_prompt_login(self):
+		self.login_as('user')
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		self.assertIn('code', args)
+		r = self.do_auth_request(response_type='code', prompt='login')
+		self.assertEqual(r.status_code, 302)
+		self.assertTrue(self.is_login_page(r.location))
+
+	# TODO: max_age
+
+	def test_sub_value(self):
+		# Via id_token_hint or claims.id_token.sub.value
+		self.login_as('user')
+		r = self.do_auth_request(response_type='code', prompt='none')
+		args = self.validate_auth_response(r)
+		r = self.do_token_request(grant_type='authorization_code', code=args['code'])
+		self.validate_token_response(r)
+		id_token = r.json['id_token']
+		r = self.do_auth_request(response_type='code', prompt='none', id_token_hint=id_token)
+		args = self.validate_auth_response(r)
+		self.assertIn('code', args)
+		r = self.do_auth_request(response_type='code', prompt='none', id_token_hint='XXXXX')
+		self.assert_auth_error(r, 'invalid_request')
+		r = self.do_auth_request(response_type='code', prompt='none', claims='{"id_token": {"sub": {"value": "10000"}}}')
+		args = self.validate_auth_response(r)
+		r = self.do_auth_request(response_type='code', prompt='none', claims='{"id_token": {"sub": {"value": "10001"}}}')
+		self.assert_auth_error(r, 'login_required')
+		# sub value in id_token_hint and claims is the same -> Not ambiguous
+		r = self.do_auth_request(response_type='code', prompt='none', id_token_hint=id_token, claims='{"id_token": {"sub": {"value": "10000"}}}')
+		args = self.validate_auth_response(r)
+		self.assertIn('code', args)
+		# sub value in id_token_hint and claims differs -> Ambiguous
+		r = self.do_auth_request(response_type='code', prompt='none', id_token_hint=id_token, claims='{"id_token": {"sub": {"value": "10001"}}}')
+		self.assert_auth_error(r, 'invalid_request')
+		self.login_as('admin')
+		r = self.do_auth_request(response_type='code', prompt='none', id_token_hint=id_token)
+		self.assert_auth_error(r, 'login_required')
+
+	def test_code_reuse(self):
+		self.login_as('user')
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r1 = self.do_token_request(grant_type='authorization_code', code=args['code'])
+		self.validate_token_response(r1)
+		r2 = self.do_token_request(grant_type='authorization_code', code=args['code'])
+		self.assert_token_error(r2, 'invalid_grant')
+
+	@unittest.skip('Token revoking on reuse is not implemented') # SHOULD
+	def test_code_reuse_revoke(self):
+		self.login_as('user')
+		r = self.do_auth_request(response_type='code')
+		args = self.validate_auth_response(r)
+		r1 = self.do_token_request(grant_type='authorization_code', code=args['code'])
+		self.validate_token_response(r1)
+		r2 = self.do_token_request(grant_type='authorization_code', code=args['code'])
+		self.assert_token_error(r2, 'invalid_grant')
+		r = self.do_userinfo_request(r1.json['access_token'])
+		self.assert_userinfo_error(r)
diff --git a/uffd/migrations/versions/01fdd7820f29_openid_connect_support.py b/uffd/migrations/versions/01fdd7820f29_openid_connect_support.py
new file mode 100644
index 0000000000000000000000000000000000000000..c7a97b2f0ba1659224e5b63a7a8785a86bbde89f
--- /dev/null
+++ b/uffd/migrations/versions/01fdd7820f29_openid_connect_support.py
@@ -0,0 +1,148 @@
+"""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')
diff --git a/uffd/models/__init__.py b/uffd/models/__init__.py
index 5fe24173b3ce2f5782e9fa089c5d21b50533833a..756a97212bc4b7d819700ddd1f313a481b81a1e3 100644
--- a/uffd/models/__init__.py
+++ b/uffd/models/__init__.py
@@ -2,7 +2,7 @@ from .api import APIClient
 from .invite import Invite, InviteGrant, InviteSignup
 from .mail import Mail, MailReceiveAddress, MailDestinationAddress
 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 .selfservice import PasswordToken
 from .service import RemailerMode, Service, ServiceUser, get_services
diff --git a/uffd/models/oauth2.py b/uffd/models/oauth2.py
index b79dd79286a17ce34a239e01b3c9701a78331243..e8fd0cdd0a79cf5669d13c9fcf6f3d2c065c228a 100644
--- a/uffd/models/oauth2.py
+++ b/uffd/models/oauth2.py
@@ -1,17 +1,25 @@
 import datetime
 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.ext.hybrid import hybrid_property
 from sqlalchemy.ext.associationproxy import association_proxy
+import jwt
 
 from uffd.database import db, CommaSeparatedList
 from uffd.tasks import cleanup_task
 from uffd.password_hash import PasswordHashAttribute, HighEntropyPasswordHash
+from uffd.utils import token_urlfriendly
 from .session import DeviceLoginInitiation, DeviceLoginType
 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):
 	__tablename__ = 'oauth2client'
 	# Inconsistently named "db_id" instead of "id" because of the naming conflict
@@ -28,17 +36,9 @@ class OAuth2Client(db.Model):
 	redirect_uris = association_proxy('_redirect_uris', 'uri')
 	logout_uris = relationship('OAuth2LogoutURI', cascade='all, delete-orphan')
 
-	@property
-	def client_type(self):
-		return 'confidential'
-
-	@property
-	def default_scopes(self):
-		return ['profile']
-
 	@property
 	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):
 		service_user = ServiceUser.query.get((self.service_id, user.id))
@@ -69,28 +69,74 @@ class OAuth2Grant(db.Model):
 	__tablename__ = 'oauth2grant'
 	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 = relationship('User')
 
 	client_db_id = Column(Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
 	client = relationship('OAuth2Client')
 
-	code = Column(String(255), index=True, nullable=False)
-	redirect_uri = Column(String(255), nullable=False)
-	expires = Column(DateTime, nullable=False, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(seconds=100))
+	_code = Column('code', String(255), nullable=False, default=token_urlfriendly)
+	code = property(lambda self: f'{self.id}-{self._code}')
+	redirect_uri = Column(String(255), nullable=True)
+	nonce = Column(Text(), nullable=True)
 	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
 	def expired(self):
 		if self.expires is None:
 			return False
 		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')
 class OAuth2Token(db.Model):
 	__tablename__ = 'oauth2token'
 	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 = relationship('User')
 
@@ -98,19 +144,46 @@ class OAuth2Token(db.Model):
 	client = relationship('OAuth2Client')
 
 	# currently only bearer is supported
-	token_type = Column(String(40), nullable=False)
-	access_token = Column(String(255), unique=True, nullable=False)
-	refresh_token = Column(String(255), unique=True, nullable=False)
-	expires = Column(DateTime, nullable=False)
+	token_type = Column(String(40), nullable=False, default='bearer')
+	_access_token = Column('access_token', String(255), unique=True, nullable=False, default=token_urlfriendly)
+	access_token = property(lambda self: f'{self.id}-{self._access_token}')
+	_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())
 
+	_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
 	def expired(self):
 		return self.expires < datetime.datetime.utcnow()
 
-	def set_expires_in_seconds(self, seconds):
-		self.expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds)
-	expires_in_seconds = property(fset=set_expires_in_seconds)
+	@classmethod
+	def get_by_access_token(cls, access_token):
+		# 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):
 	__mapper_args__ = {
@@ -122,3 +195,93 @@ class OAuth2DeviceLoginInitiation(DeviceLoginInitiation):
 	@property
 	def description(self):
 		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()))
diff --git a/uffd/models/service.py b/uffd/models/service.py
index 1bed5cfc4aa37a8ab473e739d3a79d68027c4760..c37f6b37322d482fd1a09f914924024aeb661f01 100644
--- a/uffd/models/service.py
+++ b/uffd/models/service.py
@@ -116,6 +116,16 @@ class ServiceUser(db.Model):
 			return remailer.build_v2_address(self.service_id, self.user_id)
 		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
 	def filter_query_by_email(cls, query, email):
 		'''Filter query of ServiceUser by ServiceUser.email'''
diff --git a/uffd/views/oauth2.py b/uffd/views/oauth2.py
index becb485a6409de77dfd23ba606fc378f43eb138c..f46305a12ba92eec0abd2a6b27d21ad34395ab1a 100644
--- a/uffd/views/oauth2.py
+++ b/uffd/views/oauth2.py
@@ -1,253 +1,490 @@
-import functools
-import secrets
 import urllib.parse
+import time
+import json
 
 from flask import Blueprint, request, jsonify, render_template, session, redirect, url_for, flash, abort
-import oauthlib.oauth2
 from flask_babel import gettext as _
 from sqlalchemy.exc import IntegrityError
+import jwt
 
 from uffd.secure_redirect import secure_local_redirect
 from uffd.database import db
-from uffd.models import DeviceLoginConfirmation, OAuth2Client, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation, host_ratelimit, format_delay, ServiceUser
-
-class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
-	# Argument "oauthreq" is named "request" in superclass but this clashes with flask's "request" object
-	# Arguments "token_value" and "token_data" are named "token" in superclass but this clashs with "token" endpoint
-	# pylint: disable=arguments-differ,arguments-renamed,unused-argument,too-many-public-methods,abstract-method
-
-	# In all cases (aside from validate_bearer_token), either validate_client_id or authenticate_client is called
-	# before anything else. authenticate_client_id would be called instead of authenticate_client for non-confidential
-	# clients. However, we don't support those.
-	def validate_client_id(self, client_id, oauthreq, *args, **kwargs):
-		oauthreq.client = OAuth2Client.query.filter_by(client_id=client_id).one_or_none()
-		return oauthreq.client is not None
-
-	def authenticate_client(self, oauthreq, *args, **kwargs):
-		authorization = oauthreq.extra_credentials.get('authorization')
-		if authorization:
-			# From RFC6749 2.3.1:
-			# Clients in possession of a client password MAY use the HTTP Basic authentication
-			# scheme as defined in [RFC2617] to authenticate with the authorization server.
-			# The client identifier is encoded using the "application/x-www-form-urlencoded"
-			# encoding algorithm per Appendix B, and the encoded value is used as the username
-			# the client password is encoded using the same algorithm and used as the password.
-			oauthreq.client_id = urllib.parse.unquote(authorization.username)
-			oauthreq.client_secret = urllib.parse.unquote(authorization.password)
-		if oauthreq.client_secret is None:
-			return False
-		oauthreq.client = OAuth2Client.query.filter_by(client_id=oauthreq.client_id).one_or_none()
-		if oauthreq.client is None:
-			return False
-		if not oauthreq.client.client_secret.verify(oauthreq.client_secret):
-			return False
-		if oauthreq.client.client_secret.needs_rehash:
-			oauthreq.client.client_secret = oauthreq.client_secret
-			db.session.commit()
-		return True
+from uffd.models import (
+	DeviceLoginConfirmation, OAuth2Client, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation,
+	host_ratelimit, format_delay, OAuth2Key,
+)
 
-	def get_default_redirect_uri(self, client_id, oauthreq, *args, **kwargs):
-		return oauthreq.client.default_redirect_uri
+def get_issuer():
+	return request.host_url.rstrip('/')
 
-	def validate_redirect_uri(self, client_id, redirect_uri, oauthreq, *args, **kwargs):
-		return redirect_uri in oauthreq.client.redirect_uris
+OIDC_SCOPES = {
+	# From https://openid.net/specs/openid-connect-core-1_0.html
+	'openid': {
+		# "The sub (subject) Claim MUST always be returned in the UserInfo Response."
+		'sub': None,
+	},
+	'profile': {
+		'name': None,
+		'family_name': None,
+		'given_name': None,
+		'middle_name': None,
+		'nickname': None,
+		'preferred_username': None,
+		'profile': None,
+		'picture': None,
+		'website': None,
+		'gender': None,
+		'birthdate': None,
+		'zoneinfo': None,
+		'locale': None,
+		'updated_at': None,
+	},
+	'email': {
+		'email': None,
+		'email_verified': None,
+	},
+	# Custom scopes
+	'groups': {
+		'groups': None,
+	},
+}
 
-	def validate_response_type(self, client_id, response_type, client, oauthreq, *args, **kwargs):
-		return response_type == 'code'
+OIDC_CLAIMS = {
+	'sub': lambda service_user: str(service_user.user.unix_uid),
+	'name': lambda service_user: service_user.user.displayname,
+	'preferred_username': lambda service_user: service_user.user.loginname,
+	'email': lambda service_user: service_user.email,
+	'email_verified': lambda service_user: service_user.email_verified,
+	# RFC 9068 registers the "groups" claim with a syntax taken from SCIM (RFC 7643)
+	# that is different from what we use here. The plain list of names syntax we use
+	# is far more common in the context of id_token/userinfo claims.
+	'groups': lambda service_user: [group.name for group in service_user.user.groups]
+}
 
-	def get_default_scopes(self, client_id, oauthreq, *args, **kwargs):
-		return oauthreq.client.default_scopes
+def render_claims(scopes, claims, service_user):
+	claims = dict(claims)
+	for scope in scopes:
+		claims.update(OIDC_SCOPES.get(scope, {}))
+	# This would be a good place to enforce permissions on available claims
+	res = {}
+	for claim, func in OIDC_CLAIMS.items():
+		if claim in claims:
+			res[claim] = func(service_user=service_user)
+	return res
 
-	def validate_scopes(self, client_id, scopes, client, oauthreq, *args, **kwargs):
-		if scopes == ['']:
-			oauthreq.scopes = scopes = self.get_default_scopes(client_id, oauthreq)
-		return set(scopes).issubset({'profile'})
+bp = Blueprint('oauth2', __name__, template_folder='templates')
 
-	def save_authorization_code(self, client_id, code, oauthreq, *args, **kwargs):
-		grant = OAuth2Grant(user=oauthreq.user, client=oauthreq.client, code=code['code'],
-		                    redirect_uri=oauthreq.redirect_uri, scopes=oauthreq.scopes)
-		db.session.add(grant)
-		db.session.commit()
-		# Oauthlib does not really provide a way to customize grant code generation.
-		# Actually `code` is created just before `save_authorization_code` is called
-		# and the same dict is later used to generate the OAuth2 response. So by
-		# modifing the `code` dict we can actually influence the grant code.
-		code['code'] = f"{grant.id}-{code['code']}"
-
-	def validate_code(self, client_id, code, client, oauthreq, *args, **kwargs):
-		if '-' not in code:
-			return False
-		grant_id, grant_code = code.split('-', 2)
-		oauthreq.grant = OAuth2Grant.query.get(grant_id)
-		if not oauthreq.grant or oauthreq.grant.client != client:
-			return False
-		if not secrets.compare_digest(oauthreq.grant.code, grant_code):
-			return False
-		if oauthreq.grant.expired:
-			return False
-		if oauthreq.grant.user.is_deactivated:
-			return False
-		oauthreq.user = oauthreq.grant.user
-		oauthreq.scopes = oauthreq.grant.scopes
-		return True
-
-	def invalidate_authorization_code(self, client_id, code, oauthreq, *args, **kwargs):
-		if '-' not in code:
-			return
-		grant_id, grant_code = code.split('-', 2)
-		grant = OAuth2Grant.query.get(grant_id)
-		if not grant or grant.client != oauthreq.client:
-			return
-		if not secrets.compare_digest(grant.code, grant_code):
-			return
-		db.session.delete(grant)
-		db.session.commit()
+@bp.route('/.well-known/openid-configuration')
+def discover():
+	return jsonify({
+		'issuer': get_issuer(),
+		'authorization_endpoint': url_for('oauth2.authorize', _external=True),
+		'token_endpoint': url_for('oauth2.token', _external=True),
+		'userinfo_endpoint': url_for('oauth2.userinfo', _external=True),
+		'jwks_uri': url_for('oauth2.keys', _external=True),
+		'scopes_supported': sorted(OIDC_SCOPES.keys()),
+		'response_types_supported': ['code'],
+		'grant_types_supported': ['authorization_code'],
+		'id_token_signing_alg_values_supported': OAuth2Key.get_available_algorithms(),
+		'subject_types_supported': ['public'],
+		'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'],
+		'claims_supported': sorted(['iat', 'exp', 'aud', 'iss'] + list(OIDC_CLAIMS.keys())),
+		'claims_parameter_supported': True,
+		'request_uri_parameter_supported': False, # default is True
+	})
 
-	def save_bearer_token(self, token_data, oauthreq, *args, **kwargs):
-		tok = OAuth2Token(
-			user=oauthreq.user,
-			client=oauthreq.client,
-			token_type=token_data['token_type'],
-			access_token=token_data['access_token'],
-			refresh_token=token_data['refresh_token'],
-			expires_in_seconds=token_data['expires_in'],
-			scopes=oauthreq.scopes
-		)
-		db.session.add(tok)
-		db.session.commit()
-		# Oauthlib does not really provide a way to customize access/refresh token
-		# generation. Actually `token_data` is created just before
-		# `save_bearer_token` is called and the same dict is later used to generate
-		# the OAuth2 response. So by modifing the `token_data` dict we can actually
-		# influence the tokens.
-		token_data['access_token'] = f"{tok.id}-{token_data['access_token']}"
-		token_data['refresh_token'] = f"{tok.id}-{token_data['refresh_token']}"
-		return oauthreq.client.default_redirect_uri
-
-	def validate_grant_type(self, client_id, grant_type, client, oauthreq, *args, **kwargs):
-		return grant_type == 'authorization_code'
-
-	def confirm_redirect_uri(self, client_id, code, redirect_uri, client, oauthreq, *args, **kwargs):
-		return redirect_uri == oauthreq.grant.redirect_uri
-
-	def validate_bearer_token(self, token_value, scopes, oauthreq):
-		if '-' not in token_value:
-			return False
-		tok_id, tok_secret = token_value.split('-', 2)
-		tok = OAuth2Token.query.get(tok_id)
-		if not tok or not secrets.compare_digest(tok.access_token, tok_secret):
-			return False
-		if tok.expired:
-			oauthreq.error_message = 'Token expired'
-			return False
-		if tok.user.is_deactivated:
-			oauthreq.error_message = 'User deactivated'
-			return False
-		if not set(scopes).issubset(tok.scopes):
-			oauthreq.error_message = 'Scopes invalid'
-			return False
-		oauthreq.access_token = tok
-		oauthreq.user = tok.user
-		oauthreq.scopes = scopes
-		oauthreq.client = tok.client
-		oauthreq.client_id = oauthreq.client.client_id
-		return True
-
-	# get_original_scopes/validate_refresh_token are only used for refreshing tokens. We don't implement the refresh endpoint.
-	# revoke_token is only used for revoking access tokens. We don't implement the revoke endpoint.
-	# get_id_token/validate_silent_authorization/validate_silent_login are OpenID Connect specfic.
-	# validate_user/validate_user_match are not required for Authorization Code Grant flow.
-
-validator = UffdRequestValidator()
-server = oauthlib.oauth2.WebApplicationServer(validator)
-bp = Blueprint('oauth2', __name__, url_prefix='/oauth2/', template_folder='templates')
-
-@bp.errorhandler(oauthlib.oauth2.rfc6749.errors.OAuth2Error)
-def handle_oauth2error(error):
-	return render_template('oauth2/error.html', error=type(error).__name__, error_description=error.description), 400
-
-@bp.route('/authorize', methods=['GET', 'POST'])
-def authorize():
-	scopes, credentials = server.validate_authorization_request(request.url, request.method, request.form, request.headers)
-	client = OAuth2Client.query.filter_by(client_id=credentials['client_id']).one()
+@bp.route('/oauth2/keys')
+def keys():
+	return jsonify({
+		'keys': [key.public_key_jwks_dict for key in OAuth2Key.query.filter_by(active=True)],
+	}), 200, {'Cache-Control': ['max-age=86400, public, must-revalidate, no-transform=true']}
+
+def oauth2_redirect(**extra_args):
+	urlparts = urllib.parse.urlparse(request.oauth2_redirect_uri)
+	args = urllib.parse.parse_qs(urlparts.query)
+	if 'state' in request.args:
+		args['state'] = request.args['state']
+	for key, value in extra_args.items():
+		if value is not None:
+			args[key] = [value]
+	return redirect(urlparts._replace(query=urllib.parse.urlencode(args, doseq=True)).geturl())
+
+class OAuth2Error(Exception):
+	ERROR: str
+
+	def __init__(self, error_description=None):
+		self.error_description = error_description
+
+	@property
+	def params(self):
+		res = {'error': self.ERROR}
+		if self.error_description:
+			res['error_description'] = self.error_description
+		return res
+
+# RFC 6749: OAuth 2.0
+class InvalidRequestError(OAuth2Error):
+	ERROR = 'invalid_request'
+
+class UnsupportedResponseTypeError(OAuth2Error):
+	ERROR = 'unsupported_response_type'
+
+class InvalidScopeError(OAuth2Error):
+	ERROR = 'invalid_scope'
+
+class InvalidClientError(OAuth2Error):
+	ERROR = 'invalid_client'
+
+class UnsupportedGrantTypeError(OAuth2Error):
+	ERROR = 'unsupported_grant_type'
+
+class InvalidGrantError(OAuth2Error):
+	ERROR = 'invalid_grant'
+
+class AccessDeniedError(OAuth2Error):
+	ERROR = 'access_denied'
+
+	def __init__(self, flash_message=None, **kwargs):
+		self.flash_message = flash_message
+		super().__init__(**kwargs)
+
+# RFC 6750: OAuth 2.0 Bearer Token Usage
+class InvalidTokenError(OAuth2Error):
+	ERROR = 'invalid_token'
+
+# OpenID Connect Core 1.0
+class LoginRequiredError(OAuth2Error):
+	ERROR = 'login_required'
+
+	def __init__(self, response=None, flash_message=None, **kwargs):
+		self.response = response
+		self.flash_message = flash_message
+		super().__init__(**kwargs)
+
+class RequestNotSupportedError(OAuth2Error):
+	ERROR = 'request_not_supported'
+
+class RequestURINotSupportedError(OAuth2Error):
+	ERROR = 'request_uri_not_supported'
+
+def authorize_validate_request():
+	request.oauth2_redirect_uri = None
+	for param in request.args:
+		if len(request.args.getlist(param)) > 1:
+			raise InvalidRequestError(error_description=f'Duplicate parameter {param}')
+
+	if 'client_id' not in request.args:
+		raise InvalidRequestError(error_description='Required parameter client_id missing')
+	client_id = request.args['client_id']
+	client = OAuth2Client.query.filter_by(client_id=client_id).one_or_none()
+	if not client:
+		raise InvalidRequestError(error_description=f'Unknown client {client_id}')
+
+	redirect_uri = request.args.get('redirect_uri')
+	if redirect_uri and redirect_uri not in client.redirect_uris:
+		raise InvalidRequestError(error_description='Invalid redirect_uri')
+	request.oauth2_redirect_uri = redirect_uri or client.default_redirect_uri
+	if not request.oauth2_redirect_uri:
+		raise InvalidRequestError(error_description='Parameter redirect_uri required')
+
+	if 'response_type' not in request.args:
+		raise InvalidRequestError(error_description='Required parameter response_type missing')
+	response_type = request.args['response_type']
+	if response_type != 'code':
+		raise UnsupportedResponseTypeError(error_description='Unsupported response type')
+
+	scopes = {scope for scope in request.args.get('scope', '').split(' ') if scope} or {'profile'}
+	if scopes == {'profile'}:
+		pass # valid plain OAuth2 scopes
+	elif 'openid' in scopes:
+		# OIDC core spec: "Scope values used that are not understood by an implementation SHOULD be ignored."
+		# Since we don't support some of the optional scope values defined by the
+		# spec (phone, address, offline_access), it's probably best to ignore all
+		# unknown scopes.
+		pass # valid  OIDC scopes
+	else:
+		raise InvalidScopeError(error_description='Unknown scope')
+
+	return OAuth2Grant(
+		client=client,
+		# redirect_uri is None if not present in request! This affects token request validation.
+		redirect_uri=redirect_uri,
+		scopes=scopes,
+	)
+
+def authorize_validate_request_oidc(grant):
+	nonce = request.args.get('nonce')
+	claims = json.loads(request.args['claims']) if 'claims' in request.args else None
 
+	if 'request' in request.args:
+		raise RequestNotSupportedError()
+	if 'request_uri' in request.args:
+		raise RequestURINotSupportedError()
+
+	prompt_values = {value for value in request.args.get('prompt', '').split(' ') if value}
+	if 'none' in prompt_values and prompt_values != {'none'}:
+		raise InvalidRequestError(error_description='Invalid usage of none prompt parameter value')
+
+	sub_value = None
+	if claims and claims.get('id_token', {}).get('sub', {}).get('value') is not None:
+		sub_value = claims['id_token']['sub']['value']
+	if 'id_token_hint' in request.args:
+		try:
+			id_token = OAuth2Key.decode_jwt(
+				request.args['id_token_hint'],
+				issuer=get_issuer(),
+				options={'verify_exp': False, 'verify_aud': False}
+			)
+		except (jwt.exceptions.InvalidTokenError, jwt.exceptions.InvalidKeyError) as err:
+			raise InvalidRequestError(error_description='Invalid id_token_hint value') from err
+		if sub_value is not None and id_token['sub'] != sub_value:
+			raise InvalidRequestError(error_description='Ambiguous sub values in claims and id_token_hint')
+		sub_value = id_token['sub']
+
+	# We "MUST only send a positive response if the End-User identified by that
+	# sub value has an active session with the Authorization Server or has been
+	# Authenticated as a result of the request". However, we currently cannot
+	# display the login page if there is already a valid session. So we can only
+	# support sub_value in combination with prompt=none for now.
+	if sub_value is not None and 'none' not in prompt_values:
+		raise InvalidRequestError(error_description='id_token_hint or sub claim value not supported without prompt=none')
+
+	grant.nonce = nonce
+	grant.claims = claims
+	return grant, sub_value, prompt_values
+
+def authorize_user(client):
 	if request.user:
-		credentials['user'] = request.user
-	elif 'devicelogin_started' in session:
+		return request.user
+
+	if 'devicelogin_started' in session:
 		del session['devicelogin_started']
 		host_delay = host_ratelimit.get_delay()
 		if host_delay:
-			flash(_('We received too many requests from your ip address/network! Please wait at least %(delay)s.', delay=format_delay(host_delay)))
-			return redirect(url_for('session.login', ref=request.full_path, devicelogin=True))
+			raise LoginRequiredError(
+				flash_message=_(
+					'We received too many requests from your ip address/network! Please wait at least %(delay)s.',
+					delay=format_delay(host_delay)
+				),
+				response=redirect(url_for('session.login', ref=request.full_path, devicelogin=True))
+			)
 		host_ratelimit.log()
 		initiation = OAuth2DeviceLoginInitiation(client=client)
 		db.session.add(initiation)
 		try:
 			db.session.commit()
-		except IntegrityError:
-			flash(_('Device login is currently not available. Try again later!'))
-			return redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True))
+		except IntegrityError as err:
+			raise LoginRequiredError(
+				flash_message=_('Device login is currently not available. Try again later!'),
+				response=redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True))
+			) from err
 		session['devicelogin_id'] = initiation.id
 		session['devicelogin_secret'] = initiation.secret
-		return redirect(url_for('session.devicelogin', ref=request.full_path))
-	elif 'devicelogin_id' in session and 'devicelogin_secret' in session and 'devicelogin_confirmation' in session:
-		initiation = OAuth2DeviceLoginInitiation.query.filter_by(id=session['devicelogin_id'], secret=session['devicelogin_secret'],
-		                                                         client=client).one_or_none()
+		raise LoginRequiredError(response=redirect(url_for('session.devicelogin', ref=request.full_path)))
+	if 'devicelogin_id' in session and 'devicelogin_secret' in session and 'devicelogin_confirmation' in session:
+		initiation = OAuth2DeviceLoginInitiation.query.filter_by(
+			id=session['devicelogin_id'],
+			secret=session['devicelogin_secret'],
+			client=client
+		).one_or_none()
 		confirmation = DeviceLoginConfirmation.query.get(session['devicelogin_confirmation'])
 		del session['devicelogin_id']
 		del session['devicelogin_secret']
 		del session['devicelogin_confirmation']
 		if not initiation or initiation.expired or not confirmation or confirmation.user.is_deactivated:
-			flash(_('Device login failed'))
-			return redirect(url_for('session.login', ref=request.full_path, devicelogin=True))
-		credentials['user'] = confirmation.user
+			raise LoginRequiredError(
+				flash_message=_('Device login failed'),
+				response=redirect(url_for('session.login', ref=request.full_path, devicelogin=True))
+			)
 		db.session.delete(initiation)
 		db.session.commit()
-	else:
-		flash(_('You need to login to access this service'))
-		return redirect(url_for('session.login', ref=request.full_path, devicelogin=True))
-
-	# Here we would normally ask the user, if he wants to give the requesting
-	# service access to his data. Since we only have trusted services (the
-	# clients defined in the server config), we don't ask for consent.
-	if not client.access_allowed(credentials['user']):
-		abort(403, description=_("You don't have the permission to access the service <b>%(service_name)s</b>.", service_name=client.service.name))
+		return confirmation.user
+
+	raise LoginRequiredError(
+		flash_message=_('You need to login to access this service'),
+		response=redirect(url_for('session.login', ref=request.full_path, devicelogin=True))
+	)
+
+@bp.route('/oauth2/authorize')
+def authorize():
+	is_oidc = 'openid' in request.args.get('scope', '').split(' ')
+
+	try:
+		grant = authorize_validate_request()
+		sub_value, prompt_values = None, []
+		if is_oidc:
+			grant, sub_value, prompt_values = authorize_validate_request_oidc(grant)
+	except OAuth2Error as err:
+		# Correct OAuth2/OIDC error handling would be to redirect back to the
+		# client with an error paramter, unless client_id or redirect_uri is
+		# invalid. However, uffd never did that before adding OIDC support and
+		# many applications fail to correctly handle this case. As a compromise
+		# we report errors correctly in OIDC mode and don't in plain OAuth2 mode.
+		if is_oidc and request.oauth2_redirect_uri:
+			return oauth2_redirect(**err.params)
+		return render_template('oauth2/error.html', **err.params), 400
+
+	try:
+		user = authorize_user(grant.client)
+		if sub_value is not None and str(user.unix_uid) != sub_value:
+			# We only reach this point in OIDC requests with prompt=none, see
+			# authorize_validate_request_oidc. So this LoginRequiredError is
+			# always returned as a redirect back to the client.
+			raise LoginRequiredError()
+		if not grant.client.access_allowed(user):
+			raise AccessDeniedError(flash_message=_(
+				"You don't have the permission to access the service <b>%(service_name)s</b>.",
+				service_name=grant.client.service.name
+			))
+		grant.user = user
+	except LoginRequiredError as err:
+		# We abuse LoginRequiredError to signal a redirect to the login page
+		if is_oidc and 'none' in prompt_values:
+			err.error_description = 'Login required but prompt value set to none'
+			return oauth2_redirect(**err.params)
+		if err.flash_message:
+			flash(err.flash_message)
+		return err.response
+	except AccessDeniedError as err:
+		if is_oidc and request.oauth2_redirect_uri:
+			return oauth2_redirect(**err.params)
+		abort(403, description=err.flash_message)
+
 	session['oauth2-clients'] = session.get('oauth2-clients', [])
-	if client.client_id not in session['oauth2-clients']:
-		session['oauth2-clients'].append(client.client_id)
+	if grant.client.client_id not in session['oauth2-clients']:
+		session['oauth2-clients'].append(grant.client.client_id)
+	db.session.add(grant)
+	db.session.commit()
+	return oauth2_redirect(code=grant.code)
 
-	headers, body, status = server.create_authorization_response(request.url, request.method, request.form, request.headers, scopes, credentials)
-	return body or '', status, headers
+def token_authenticate_client():
+	for param in ('client_id', 'client_secret'):
+		if len(request.form.getlist(param)) > 1:
+			raise InvalidRequestError(error_description=f'Duplicate parameter {param}')
+	if request.authorization:
+		client_id = urllib.parse.unquote(request.authorization.username)
+		client_secret = urllib.parse.unquote(request.authorization.password)
+		if request.form.get('client_id', client_id) != client_id:
+			raise InvalidRequestError(error_description='Ambiguous parameter client_id')
+		if 'client_secret' in request.form:
+			raise InvalidRequestError(error_description='Ambiguous parameter client_secret')
+	elif 'client_id' in request.form and 'client_secret' in request.form:
+		client_id = request.form['client_id']
+		client_secret = request.form['client_secret']
+	else:
+		raise InvalidClientError()
+
+	client = OAuth2Client.query.filter_by(client_id=client_id).one_or_none()
+	if client is None or not client.client_secret.verify(client_secret):
+		raise InvalidClientError()
+	if client.client_secret.needs_rehash:
+		client.client_secret = client_secret
+		db.session.commit()
+	return client
+
+def token_validate_request(client):
+	for param in ('grant_type', 'code', 'redirect_uri'):
+		if len(request.form.getlist(param)) > 1:
+			raise InvalidRequestError(error_description=f'Duplicate parameter {param}')
+	if 'grant_type' not in request.form:
+		raise InvalidRequestError(error_description='Parameter grant_type missing')
+	grant_type = request.form['grant_type']
+	if grant_type != 'authorization_code':
+		raise UnsupportedGrantTypeError()
+	if 'code' not in request.form:
+		raise InvalidRequestError(error_description='Parameter code missing')
+	code = request.form['code']
 
-@bp.route('/token', methods=['GET', 'POST'])
+	grant = OAuth2Grant.get_by_authorization_code(code)
+	if not grant or grant.client != client:
+		raise InvalidGrantError()
+	if grant.redirect_uri and grant.redirect_uri != request.form.get('redirect_uri'):
+		raise InvalidRequestError(error_description='Parameter redirect_uri missing or invalid')
+	return grant
+
+@bp.route('/oauth2/token', methods=['POST'])
 def token():
-	headers, body, status = server.create_token_response(request.url, request.method, request.form,
-	                                                     request.headers, {'authorization': request.authorization})
-	return body, status, headers
-
-def oauth_required(*scopes):
-	def wrapper(func):
-		@functools.wraps(func)
-		def decorator(*args, **kwargs):
-			valid, oauthreq = server.verify_request(request.url, request.method, request.form, request.headers, scopes)
-			if not valid:
-				abort(401)
-			request.oauth = oauthreq
-			return func(*args, **kwargs)
-		return decorator
-	return wrapper
-
-@bp.route('/userinfo')
-@oauth_required('profile')
+	try:
+		client = token_authenticate_client()
+		grant = token_validate_request(client)
+	except InvalidClientError as err:
+		return jsonify(err.params), 401, {'WWW-Authenticate': ['Basic realm="oauth2"']}
+	except OAuth2Error as err:
+		return jsonify(err.params), 400
+
+	tok = grant.make_token()
+	db.session.add(tok)
+	db.session.delete(grant)
+	db.session.commit()
+
+	resp = {
+		'token_type': 'Bearer',
+		'access_token': tok.access_token,
+		'expires_in': tok.EXPIRES_IN,
+		'scope': ' '.join(tok.scopes),
+	}
+	if 'openid' in tok.scopes:
+		key = OAuth2Key.get_preferred_key()
+		id_token = render_claims(['openid'], (grant.claims or {}).get('id_token', {}), tok.service_user)
+		id_token['iss'] = get_issuer()
+		id_token['aud'] = tok.client.client_id
+		id_token['iat'] = int(time.time())
+		id_token['at_hash'] = key.oidc_hash(tok.access_token.encode('ascii'))
+		id_token['exp'] = id_token['iat'] + tok.EXPIRES_IN
+		if grant.nonce:
+			id_token['nonce'] = grant.nonce
+		resp['id_token'] = OAuth2Key.get_preferred_key().encode_jwt(id_token)
+	else:
+		# We don't support the refresh_token grant type. Due to limitations of
+		# oauthlib we always returned (disfunctional) refresh tokens in the past.
+		# We still do that for non-OIDC clients to not change behavour for
+		# existing clients.
+		resp['refresh_token'] = tok.refresh_token
+
+	return jsonify(resp), 200, {'Cache-Control': ['no-store']}
+
+def validate_access_token():
+	if len(request.headers.getlist('Authorization')) == 1 and 'access_token' not in request.values:
+		auth_type, auth_value = (request.headers['Authorization'].split(' ', 1) + [''])[:2]
+		if auth_type.lower() != 'bearer':
+			raise InvalidRequestError()
+		access_token = auth_value
+	elif len(request.values.getlist('access_token')) == 1 and 'Authorization' not in request.headers:
+		access_token = request.values['access_token']
+	else:
+		raise InvalidClientError()
+	tok = OAuth2Token.get_by_access_token(access_token)
+	if not tok:
+		raise InvalidTokenError()
+	return tok
+
+@bp.route('/oauth2/userinfo', methods=['GET', 'POST'])
 def userinfo():
-	service_user = ServiceUser.query.get((request.oauth.client.service_id, request.oauth.user.id))
-	return jsonify(
-		id=service_user.user.unix_uid,
-		name=service_user.user.displayname,
-		nickname=service_user.user.loginname,
-		email=service_user.email,
-		groups=[group.name for group in service_user.user.groups]
-	)
+	try:
+		tok = validate_access_token()
+	except OAuth2Error as err:
+		# RFC 6750:
+		# If the request lacks any authentication information (e.g., the client
+		# was unaware that authentication is necessary or attempted using an
+		# unsupported authentication method), the resource server SHOULD NOT
+		# include an error code or other error information.
+		header = 'Bearer'
+		if request.headers.get('Authorization', '').lower().startswith('bearer') or 'access_token' in request.values:
+			header += f' error="{err.ERROR}"'
+		return '', 401, {'WWW-Authenticate': [header]}
+
+	service_user = tok.service_user
+	if 'openid' in tok.scopes:
+		resp = render_claims(tok.scopes, (tok.claims or {}).get('userinfo', {}), service_user)
+	else:
+		resp = {
+			'id': service_user.user.unix_uid,
+			'name': service_user.user.displayname,
+			'nickname': service_user.user.loginname,
+			'email': service_user.email,
+			'groups': [group.name for group in service_user.user.groups],
+		}
+	return jsonify(resp), 200, {'Cache-Control': ['private']}
 
 @bp.app_url_defaults
 def inject_logout_params(endpoint, values):
@@ -255,7 +492,7 @@ def inject_logout_params(endpoint, values):
 		return
 	values['client_ids'] = ','.join(session['oauth2-clients'])
 
-@bp.route('/logout')
+@bp.route('/oauth2/logout')
 def logout():
 	if not request.values.get('client_ids'):
 		return secure_local_redirect(request.values.get('ref', '/'))