feat: Initial implementation of OIDC

This commit is contained in:
Ginger committed 2026-07-02 16:34:38 -04:00
1 parent 69163b5391
commit c0cb4afd8a
32 files changed
+1300 -63

No files matched your search

+1
View File
@@ -49,6 +49,7 @@ url.workspace = true
recaptcha-verify = { version = "0.2.0", default-features = false }
reqwest_recaptcha = { package = "reqwest", version = "0.12.28", default-features = false, features = ["rustls-tls-native-roots-no-provider"] } # As long as recaptcha-verify's reqwest is outdated
form_urlencoded = "1.2.2"
openidconnect.workspace = true
[build-dependencies]
memory-serve = "2.1.0"
+1
View File
@@ -133,6 +133,7 @@ pub fn build(services: &Services) -> Router<state::State> {
.nest("/account/", account::build())
.merge(debug::build())
.nest("/oauth2/", oauth::build())
.nest("/oidc/", oidc::build())
.merge(resources::build())
.merge(threepid::build())
.fallback(async || WebError::NotFound),
+19
View File
@@ -21,6 +21,7 @@ use crate::{
GET_POST, Result, TemplateContext,
account::register::{TrustedFlowStatus, UntrustedFlowStatus, registration_flow_status},
components::UserCard,
oidc::{OIDC_SESSION_ID_KEY, OidcSession, OidcSessionState},
},
response,
session::{LoginQuery, LoginTarget, User, UserSession},
@@ -68,6 +69,24 @@ async fn route_login(
) -> Result {
let user_id = user.into_session().map(|session| session.user_id);
if services.oidc.enabled() {
if user_id.is_some() && !reauthenticate {
return response!(Redirect::to(&next.unwrap_or_default().target_path()));
}
let (session, redirect_url) = services.oidc.begin_session().await;
session_store
.insert(OIDC_SESSION_ID_KEY, OidcSession {
next: next.unwrap_or_default(),
state: OidcSessionState::CodeExchange { expected_user: user_id, session },
})
.await
.expect("should be able to serialize OIDC session");
return response!(Redirect::to(redirect_url.as_str()));
}
let body = match &user_id {
| None => {
let (trusted_flow_status, untrusted_flow_status) =
+1 -1
View File
@@ -520,7 +520,7 @@ async fn complete_registration(
) -> Result<Redirect> {
services
.users
.create_local_account(&user_id, password_hash, email)
.create_local_account(&user_id, Some(password_hash), email)
.await?;
if let Some(registration_token) = registration_token {
+1
View File
@@ -17,6 +17,7 @@ mod components;
pub(super) mod debug;
pub(super) mod index;
pub(super) mod oauth;
pub(super) mod oidc;
pub(super) mod resources;
pub(super) mod threepid;
+126
View File
@@ -0,0 +1,126 @@
use std::time::SystemTime;
use axum::{
Extension, Router,
extract::{Query, State},
response::Redirect,
routing::on,
};
use conduwuit_service::{oauth::grant::AuthorizationCodeResponse, oidc::SessionCompletionStatus};
use futures::FutureExt;
use ruma::OwnedServerName;
use serde::{Deserialize, de::IgnoredAny};
use tower_sessions::Session;
use crate::{
WebError,
extract::{Expect, PostForm},
pages::{
GET_POST, Result, TemplateContext,
oidc::{OIDC_SESSION_ID_KEY, OidcSession, OidcSessionState},
},
response,
session::{User, UserSession},
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new().route("/", on(GET_POST, route_complete))
}
template! {
struct OidcComplete use "oidc_complete.html.j2" {
server_name: OwnedServerName,
username_error: Option<String>
}
}
#[derive(Deserialize)]
struct LoginForm {
username: String,
}
async fn route_complete(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
Expect(Query(query)): Expect<Query<AuthorizationCodeResponse>>,
session_store: Session,
user: User<true>,
PostForm(form): PostForm<LoginForm>,
) -> Result {
let user_id = user.into_session().map(|session| session.user_id);
let Some(session) = session_store
.get::<OidcSession>(OIDC_SESSION_ID_KEY)
.await
.expect("should be able to deserialize oidc session")
else {
return response!(WebError::BadRequest(
"No OIDC session found. What are you doing here?".to_owned()
));
};
let session_completion_status = match session.state {
| OidcSessionState::CodeExchange { expected_user, session: pending_session } => {
if let (Some(user_id), Some(expected_user)) = (&user_id, &expected_user)
&& user_id != expected_user
{
return response!(WebError::BadRequest(
"Identity mismatch. You may have switched accounts at your identity \
provider. Please log out and back in to continue."
.to_owned()
));
}
let claims = services
.oidc
.exchange_code(pending_session, query)
.boxed()
.await
.map_err(|err| WebError::BadRequest(err.to_owned()))?;
session_store
.insert(OIDC_SESSION_ID_KEY, OidcSession {
next: session.next.clone(),
state: OidcSessionState::Authorized { claims: Box::new(claims.clone()) },
})
.await
.expect("Should be able to serialize oidc session");
services.oidc.complete_session(&claims, None).await
},
| OidcSessionState::Authorized { claims } =>
services
.oidc
.complete_session(&claims, form.map(|form| form.username))
.await,
}
.map_err(|err| WebError::BadRequest(err.to_owned()))?;
match session_completion_status {
| SessionCompletionStatus::Complete(user_id) => {
let _ = session_store
.remove::<IgnoredAny>(OIDC_SESSION_ID_KEY)
.await;
let user_session = UserSession { user_id, last_login: SystemTime::now() };
session_store
.insert(User::KEY, user_session)
.await
.expect("should be able to serialize user session");
response!(Redirect::to(&session.next.target_path()))
},
| SessionCompletionStatus::NeedsLocalpart => {
response!(OidcComplete::new(context, services.globals.server_name().to_owned(), None))
},
| SessionCompletionStatus::InvalidLocalpart(error) => {
response!(OidcComplete::new(
context,
services.globals.server_name().to_owned(),
Some(error)
))
},
}
}
+35
View File
@@ -0,0 +1,35 @@
use axum::Router;
use conduwuit_service::oidc;
use openidconnect::core::CoreIdTokenClaims;
use ruma::OwnedUserId;
use serde::{Deserialize, Serialize};
use crate::session::LoginTarget;
mod complete;
pub(crate) const OIDC_SESSION_ID_KEY: &str = "oidc_session";
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct OidcSession {
pub next: LoginTarget,
pub state: OidcSessionState,
}
#[derive(Debug, Deserialize, Serialize)]
pub(crate) enum OidcSessionState {
CodeExchange {
expected_user: Option<OwnedUserId>,
session: oidc::PendingSession,
},
Authorized {
claims: Box<CoreIdTokenClaims>,
},
}
pub(crate) fn build() -> Router<crate::State> {
#[allow(clippy::wildcard_imports)]
use self::*;
Router::new().nest("/complete", complete::build())
}
+1
View File
@@ -33,6 +33,7 @@
padding: 0.5em;
margin-bottom: 0.5em;
line-height: 1;
align-items: baseline;
border-radius: var(--border-radius-sm);
border: 2px solid var(--secondary);
@@ -0,0 +1,39 @@
{% extends "_layout.html.j2" %}
{% import "_components/form.html.j2" as form %}
{%- block head -%}
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/login.css">
{%- endblock -%}
{%- block title -%}
Link your account
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1 class="with-matrix-icon">
Link your account
<a href="https://matrix.org" target="_blank" noreferer>
<img class="matrix-icon" alt="Matrix logo" aria-ignore src="{{ crate::ROUTE_PREFIX }}/resources/matrix-icon.svg">
</a>
</h1>
<form method="post">
<p>To finish linking your account to Matrix, choose a username.</p>
<p>
<label for="username">Username</label>
<span class="username-input">
<span>@</span>
<input type="text" id="username" name="username" autocomplete="username" required>
<span>:{{ server_name }}</span>
</span>
{% if let Some(username_error) = username_error %}
<small class="error">
{{ username_error }}
</small>
{% endif %}
<small>Your username cannot be changed after you create your account.</small>
</p>
<button type="submit">Continue</button>
</form>
</div>
{% endblock %}