Skip to content

Commit 1807530

Browse files
dasha-uwulytedev
authored andcommitted
feat: implement OIDC server for next-gen auth (MSC2965/2964/2966/2967)
Implements a built-in OIDC authorization server that allows Matrix clients like Element X to authenticate via the next-gen auth flow (MSC2964). 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 - Authorization code grant with refresh tokens - All OIDC state persisted in RocksDB Refs: #246, #266
1 parent 0381547 commit 1807530

File tree

8 files changed

+481
-3
lines changed

8 files changed

+481
-3
lines changed

src/api/client/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub(super) mod media_legacy;
1616
pub(super) mod membership;
1717
pub(super) mod message;
1818
pub(super) mod openid;
19+
pub(super) mod oidc;
1920
pub(super) mod presence;
2021
pub(super) mod profile;
2122
pub(super) mod push;
@@ -63,6 +64,7 @@ pub(super) use media_legacy::*;
6364
pub(super) use membership::*;
6465
pub(super) use message::*;
6566
pub(super) use openid::*;
67+
pub(super) use oidc::*;
6668
pub(super) use presence::*;
6769
pub(super) use profile::*;
6870
pub(super) use push::*;

src/api/client/oidc.rs

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
use std::time::SystemTime;
2+
3+
use axum::{Json, extract::State, response::{IntoResponse, Redirect}};
4+
use axum_extra::{TypedHeader, headers::{Authorization, authorization::Bearer}};
5+
use http::StatusCode;
6+
use ruma::OwnedDeviceId;
7+
use serde::{Deserialize, Serialize};
8+
use tuwunel_core::{Err, Result, err, info, utils};
9+
use tuwunel_service::{oauth::oidc_server::{DcrRequest, IdTokenClaims, OidcAuthRequest, OidcServer, ProviderMetadata}, users::device::generate_refresh_token};
10+
11+
const OIDC_REQ_ID_LENGTH: usize = 32;
12+
13+
#[derive(Serialize)]
14+
struct AuthIssuerResponse { issuer: String }
15+
16+
pub(crate) async fn auth_issuer_route(State(services): State<crate::State>) -> Result<impl IntoResponse> {
17+
let issuer = oidc_issuer_url(&services)?;
18+
Ok(Json(AuthIssuerResponse { issuer }))
19+
}
20+
21+
pub(crate) async fn openid_configuration_route(State(services): State<crate::State>) -> Result<impl IntoResponse> {
22+
Ok(Json(oidc_metadata(&services)?))
23+
}
24+
25+
fn oidc_metadata(services: &tuwunel_service::Services) -> Result<ProviderMetadata> {
26+
let issuer = oidc_issuer_url(services)?;
27+
let base = issuer.trim_end_matches('/').to_owned();
28+
29+
Ok(ProviderMetadata {
30+
issuer,
31+
authorization_endpoint: format!("{base}/_tuwunel/oidc/authorize"),
32+
token_endpoint: format!("{base}/_tuwunel/oidc/token"),
33+
registration_endpoint: Some(format!("{base}/_tuwunel/oidc/registration")),
34+
revocation_endpoint: Some(format!("{base}/_tuwunel/oidc/revoke")),
35+
jwks_uri: format!("{base}/_tuwunel/oidc/jwks"),
36+
userinfo_endpoint: Some(format!("{base}/_tuwunel/oidc/userinfo")),
37+
account_management_uri: Some(format!("{base}/_tuwunel/oidc/account")),
38+
account_management_actions_supported: Some(vec!["org.matrix.profile".to_owned(), "org.matrix.sessions_list".to_owned(), "org.matrix.session_view".to_owned(), "org.matrix.session_end".to_owned(), "org.matrix.cross_signing_reset".to_owned()]),
39+
response_types_supported: vec!["code".to_owned()],
40+
response_modes_supported: Some(vec!["query".to_owned(), "fragment".to_owned()]),
41+
grant_types_supported: Some(vec!["authorization_code".to_owned(), "refresh_token".to_owned()]),
42+
code_challenge_methods_supported: Some(vec!["S256".to_owned()]),
43+
token_endpoint_auth_methods_supported: Some(vec!["none".to_owned(), "client_secret_basic".to_owned(), "client_secret_post".to_owned()]),
44+
scopes_supported: Some(vec!["openid".to_owned(), "urn:matrix:org.matrix.msc2967.client:api:*".to_owned(), "urn:matrix:org.matrix.msc2967.client:device:*".to_owned()]),
45+
subject_types_supported: Some(vec!["public".to_owned()]),
46+
id_token_signing_alg_values_supported: Some(vec!["ES256".to_owned()]),
47+
prompt_values_supported: Some(vec!["create".to_owned()]),
48+
claim_types_supported: Some(vec!["normal".to_owned()]),
49+
claims_supported: Some(vec!["iss".to_owned(), "sub".to_owned(), "aud".to_owned(), "exp".to_owned(), "iat".to_owned(), "nonce".to_owned()]),
50+
})
51+
}
52+
53+
pub(crate) async fn registration_route(State(services): State<crate::State>, Json(body): Json<DcrRequest>) -> Result<impl IntoResponse> {
54+
let Ok(oidc) = get_oidc_server(&services) else { return Err!(Request(NotFound("OIDC server not configured"))); };
55+
56+
if body.redirect_uris.is_empty() { return Err!(Request(InvalidParam("redirect_uris must not be empty"))); }
57+
58+
let reg = oidc.register_client(body)?;
59+
info!("OIDC client registered: {} ({})", reg.client_id, reg.client_name.as_deref().unwrap_or("unnamed"));
60+
61+
Ok((StatusCode::CREATED, Json(serde_json::json!({"client_id": reg.client_id, "client_id_issued_at": reg.registered_at, "redirect_uris": reg.redirect_uris, "client_name": reg.client_name, "client_uri": reg.client_uri, "logo_uri": reg.logo_uri, "contacts": reg.contacts, "token_endpoint_auth_method": reg.token_endpoint_auth_method, "grant_types": reg.grant_types, "response_types": reg.response_types, "application_type": reg.application_type}))))
62+
}
63+
64+
#[derive(Debug, Deserialize)]
65+
pub(crate) struct AuthorizeParams {
66+
client_id: String, redirect_uri: String, response_type: String, scope: String,
67+
state: Option<String>, nonce: Option<String>, code_challenge: Option<String>,
68+
code_challenge_method: Option<String>, #[serde(default, rename = "prompt")] _prompt: Option<String>,
69+
}
70+
71+
pub(crate) async fn authorize_route(State(services): State<crate::State>, request: axum::extract::Request) -> Result<impl IntoResponse> {
72+
let params: AuthorizeParams = serde_html_form::from_str(request.uri().query().unwrap_or_default())?;
73+
let Ok(oidc) = get_oidc_server(&services) else { return Err!(Request(NotFound("OIDC server not configured"))); };
74+
75+
if params.response_type != "code" { return Err!(Request(InvalidParam("Only response_type=code is supported"))); }
76+
77+
oidc.validate_redirect_uri(&params.client_id, &params.redirect_uri).await?;
78+
79+
if !scope_contains_token(&params.scope, "openid") { return Err!(Request(InvalidParam("openid scope is required"))); }
80+
81+
let req_id = utils::random_string(OIDC_REQ_ID_LENGTH);
82+
let now = SystemTime::now();
83+
84+
oidc.store_auth_request(&req_id, &OidcAuthRequest {
85+
client_id: params.client_id, redirect_uri: params.redirect_uri, scope: params.scope,
86+
state: params.state, nonce: params.nonce, code_challenge: params.code_challenge,
87+
code_challenge_method: params.code_challenge_method, created_at: now,
88+
expires_at: now.checked_add(OidcServer::auth_request_lifetime()).unwrap_or(now),
89+
});
90+
91+
let default_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")))?;
92+
let idp_id = default_idp.id();
93+
94+
let base = oidc_issuer_url(&services)?;
95+
let base = base.trim_end_matches('/');
96+
97+
let mut complete_url = url::Url::parse(&format!("{base}/_tuwunel/oidc/_complete")).map_err(|_| err!(error!("Failed to build complete URL")))?;
98+
complete_url.query_pairs_mut().append_pair("oidc_req_id", &req_id);
99+
100+
let mut sso_url = url::Url::parse(&format!("{base}/_matrix/client/v3/login/sso/redirect/{idp_id}")).map_err(|_| err!(error!("Failed to build SSO URL")))?;
101+
sso_url.query_pairs_mut().append_pair("redirectUrl", complete_url.as_str());
102+
103+
Ok(Redirect::temporary(sso_url.as_str()))
104+
}
105+
106+
#[derive(Debug, Deserialize)]
107+
pub(crate) struct CompleteParams { oidc_req_id: String, #[serde(rename = "loginToken")] login_token: String }
108+
109+
pub(crate) async fn complete_route(State(services): State<crate::State>, request: axum::extract::Request) -> Result<impl IntoResponse> {
110+
let params: CompleteParams = serde_html_form::from_str(request.uri().query().unwrap_or_default())?;
111+
let Ok(oidc) = get_oidc_server(&services) else { return Err!(Request(NotFound("OIDC server not configured"))); };
112+
113+
let user_id = services.users.find_from_login_token(&params.login_token).await.map_err(|_| err!(Request(Forbidden("Invalid or expired login token"))))?;
114+
let auth_req = oidc.take_auth_request(&params.oidc_req_id).await?;
115+
let code = oidc.create_auth_code(&auth_req, user_id);
116+
117+
let mut redirect_url = url::Url::parse(&auth_req.redirect_uri).map_err(|_| err!(Request(InvalidParam("Invalid redirect_uri"))))?;
118+
redirect_url.query_pairs_mut().append_pair("code", &code);
119+
if let Some(state) = &auth_req.state { redirect_url.query_pairs_mut().append_pair("state", state); }
120+
121+
Ok(Redirect::temporary(redirect_url.as_str()))
122+
}
123+
124+
#[derive(Debug, Deserialize)]
125+
pub(crate) struct TokenRequest {
126+
grant_type: String, code: Option<String>, redirect_uri: Option<String>, client_id: Option<String>,
127+
code_verifier: Option<String>, refresh_token: Option<String>, #[serde(rename = "scope")] _scope: Option<String>,
128+
}
129+
130+
pub(crate) async fn token_route(State(services): State<crate::State>, axum::extract::Form(body): axum::extract::Form<TokenRequest>) -> impl IntoResponse {
131+
match body.grant_type.as_str() {
132+
| "authorization_code" => token_authorization_code(&services, &body).await.unwrap_or_else(|e| oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string())),
133+
| "refresh_token" => token_refresh(&services, &body).await.unwrap_or_else(|e| oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string())),
134+
| _ => oauth_error(StatusCode::BAD_REQUEST, "unsupported_grant_type", "Unsupported grant_type"),
135+
}
136+
}
137+
138+
async fn token_authorization_code(services: &tuwunel_service::Services, body: &TokenRequest) -> Result<http::Response<axum::body::Body>> {
139+
let code = body.code.as_deref().ok_or_else(|| err!(Request(InvalidParam("code is required"))))?;
140+
let redirect_uri = body.redirect_uri.as_deref().ok_or_else(|| err!(Request(InvalidParam("redirect_uri is required"))))?;
141+
let client_id = body.client_id.as_deref().ok_or_else(|| err!(Request(InvalidParam("client_id is required"))))?;
142+
143+
let oidc = get_oidc_server(services)?;
144+
let session = oidc.exchange_auth_code(code, client_id, redirect_uri, body.code_verifier.as_deref()).await?;
145+
146+
let user_id = &session.user_id;
147+
let (access_token, expires_in) = services.users.generate_access_token(true);
148+
let refresh_token = generate_refresh_token();
149+
150+
let device_id: Option<OwnedDeviceId> = extract_device_id(&session.scope).map(OwnedDeviceId::from);
151+
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?;
152+
153+
info!("{user_id} logged in via OIDC (device {device_id})");
154+
155+
let id_token = if session.scope.contains("openid") {
156+
let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_secs();
157+
let issuer = oidc_issuer_url(services)?;
158+
let claims = IdTokenClaims { iss: issuer, sub: user_id.to_string(), aud: client_id.to_owned(), exp: now.saturating_add(3600), iat: now, nonce: session.nonce, at_hash: Some(OidcServer::at_hash(&access_token)) };
159+
Some(oidc.sign_id_token(&claims)?)
160+
} else { None };
161+
162+
let mut response = serde_json::json!({"access_token": access_token, "token_type": "Bearer", "scope": session.scope, "refresh_token": refresh_token});
163+
if let Some(expires_in) = expires_in { response["expires_in"] = serde_json::json!(expires_in.as_secs()); }
164+
if let Some(id_token) = id_token { response["id_token"] = serde_json::json!(id_token); }
165+
166+
Ok(Json(response).into_response())
167+
}
168+
169+
async fn token_refresh(services: &tuwunel_service::Services, body: &TokenRequest) -> Result<http::Response<axum::body::Body>> {
170+
let refresh_token = body.refresh_token.as_deref().ok_or_else(|| err!(Request(InvalidParam("refresh_token is required"))))?;
171+
let (user_id, device_id, _) = services.users.find_from_token(refresh_token).await.map_err(|_| err!(Request(Forbidden("Invalid refresh token"))))?;
172+
173+
let (new_access_token, expires_in) = services.users.generate_access_token(true);
174+
let new_refresh_token = generate_refresh_token();
175+
services.users.set_access_token(&user_id, &device_id, &new_access_token, expires_in, Some(&new_refresh_token)).await?;
176+
177+
let mut response = serde_json::json!({"access_token": new_access_token, "token_type": "Bearer", "refresh_token": new_refresh_token});
178+
if let Some(expires_in) = expires_in { response["expires_in"] = serde_json::json!(expires_in.as_secs()); }
179+
180+
Ok(Json(response).into_response())
181+
}
182+
183+
#[derive(Debug, Deserialize)]
184+
pub(crate) struct RevokeRequest { token: String, #[serde(default, rename = "token_type_hint")] _token_type_hint: Option<String> }
185+
186+
pub(crate) async fn revoke_route(State(services): State<crate::State>, axum::extract::Form(body): axum::extract::Form<RevokeRequest>) -> Result<impl IntoResponse> {
187+
if let Ok((user_id, device_id, _)) = services.users.find_from_token(&body.token).await { services.users.remove_device(&user_id, &device_id).await; }
188+
Ok(Json(serde_json::json!({})))
189+
}
190+
191+
pub(crate) async fn jwks_route(State(services): State<crate::State>) -> Result<impl IntoResponse> {
192+
let oidc = get_oidc_server(&services)?;
193+
Ok(Json(oidc.jwks()))
194+
}
195+
196+
pub(crate) async fn userinfo_route(State(services): State<crate::State>, TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>) -> Result<impl IntoResponse> {
197+
let token = bearer.token();
198+
let Ok((user_id, _device_id, _expires)) = services.users.find_from_token(token).await else { return Err!(Request(Unauthorized("Invalid access token"))); };
199+
let displayname = services.users.displayname(&user_id).await.ok();
200+
let avatar_url = services.users.avatar_url(&user_id).await.ok();
201+
Ok(Json(serde_json::json!({"sub": user_id.to_string(), "name": displayname, "picture": avatar_url})))
202+
}
203+
204+
pub(crate) async fn account_route() -> impl IntoResponse {
205+
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>")
206+
}
207+
208+
fn oauth_error(status: StatusCode, error: &str, description: &str) -> http::Response<axum::body::Body> {
209+
(status, Json(serde_json::json!({"error": error, "error_description": description}))).into_response()
210+
}
211+
212+
fn scope_contains_token(scope: &str, token: &str) -> bool { scope.split_whitespace().any(|t| t == token) }
213+
214+
fn get_oidc_server(services: &tuwunel_service::Services) -> Result<&OidcServer> {
215+
services.oauth.oidc_server.as_deref().ok_or_else(|| err!(Request(NotFound("OIDC server not configured"))))
216+
}
217+
218+
fn oidc_issuer_url(services: &tuwunel_service::Services) -> Result<String> {
219+
services.config.well_known.client.as_ref().map(|url| { let s = url.to_string(); if s.ends_with('/') { s } else { s + "/" } }).ok_or_else(|| err!(Config("well_known.client", "well_known.client must be set for OIDC server")))
220+
}
221+
222+
fn extract_device_id(scope: &str) -> Option<String> { scope.split_whitespace().find_map(|s| s.strip_prefix("urn:matrix:org.matrix.msc2967.client:device:")).map(ToOwned::to_owned) }

src/api/client/versions.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ static VERSIONS: [&str; 17] = [
5151
"v1.15", /* custom profile fields */
5252
];
5353

54-
static UNSTABLE_FEATURES: [&str; 18] = [
54+
static UNSTABLE_FEATURES: [&str; 22] = [
5555
"org.matrix.e2e_cross_signing",
5656
// private read receipts (https://github.com/matrix-org/matrix-spec-proposals/pull/2285)
5757
"org.matrix.msc2285.stable",
@@ -86,4 +86,12 @@ static UNSTABLE_FEATURES: [&str; 18] = [
8686
"org.matrix.simplified_msc3575",
8787
// Allow room moderators to view redacted event content (https://github.com/matrix-org/matrix-spec-proposals/pull/2815)
8888
"fi.mau.msc2815",
89+
// OIDC-native auth: authorization code grant (https://github.com/matrix-org/matrix-spec-proposals/pull/2964)
90+
"org.matrix.msc2964",
91+
// OIDC-native auth: auth issuer discovery (https://github.com/matrix-org/matrix-spec-proposals/pull/2965)
92+
"org.matrix.msc2965",
93+
// OIDC-native auth: dynamic client registration (https://github.com/matrix-org/matrix-spec-proposals/pull/2966)
94+
"org.matrix.msc2966",
95+
// OIDC-native auth: API scopes (https://github.com/matrix-org/matrix-spec-proposals/pull/2967)
96+
"org.matrix.msc2967",
8997
];

src/api/router.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,20 @@ pub fn build(router: Router<State>, server: &Server) -> Router<State> {
195195
.ruma_route(&client::well_known_support)
196196
.ruma_route(&client::well_known_client)
197197
.route("/_tuwunel/server_version", get(client::tuwunel_server_version))
198+
// OIDC server endpoints (next-gen auth, MSC2965/2964/2966/2967)
199+
.route("/_matrix/client/unstable/org.matrix.msc2965/auth_issuer", get(client::auth_issuer_route))
200+
.route("/_matrix/client/v1/auth_issuer", get(client::auth_issuer_route))
201+
.route("/_matrix/client/unstable/org.matrix.msc2965/auth_metadata", get(client::openid_configuration_route))
202+
.route("/_matrix/client/v1/auth_metadata", get(client::openid_configuration_route))
203+
.route("/.well-known/openid-configuration", get(client::openid_configuration_route))
204+
.route("/_tuwunel/oidc/registration", post(client::registration_route))
205+
.route("/_tuwunel/oidc/authorize", get(client::authorize_route))
206+
.route("/_tuwunel/oidc/_complete", get(client::complete_route))
207+
.route("/_tuwunel/oidc/token", post(client::token_route))
208+
.route("/_tuwunel/oidc/revoke", post(client::revoke_route))
209+
.route("/_tuwunel/oidc/jwks", get(client::jwks_route))
210+
.route("/_tuwunel/oidc/userinfo", get(client::userinfo_route))
211+
.route("/_tuwunel/oidc/account", get(client::account_route))
198212
.ruma_route(&client::room_initial_sync_route)
199213
.route("/client/server.json", get(client::syncv3_client_server_json));
200214

src/database/maps.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ pub(super) static MAPS: &[Descriptor] = &[
138138
name: "oauthuniqid_oauthid",
139139
..descriptor::RANDOM_SMALL
140140
},
141+
Descriptor {
142+
name: "oidc_signingkey",
143+
..descriptor::RANDOM_SMALL
144+
},
145+
Descriptor {
146+
name: "oidcclientid_registration",
147+
..descriptor::RANDOM_SMALL
148+
},
149+
Descriptor {
150+
name: "oidccode_authsession",
151+
..descriptor::RANDOM_SMALL
152+
},
153+
Descriptor {
154+
name: "oidcreqid_authrequest",
155+
..descriptor::RANDOM_SMALL
156+
},
141157
Descriptor {
142158
name: "onetimekeyid_onetimekeys",
143159
..descriptor::RANDOM_SMALL

src/service/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ lru-cache.workspace = true
104104
rand.workspace = true
105105
regex.workspace = true
106106
reqwest.workspace = true
107+
ring.workspace = true
107108
ruma.workspace = true
108109
rustls.workspace = true
109110
rustyline-async.workspace = true

src/service/oauth/mod.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod oidc_server;
12
pub mod providers;
23
pub mod sessions;
34
pub mod user_info;
@@ -14,13 +15,14 @@ use ruma::UserId;
1415
use serde::Serialize;
1516
use serde_json::Value as JsonValue;
1617
use tuwunel_core::{
17-
Err, Result, err, implement,
18+
Err, Result, err, implement, info,
1819
utils::{hash::sha256, result::LogErr, stream::ReadyExt},
1920
};
2021
use url::Url;
2122

22-
use self::{providers::Providers, sessions::Sessions};
23+
use self::{oidc_server::OidcServer, providers::Providers, sessions::Sessions};
2324
pub use self::{
25+
oidc_server::ProviderMetadata,
2426
providers::{Provider, ProviderId},
2527
sessions::{CODE_VERIFIER_LENGTH, SESSION_ID_LENGTH, Session, SessionId},
2628
user_info::UserInfo,
@@ -31,16 +33,30 @@ pub struct Service {
3133
services: SelfServices,
3234
pub providers: Arc<Providers>,
3335
pub sessions: Arc<Sessions>,
36+
/// OIDC server (authorization server) for next-gen Matrix auth.
37+
/// Only initialized when identity providers are configured.
38+
pub oidc_server: Option<Arc<OidcServer>>,
3439
}
3540

3641
impl crate::Service for Service {
3742
fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
3843
let providers = Arc::new(Providers::build(args));
3944
let sessions = Arc::new(Sessions::build(args, providers.clone()));
45+
46+
let oidc_server = if !args.server.config.identity_provider.is_empty()
47+
|| args.server.config.well_known.client.is_some()
48+
{
49+
info!("Initializing OIDC server for next-gen auth (MSC2965)");
50+
Some(Arc::new(OidcServer::build(args)?))
51+
} else {
52+
None
53+
};
54+
4055
Ok(Arc::new(Self {
4156
services: args.services.clone(),
4257
sessions,
4358
providers,
59+
oidc_server,
4460
}))
4561
}
4662

0 commit comments

Comments
 (0)