Skip to content

feat: implement OIDC server for next-gen auth (MSC2965/2964/2966/2967)#342

Open
lytedev wants to merge 6 commits intomatrix-construct:mainfrom
lytedev:oidc-server
Open

feat: implement OIDC server for next-gen auth (MSC2965/2964/2966/2967)#342
lytedev wants to merge 6 commits intomatrix-construct:mainfrom
lytedev:oidc-server

Conversation

@lytedev
Copy link
Copy Markdown

@lytedev lytedev commented Feb 26, 2026

Implements a built-in OIDC authorization server that allows Matrix clients like Element X to authenticate via the next-gen auth flow (MSC2964). User authentication is delegated to upstream identity providers (e.g. Kanidm) through the existing SSO/OAuth client flow.

Endpoints

  • auth_issuer + auth_metadata discovery (stable v1 + unstable MSC2965)
  • OpenID Connect discovery (/.well-known/openid-configuration)
  • Dynamic Client Registration (MSC2966)
  • Authorization + token + revocation + JWKS + userinfo
  • SSO bridge: authorize → SSO redirect → complete → code → token

Features

  • ES256 (P-256) JWT signing with persistent key material
  • PKCE (S256) support with RFC 7636 verifier validation (43-128 chars, restricted charset)
  • Authorization code grant with refresh tokens
  • All OIDC state persisted in RocksDB (signing keys, client registrations, auth codes, pending auth requests)
  • Device ID extraction from MSC2967 scopes

Spec compliance fixes

  • OAuth error responses use RFC 6749 §5.2 format ({"error": "...", "error_description": "..."}) on token, registration, and revocation endpoints instead of Matrix {"errcode": "M_..."} format
  • PKCE code_verifier validation per RFC 7636 §4.1 (length and charset checks)
  • Scope token matching uses exact whitespace-delimited comparison per RFC 6749 §3.3 instead of substring matching
  • Typed ProviderMetadata struct for the discovery document with documented fields per RFC 8414 / OpenID Connect Discovery 1.0
  • DCR request/response includes policy_uri, tos_uri, software_id, software_version per RFC 7591

Refs: #246, #266

@lytedev lytedev marked this pull request as draft February 26, 2026 17:39
@jevolk jevolk added the feature New feature or functionality that didn't exist. label Feb 27, 2026
@Ludea
Copy link
Copy Markdown

Ludea commented Feb 27, 2026

@lytedev lytedev mentioned this pull request Mar 12, 2026
@lytedev
Copy link
Copy Markdown
Author

lytedev commented Mar 12, 2026

There is https://github.com/element-hq/matrix-authentication-service/tree/main/crates/cli which start MAS

Looks like that code is under an incompatible software license. I'm a big AGPL fan, but we can't use it here as far as I'm aware =(

@lytedev lytedev force-pushed the oidc-server branch 3 times, most recently from 1807530 to 22b5efc Compare March 12, 2026 16:40
@chbgdn
Copy link
Copy Markdown

chbgdn commented Mar 19, 2026

Working great. Added some fixes for Element X (it doesn't request openid scope, which is not mandatory) and implemented real client names (switched from hardcoded "OIDC Client" to the registered name with a fallback).

diff --git a/src/api/client/oidc.rs b/src/api/client/oidc.rs
index 73eaac2a..aac7881c 100644
--- a/src/api/client/oidc.rs
+++ b/src/api/client/oidc.rs
@@ -76,8 +76,6 @@ pub(crate) async fn authorize_route(State(services): State<crate::State>, reques
 
 	oidc.validate_redirect_uri(&params.client_id, &params.redirect_uri).await?;
 
-	if !scope_contains_token(&params.scope, "openid") { return Err!(Request(InvalidParam("openid scope is required"))); }
-
 	let req_id = utils::random_string(OIDC_REQ_ID_LENGTH);
 	let now = SystemTime::now();
 
@@ -147,8 +145,10 @@ async fn token_authorization_code(services: &tuwunel_service::Services, body: &T
 	let (access_token, expires_in) = services.users.generate_access_token(true);
 	let refresh_token = generate_refresh_token();
 
+	let client_name = oidc.get_client(&session.client_id).await.ok().and_then(|c| c.client_name).unwrap_or_else(|| "OIDC Client".to_owned());
+
 	let device_id: Option<OwnedDeviceId> = extract_device_id(&session.scope).map(OwnedDeviceId::from);
-	let device_id = services.users.create_device(user_id, device_id.as_deref(), (Some(&access_token), expires_in), Some(&refresh_token), Some("OIDC Client"), None).await?;
+	let device_id = services.users.create_device(user_id, device_id.as_deref(), (Some(&access_token), expires_in), Some(&refresh_token), Some(&client_name), None).await?;
 
 	info!("{user_id} logged in via OIDC (device {device_id})");

@jevolk
Copy link
Copy Markdown
Member

jevolk commented Mar 19, 2026

Thank you both! I'm excited to have a go at this soon.

@siennathesane
Copy link
Copy Markdown

siennathesane commented Mar 20, 2026

Tested this with Element X iOS against our production tuwunel instance (Hydra as the upstream IdP) — works great! Two small changes we needed:

  1. Remove hard openid scope requirement in authorize_route

Same fix @chbgdn posted above. Element X doesn't request the openid scope, which is optional per OIDC spec. The id_token generation in token_authorization_code already handles this correctly (only mints one when openid is in scope).

  -     if !scope_contains_token(&params.scope, "openid") { return Err!(Request(InvalidParam("openid scope is required"))); }
  -     let req_id = utils::random_string(OIDC_REQ_ID_LENGTH);
  1. Use DCR-registered client name as device display name

Without this, every OIDC session shows up as "OIDC Client" in the device list. With it, you get "Element X" (or whatever the client registered as via DCR).

        let device_id: Option<OwnedDeviceId> = extract_device_id(&session.scope).map(OwnedDeviceId::from);
  -     let device_id = services.users.create_device(user_id, device_id.as_deref(), (Some(&access_token), expires_in), Some(&refresh_token), Some("OIDC Client"), None).await?;
  +     let client_name = oidc.get_client(client_id).await.ok().and_then(|c| c.client_name);
  +     let device_display_name = client_name.as_deref().unwrap_or("OIDC Client");
  +     let device_id = services.users.create_device(user_id, device_id.as_deref(), (Some(&access_token), expires_in), Some(device_display_name), None).await?;

Thanks @lytedev and @chbgdn for the great work on this — was very straightforward to deploy.

//cc @lonnithelost

@DonPrus
Copy link
Copy Markdown

DonPrus commented Mar 22, 2026

Tested this locally on top of the PR, and these two changes were enough for me as well.

I brought up a local isolated OIDC-capable test setup and verified both paths against the real router/handlers:

  • /authorize now succeeds without openid in the requested scope
  • /token uses the DCR-registered client_name as the created device display name
  • no id_token is returned when openid is not in scope

Path is the same as @siennathesane mentioned

@shaba
Copy link
Copy Markdown

shaba commented Mar 24, 2026

Fixes OIDC loopback redirects for native clients like Fractal. Previously
validate_redirect_uri() required an exact string match, so callbacks like http://127.0.0.1:/ failed with M_INVALID_PARAM:
redirect_uri not registered for this client.

Now loopback HTTP redirect URIs on 127.0.0.1 / ::1 are matched ignoring only the port, while still requiring the rest of the URI to match. Non-loopback redirect URIs still use exact matching.
That makes the OIDC flow work for Fractal and other native apps using RFC 8252-style loopback callbacks, without loosening redirect validation for non-loopback URIs.


diff --git a/src/service/oauth/oidc_server.rs b/src/service/oauth/oidc_server.rs
index 7faac23eb..758fd0dfb 100644
--- a/src/service/oauth/oidc_server.rs
+++ b/src/service/oauth/oidc_server.rs
@@ -1,4 +1,4 @@
-use std::{sync::Arc, time::{Duration, SystemTime}};
+use std::{net::IpAddr, sync::Arc, time::{Duration, SystemTime}};
 
 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as b64};
 use ring::{rand::SystemRandom, signature::{self, EcdsaKeyPair, KeyPair}};
@@ -141,7 +141,11 @@ pub async fn get_client(&self, client_id: &str) -> Result<OidcClientRegistration
 
 	pub async fn validate_redirect_uri(&self, client_id: &str, redirect_uri: &str) -> Result {
 		let client = self.get_client(client_id).await?;
-		if client.redirect_uris.iter().any(|uri| uri == redirect_uri) { Ok(()) } else { Err!(Request(InvalidParam("redirect_uri not registered for this client"))) }
+		if client.redirect_uris.iter().any(|uri| redirect_uri_matches(uri, redirect_uri)) {
+			Ok(())
+		} else {
+			Err!(Request(InvalidParam("redirect_uri not registered for this client")))
+		}
 	}
 
 	pub fn store_auth_request(&self, req_id: &str, request: &OidcAuthRequest) { self.db.oidcreqid_authrequest.raw_put(req_id, Cbor(request)); }
@@ -215,3 +219,25 @@ pub fn sign_id_token(&self, claims: &IdTokenClaims) -> Result<String> {
 
 	#[must_use] pub fn auth_request_lifetime() -> Duration { AUTH_REQUEST_LIFETIME }
 }
+
+fn redirect_uri_matches(registered: &str, requested: &str) -> bool {
+	if registered == requested {
+		return true;
+	}
+
+	match (url::Url::parse(registered), url::Url::parse(requested)) {
+		(Ok(registered), Ok(requested)) if is_loopback_redirect_uri(&registered) && is_loopback_redirect_uri(&requested) => {
+			registered.scheme() == requested.scheme()
+				&& registered.host_str() == requested.host_str()
+				&& registered.path() == requested.path()
+				&& registered.query() == requested.query()
+				&& registered.fragment() == requested.fragment()
+		},
+		_ => false,
+	}
+}
+
+fn is_loopback_redirect_uri(uri: &url::Url) -> bool {
+	uri.scheme() == "http"
+		&& matches!(uri.host_str().and_then(|host| host.parse::<IpAddr>().ok()), Some(ip) if ip.is_loopback())

lytedev added a commit to lytedev/tuwunel that referenced this pull request Mar 25, 2026
- Remove hard openid scope requirement in authorize_route (Element X
  doesn't send it, and it's optional per OIDC spec)
- Use DCR-registered client name as device display name instead of
  hardcoded "OIDC Client"
- Support RFC 8252 loopback redirect URIs for native clients (Fractal)
  by matching ignoring port for http://127.0.0.1 and http://[::1]

Co-Authored-By: chbgdn
Co-Authored-By: siennathesane
Co-Authored-By: shaba
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
@lytedev
Copy link
Copy Markdown
Author

lytedev commented Mar 25, 2026

Thank you all for these fine additions! For the sake of simplicity and for updating my own deployment. I've gone ahead and included them in a new commit on this PR.

@jevolk
Copy link
Copy Markdown
Member

jevolk commented Mar 25, 2026

Would it be too much to ask if you could rebase on the latest main branch so we could get CI rolling here?

@lytedev
Copy link
Copy Markdown
Author

lytedev commented Mar 29, 2026

Not at all. Will do so later today!

dasha-uwu and others added 4 commits March 29, 2026 13:41
Implements a built-in OIDC authorization server that allows Matrix clients
like Element X to authenticate via OIDC, delegating user authentication
to upstream identity providers (e.g. Kanidm) through the existing SSO flow.

## Endpoints
- GET /_matrix/client/unstable/org.matrix.msc2965/auth_issuer
- GET /.well-known/openid-configuration
- POST /_tuwunel/oidc/registration (Dynamic Client Registration)
- GET /_tuwunel/oidc/authorize → SSO redirect → _complete bridge
- POST /_tuwunel/oidc/token (auth code exchange + refresh)
- POST /_tuwunel/oidc/revoke
- GET /_tuwunel/oidc/jwks
- GET /_tuwunel/oidc/userinfo
- GET /_tuwunel/oidc/account (placeholder)

## Spec compliance fixes
- OAuth error responses use RFC 6749 §5.2 format ({"error": "...", "error_description": "..."})
- PKCE code_verifier validation per RFC 7636 §4.1
- Scope token matching uses exact whitespace-delimited comparison per RFC 6749 §3.3
- Typed ProviderMetadata struct for the discovery document
- DCR includes policy_uri, tos_uri, software_id, software_version per RFC 7591

Refs: matrix-construct#246, matrix-construct#266
… sync

- Add policy_uri, tos_uri, software_id, software_version to DCR per RFC 7591
- Add code_verifier length (43-128) and charset validation per RFC 7636 §4.1
- Warn at startup if OIDC server enabled without identity providers
- Include Cargo.lock update for ring dependency
- Remove hard openid scope requirement in authorize_route (Element X
  doesn't send it, and it's optional per OIDC spec)
- Use DCR-registered client name as device display name instead of
  hardcoded "OIDC Client"
- Support RFC 8252 loopback redirect URIs for native clients (Fractal)
  by matching ignoring port for http://127.0.0.1 and http://[::1]

Co-Authored-By: chbgdn
Co-Authored-By: siennathesane
Co-Authored-By: shaba
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
@lytedev lytedev marked this pull request as ready for review March 29, 2026 18:57
@lytedev
Copy link
Copy Markdown
Author

lytedev commented Mar 29, 2026

Rebased!

chbgdn pushed a commit to chbgdn/tuwunel that referenced this pull request Apr 3, 2026
- Remove hard openid scope requirement in authorize_route (Element X
  doesn't send it, and it's optional per OIDC spec)
- Use DCR-registered client name as device display name instead of
  hardcoded "OIDC Client"
- Support RFC 8252 loopback redirect URIs for native clients (Fractal)
  by matching ignoring port for http://127.0.0.1 and http://[::1]

Co-Authored-By: chbgdn
Co-Authored-By: siennathesane
Co-Authored-By: shaba
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
@chbgdn
Copy link
Copy Markdown

chbgdn commented Apr 3, 2026

@lytedev Since account/device management panel aren't implemented yet, the stub HTML page is pretty useless. Instead of returning a "not implemented" message, we could just redirect to the IdP's issuer_url where users can actually manage their account.

Patch:

diff --git a/src/api/client/oidc.rs b/src/api/client/oidc.rs
--- a/src/api/client/oidc.rs
+++ b/src/api/client/oidc.rs
@@ -452,11 +452,30 @@ pub(crate) async fn userinfo_route(
 	})))
 }
 
-pub(crate) async fn account_route() -> impl IntoResponse {
-	axum::response::Html(
-		"<html><body><h1>Account Management</h1><p>Account management is not yet implemented. \
-		 Please use your identity provider to manage your account.</p></body></html>",
-	)
+pub(crate) async fn account_route(
+	State(services): State<crate::State>,
+) -> Result<impl IntoResponse> {
+	// Redirect to the identity provider's panel where users can manage
+	// their account, sessions, devices, and profile.
+	let idp = services
+		.config
+		.identity_provider
+		.values()
+		.find(|idp| idp.default)
+		.or_else(|| services.config.identity_provider.values().next())
+		.ok_or_else(|| err!(Config("identity_provider", "No identity provider configured")))?;
+
+	let panel_url = idp
+		.issuer_url
+		.as_ref()
+		.ok_or_else(|| {
+			err!(Config(
+				"issuer_url",
+				"issuer_url is required for account management redirect"
+			))
+		})?;
+
+	Ok(Redirect::temporary(panel_url.as_str()))
 }

@jevolk jevolk self-assigned this Apr 3, 2026
jevolk and others added 2 commits April 3, 2026 20:21
Signed-off-by: Jason Volk <jason@zemos.net>
…r account.

Signed-off-by: Jason Volk <jason@zemos.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or functionality that didn't exist.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants