mirror of
https://forgejo.ellis.link/continuwuation/continuwuity/
synced 2026-08-28 20:08:19 +00:00
feat: Initial implementation of OIDC
This commit is contained in:
Generated
+610
-44
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!() }
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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}`"))
|
||||
|
||||
@@ -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."
|
||||
)));
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -160,7 +160,7 @@ pub enum ErrorCode {
|
||||
InvalidClientMetadata,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct AuthorizationCodeResponse {
|
||||
pub state: String,
|
||||
pub code: String,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -314,7 +314,7 @@ async fn create_session(
|
||||
.services
|
||||
.config
|
||||
.oauth
|
||||
.compatibility_mode
|
||||
.compatibility_mode()
|
||||
.uiaa_available()
|
||||
{
|
||||
return Err!(Request(Unrecognized(
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user