feat: Initial implementation of OIDC

This commit is contained in:
Ginger
2026-07-02 16:34:38 -04:00
parent 69163b5391
commit c0cb4afd8a
32 changed files with 1300 additions and 63 deletions
Generated
+610 -44
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -403,6 +403,9 @@ default-features = false
version = "0.11.0"
default-features = false
[workspace.dependencies.openidconnect]
version = "4.0.1"
# optional opentelemetry, performance measurements, flamegraphs, etc for performance measurements and monitoring
[workspace.dependencies.opentelemetry]
version = "0.32.0"
+38
View File
@@ -2028,3 +2028,41 @@
# legacy authentication will be unable to log in.
#
#compatibility_mode = "hybrid"
# This item is undocumented. Please contribute documentation for it.
#
#oidc =
# Uncommenting this section will enable Continuwuity's support for
# authenticating users using an OpenID Connect-compatible identity provider.
# This is referred to as "delegated authentication".
#
# IMPORTANT NOTE: When delegated authentication is active, Continuwuity will behave as if
# the `global.oauth.compatibility_mode` setting is set to `exclusive`.
# Matrix clients which do not support OAuth login (also referred to as "next-gen auth") will NOT be able
# to log in while delegated authentication is active.
#[global.oauth.oidc]
# The OIDC issuer URL. Continuwuity will use OpenID Connect Discovery to
# automatically fetch the identity provider's metadata from this URL.
# Generally you should set this to the base domain your identity provider
# runs on.
#
#discovery_url =
# The OAuth client ID for Continuwuity to use when communicating with the
# identity provider.
#
#client_id =
# The OAuth client secret for Continuwuity to use when communicating with
# the identity provider.
#
#client_secret =
# Whether the user should be prompted to choose a localpart
# when signing in for the first time. If this is `false`, Continuwuity
# will attempt to use the value of the `preferred_username` claim
# returned from the IDP as the user's localpart, and authentication will
# fail if this claim is missing or is not a valid localpart.
#
#prompt_for_localpart = false
+15 -1
View File
@@ -1,5 +1,5 @@
use clap::Parser;
use conduwuit::Result;
use conduwuit::{Err, Result};
use crate::{
appservice::{self, AppserviceCommand},
@@ -8,6 +8,7 @@
debug::{self, DebugCommand},
federation::{self, FederationCommand},
media::{self, MediaCommand},
oidc::{self, OidcCommand},
query::{self, QueryCommand},
room::{self, RoomCommand},
server::{self, ServerCommand},
@@ -30,6 +31,9 @@ pub enum AdminCommand {
/// Commands for managing registration tokens
Token(TokenCommand),
#[command(subcommand)]
Oidc(OidcCommand),
#[command(subcommand)]
/// Commands for managing rooms
Rooms(RoomCommand),
@@ -80,6 +84,16 @@ pub(super) async fn process(command: AdminCommand, context: &Context<'_>) -> Res
context.bail_restricted()?;
token::process(command, context).await
},
| Oidc(command) => {
// OIDC commands are all restricted
context.bail_restricted()?;
if !context.services.oidc.enabled() {
return Err!("OIDC is not configured");
}
oidc::process(command, context).await
},
| Rooms(command) => room::process(command, context).await,
| Federation(command) => federation::process(command, context).await,
| Server(command) => server::process(command, context).await,
+1
View File
@@ -16,6 +16,7 @@
pub(crate) mod debug;
pub(crate) mod federation;
pub(crate) mod media;
pub(crate) mod oidc;
pub(crate) mod query;
pub(crate) mod room;
pub(crate) mod server;
+17
View File
@@ -0,0 +1,17 @@
use conduwuit::Result;
use crate::utils::parse_active_local_user_id;
impl crate::Context<'_> {
pub(super) async fn oidc_link(&self, user_id: String, subject: String) -> Result {
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
self.services.oidc.link_user(&user_id, &subject).await;
self.write_str("Account linked successfully").await?;
Ok(())
}
pub(super) async fn oidc_unlink(&self, _user_id: String) -> Result { todo!() }
}
+21
View File
@@ -0,0 +1,21 @@
mod commands;
use clap::Subcommand;
use conduwuit::Result;
use conduwuit_macros::admin_command_dispatch;
#[admin_command_dispatch]
#[derive(Debug, Subcommand)]
pub enum OidcCommand {
/// Link a user ID to the given subject claim.
#[clap(name = "link")]
OidcLink {
user_id: String,
subject: String,
},
#[clap(name = "unlink")]
OidcUnlink {
user_id: String,
},
}
+1 -1
View File
@@ -44,7 +44,7 @@ pub(super) async fn issue_token(&self, expires: super::TokenExpires) -> Result {
.services
.config
.oauth
.compatibility_mode
.compatibility_mode()
.oauth_available()
{
self.write_str(&format!(
+1 -1
View File
@@ -59,7 +59,7 @@ pub(super) async fn create_user(&self, username: String, password: Option<String
self.services
.users
.create_local_account(&user_id, HashedPassword::new(password)?, None)
.create_local_account(&user_id, Some(HashedPassword::new(password)?), None)
.await?;
self.write_str(&format!("Created user {user_id} with password `{password}`"))
+2 -2
View File
@@ -97,7 +97,7 @@ pub(crate) async fn register_route(
services
.users
.create_local_account(&user_id, password, identity.email)
.create_local_account(&user_id, Some(password), identity.email)
.await?;
user_id
@@ -106,7 +106,7 @@ pub(crate) async fn register_route(
let (token, device) = if !body.inhibit_login {
// If UIAA is disabled, we can't create a device. In that case only appservices
// can reach this point in the first place, so we return an error for them.
if !services.config.oauth.compatibility_mode.uiaa_available() {
if !services.config.oauth.compatibility_mode().uiaa_available() {
return Err!(Request(AppserviceLoginUnsupported(
"User-interactive appservice registration is not available on this server."
)));
+1 -1
View File
@@ -37,7 +37,7 @@ pub(crate) fn router(state: crate::State) -> Router<crate::State> {
.layer(middleware::from_fn_with_state(
state,
async |State(state): State<crate::State>, request: Request, next: Next| -> Response {
if state.config.oauth.compatibility_mode.oauth_available() {
if state.config.oauth.compatibility_mode().oauth_available() {
next.run(request).await
} else {
(StatusCode::NOT_FOUND, "OAuth is unavailable on this server").into_response()
+1 -1
View File
@@ -21,7 +21,7 @@ pub(crate) async fn get_authorization_server_metadata_route(
State(services): State<crate::State>,
_body: Ruma<get_authorization_server_metadata::v1::Request>,
) -> Result<get_authorization_server_metadata::v1::Response> {
if !services.config.oauth.compatibility_mode.oauth_available() {
if !services.config.oauth.compatibility_mode().oauth_available() {
return Err!(Request(Unrecognized("OAuth is unavailable on this server")));
}
+2 -2
View File
@@ -43,7 +43,7 @@ pub(crate) async fn get_login_types_route(
ClientIp(client): ClientIp,
_body: Ruma<get_login_types::v3::Request>,
) -> Result<get_login_types::v3::Response> {
if !services.config.oauth.compatibility_mode.uiaa_available() {
if !services.config.oauth.compatibility_mode().uiaa_available() {
return Err!(Request(Unrecognized(
"User-interactive authentication is not available on this server."
)));
@@ -120,7 +120,7 @@ pub(crate) async fn login_route(
ClientIp(client): ClientIp,
body: Ruma<login::v3::Request>,
) -> Result<login::v3::Response> {
if !services.config.oauth.compatibility_mode.uiaa_available() {
if !services.config.oauth.compatibility_mode().uiaa_available() {
return match body.login_info {
| LoginInfo::ApplicationService(_) => {
Err!(Request(AppserviceLoginUnsupported(
+1
View File
@@ -118,6 +118,7 @@ url.workspace = true
parking_lot.workspace = true
lock_api.workspace = true
hyper-util.workspace = true
openidconnect.workspace = true
[target.'cfg(unix)'.dependencies]
nix.workspace = true
+58 -2
View File
@@ -17,6 +17,7 @@
use figment::providers::{Env, Format, Toml};
pub use figment::{Figment, value::Value as FigmentValue};
use lettre::message::Mailbox;
use openidconnect::{ClientId, ClientSecret};
use regex::RegexSet;
use ruma::{
OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, RoomVersionId,
@@ -2419,10 +2420,65 @@ pub struct OauthConfig {
/// legacy authentication will be unable to log in.
///
/// default: "hybrid"
pub compatibility_mode: OAuthMode,
compatibility_mode: OAuthMode,
pub oidc: Option<OidcConfig>,
}
#[derive(Clone, Debug, Default, Deserialize)]
impl OauthConfig {
#[must_use]
pub fn compatibility_mode(&self) -> OAuthMode {
if self.oidc.is_some() {
OAuthMode::Exclusive
} else {
self.compatibility_mode
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[config_example_generator(
filename = "conduwuit-example.toml",
section = "global.oauth.oidc",
optional = "true",
header = "\
# Uncommenting this section will enable Continuwuity's support for
# authenticating users using an OpenID Connect-compatible identity provider.
# This is referred to as \"delegated authentication\".
#
# IMPORTANT NOTE: When delegated authentication is active, Continuwuity will behave as if
# the `global.oauth.compatibility_mode` setting is set to `exclusive`.
# Matrix clients which do not support OAuth login (also referred to as \"next-gen auth\") will \
NOT be able
# to log in while delegated authentication is active."
)]
pub struct OidcConfig {
/// The OIDC issuer URL. Continuwuity will use OpenID Connect Discovery to
/// automatically fetch the identity provider's metadata from this URL.
/// Generally you should set this to the base domain your identity provider
/// runs on.
pub discovery_url: Url,
/// The OAuth client ID for Continuwuity to use when communicating with the
/// identity provider.
pub client_id: ClientId,
/// The OAuth client secret for Continuwuity to use when communicating with
/// the identity provider.
pub client_secret: ClientSecret,
/// Whether the user should be prompted to choose a localpart
/// when signing in for the first time. If this is `false`, Continuwuity
/// will attempt to use the value of the `preferred_username` claim
/// returned from the IDP as the user's localpart, and authentication will
/// fail if this claim is missing or is not a valid localpart.
///
/// default: false
#[serde(default)]
pub prompt_for_localpart: bool,
}
#[derive(Clone, Copy, Debug, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OAuthMode {
Disabled,
+4
View File
@@ -124,6 +124,10 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "onetimekeyid_onetimekeys",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "openidsubject_localpart",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "fallbackkeyid_fallbackkey",
..descriptor::RANDOM_SMALL
+1
View File
@@ -120,6 +120,7 @@ reqwest_recaptcha = { package = "reqwest", version = "0.12.28", default-features
yansi.workspace = true
lettre.workspace = true
serde_urlencoded.workspace = true
openidconnect.workspace = true
[target.'cfg(all(unix, target_os = "linux"))'.dependencies]
sd-notify.workspace = true
+1
View File
@@ -28,6 +28,7 @@
pub mod media;
pub mod moderation;
pub mod oauth;
pub mod oidc;
pub mod presence;
pub mod pusher;
pub mod registration_tokens;
+1 -1
View File
@@ -160,7 +160,7 @@ pub enum ErrorCode {
InvalidClientMetadata,
}
#[derive(Serialize)]
#[derive(Serialize, Deserialize)]
pub struct AuthorizationCodeResponse {
pub state: String,
pub code: String,
+283
View File
@@ -0,0 +1,283 @@
use std::{sync::Arc, time::Duration};
use async_trait::async_trait;
use conduwuit::{Result, config::OidcConfig, err, error, info};
use database::{Deserialized, Map};
use openidconnect::{
AuthorizationCode, CsrfToken, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl,
Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, TokenResponse,
core::{CoreAuthenticationFlow, CoreClient, CoreIdTokenClaims, CoreProviderMetadata},
reqwest,
};
use ruma::{OwnedUserId, UserId};
use serde::{Deserialize, Serialize};
use tokio::sync::SetOnce;
use url::Url;
use crate::{
Dep, config, globals,
oauth::grant::AuthorizationCodeResponse,
users::{self, AccountStatus},
};
pub struct Service {
services: Services,
db: Data,
client: Option<OidcClient>,
}
struct Data {
openidsubject_localpart: Arc<Map>,
}
struct Services {
config: Dep<config::Service>,
globals: Dep<globals::Service>,
users: Dep<users::Service>,
}
struct OidcClient {
config: OidcConfig,
machine: SetOnce<
CoreClient<
EndpointSet,
EndpointNotSet,
EndpointNotSet,
EndpointNotSet,
EndpointMaybeSet,
EndpointMaybeSet,
>,
>,
client: reqwest::Client,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct PendingSession {
pkce_verifier: PkceCodeVerifier,
nonce: Nonce,
csrf_token: CsrfToken,
}
pub enum SessionCompletionStatus {
Complete(OwnedUserId),
NeedsLocalpart,
InvalidLocalpart(String),
}
#[async_trait]
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
services: Services {
config: args.depend::<config::Service>("config"),
globals: args.depend::<globals::Service>("globals"),
users: args.depend::<users::Service>("users"),
},
db: Data {
openidsubject_localpart: args.db["openidsubject_localpart"].clone(),
},
client: args.server.config.oauth.oidc.as_ref().map(|config| OidcClient {
config: config.clone(),
machine: SetOnce::new(),
// This isn't in the client service because it has to use the `reqwest` shipped by `openidconnect`
client: reqwest::ClientBuilder::new()
.connect_timeout(Duration::from_secs(args.server.config.request_conn_timeout))
.read_timeout(Duration::from_secs(args.server.config.request_timeout))
.timeout(Duration::from_secs(args.server.config.request_total_timeout))
.pool_idle_timeout(Duration::from_secs(args.server.config.request_idle_timeout))
.pool_max_idle_per_host(args.server.config.request_idle_per_host.into())
.user_agent(conduwuit::user_agent())
.redirect(reqwest::redirect::Policy::none())
.danger_accept_invalid_certs(args.server.config.allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure)
.build()
.expect("client should build")
}),
}))
}
async fn worker(self: Arc<Self>) -> Result {
if let Some(OidcClient { config, machine, client }) = &self.client {
let redirect_url = self
.services
.config
.get_client_domain()
.join(&format!("{}/oidc/complete", conduwuit::ROUTE_PREFIX))
.expect("redirect url should be valid");
let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::from_url(config.discovery_url.clone()),
client,
)
.await
.map_err(|err| err!("Failed to discover OIDC provider metadata: {err}"))?;
machine
.set(
CoreClient::from_provider_metadata(
provider_metadata,
config.client_id.clone(),
Some(config.client_secret.clone()),
)
.set_redirect_uri(RedirectUrl::from_url(redirect_url)),
)
.expect("machine should be empty");
}
Ok(())
}
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
}
impl Service {
const SERVER_MISCONFIGURED: &str =
"Identity server is misconfigured. Contact your homeserver's administrator.";
pub fn enabled(&self) -> bool { self.client.is_some() }
pub async fn begin_session(&self) -> (PendingSession, Url) {
let OidcClient { machine, .. } = self.client.as_ref().expect("oidc should be configured");
let machine = machine.wait().await;
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_token, nonce) = machine
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.set_pkce_challenge(pkce_challenge)
.url();
(PendingSession { pkce_verifier, nonce, csrf_token }, auth_url)
}
pub async fn exchange_code(
&self,
session: PendingSession,
response: AuthorizationCodeResponse,
) -> Result<CoreIdTokenClaims, &'static str> {
let Some(OidcClient { machine, client, .. }) = self.client.as_ref() else {
return Err("Delegated authentication is not enabled on this server.");
};
let machine = machine.wait().await;
if session.csrf_token.into_secret() != response.state {
return Err("State mismatch.");
}
let token_response = machine
.exchange_code(AuthorizationCode::new(response.code))
.expect("machine should be configured correctly")
.set_pkce_verifier(session.pkce_verifier)
.request_async(client)
.await
.map_err(|err| {
error!("Failed to exchange OIDC authorization code: {err}");
"Code exchange failed."
})?;
let Some(id_token) = token_response.id_token() else {
error!("Identity server did not return an id token");
return Err(Self::SERVER_MISCONFIGURED);
};
let claims = id_token
.claims(&machine.id_token_verifier(), &session.nonce)
.map_err(|err| {
error!("Failed to verify id token claims: {err}");
Self::SERVER_MISCONFIGURED
})?
.to_owned();
info!(subject = claims.subject().as_str(), "Authenticated subject");
Ok(claims)
}
pub async fn complete_session(
&self,
claims: &CoreIdTokenClaims,
supplied_username: Option<String>,
) -> Result<SessionCompletionStatus, &'static str> {
let Some(OidcClient { config, .. }) = self.client.as_ref() else {
return Err("Delegated authentication is not enabled on this server.");
};
let subject = claims.subject().as_str();
let user_id = if let Ok(localpart) = self
.db
.openidsubject_localpart
.get(subject)
.await
.deserialized::<String>()
{
UserId::parse(format!("@{localpart}:{}", self.services.globals.server_name()))
.expect("saved localpart should be valid")
} else if config.prompt_for_localpart {
if let Some(supplied_username) = supplied_username {
match self
.services
.users
.determine_registration_user_id(Some(supplied_username), None, None)
.await
{
| Ok(user_id) => user_id,
| Err(err) =>
return Ok(SessionCompletionStatus::InvalidLocalpart(err.message())),
}
} else {
return Ok(SessionCompletionStatus::NeedsLocalpart);
}
} else if let Some(preferred_username) = claims.preferred_username() {
self.services
.users
.determine_registration_user_id(Some(preferred_username.to_string()), None, None)
.await
.map_err(|err| {
error!("Preferred username claim is not a valid localpart: {err}");
"Your preferred username is not a valid Matrix user ID localpart. Contact \
your homeserver's administrator."
})?
} else {
error!("No preferred username claim was present");
return Err(Self::SERVER_MISCONFIGURED);
};
info!(?subject, ?user_id, "User {user_id} successfully authorized with OIDC");
match self.services.users.status(&user_id).await {
| AccountStatus::Active => {
// Do nothing, an account already exists
},
| AccountStatus::NotFound => {
// Create a new shadow user
self.services
.users
.create_local_account(&user_id, None, None)
.await
.map_err(|err| {
error!("Failed to create a shadow user for {user_id}: {err}");
Self::SERVER_MISCONFIGURED
})?;
self.link_user(&user_id, subject).await;
info!(?subject, ?user_id, "Shadow user created for {user_id}");
},
| AccountStatus::Deactivated => {
return Err("Your account has been deactivated.");
},
}
Ok(SessionCompletionStatus::Complete(user_id))
}
pub async fn link_user(&self, user_id: &UserId, subject: &str) {
self.db
.openidsubject_localpart
.insert(subject, user_id.localpart());
}
}
+4 -2
View File
@@ -11,8 +11,8 @@
account_data, admin, announcements, antispam, appservice, client, config, emergency,
federation, firstrun, globals, key_backups, mailer,
manager::Manager,
media, moderation, oauth, presence, pusher, registration_tokens, resolver, rooms, sending,
server_keys,
media, moderation, oauth, oidc, presence, pusher, registration_tokens, resolver, rooms,
sending, server_keys,
service::{self, Args, Map, Service},
sync, threepid, transactions, uiaa, users,
};
@@ -28,6 +28,7 @@ pub struct Services {
pub key_backups: Arc<key_backups::Service>,
pub media: Arc<media::Service>,
pub oauth: Arc<oauth::Service>,
pub oidc: Arc<oidc::Service>,
pub mailer: Arc<mailer::Service>,
pub presence: Arc<presence::Service>,
pub pusher: Arc<pusher::Service>,
@@ -85,6 +86,7 @@ macro_rules! build {
key_backups: build!(key_backups::Service),
media: build!(media::Service),
oauth: build!(oauth::Service),
oidc: build!(oidc::Service),
mailer: build!(mailer::Service),
presence: build!(presence::Service),
pusher: build!(pusher::Service),
+1 -1
View File
@@ -314,7 +314,7 @@ async fn create_session(
.services
.config
.oauth
.compatibility_mode
.compatibility_mode()
.uiaa_available()
{
return Err!(Request(Unrecognized(
+9 -3
View File
@@ -38,8 +38,10 @@ pub enum AccountStatus {
}
impl AccountStatus {
#[must_use]
pub fn is_found(&self) -> bool { !matches!(self, Self::NotFound) }
#[must_use]
pub fn is_active(&self) -> bool { matches!(self, Self::Active) }
pub fn ensure_active(&self) -> Result<()> {
@@ -125,15 +127,19 @@ pub async fn create_shadow_account(&self, user_id: &UserId) -> Result<()> {
Ok(())
}
/// Create a new account for a local human or bot user.
/// Create a new account for a local human or bot user. If `password` is
/// None, the account will be a shadow account.
pub async fn create_local_account(
&self,
user_id: &UserId,
password: HashedPassword,
password: Option<HashedPassword>,
email: Option<Address>,
) -> Result<()> {
self.create_shadow_account(user_id).await?;
self.convert_to_local_account(user_id, password).await?;
if let Some(password) = password {
self.convert_to_local_account(user_id, password).await?;
}
// Set an initial display name
{
+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 @@
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 @@
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 %}