feat: Allow existing legacy accounts to be linked interactively

This commit is contained in:
Ginger committed 2026-07-02 16:34:38 -04:00
1 parent 9a9cc94702
commit 4ee0bb7533
3 files changed
+128 -48

No files matched your search

+5 -15
View File
@@ -58,9 +58,8 @@ pub struct PendingSession {
}
pub enum SessionCompletionStatus {
NeedsUserId,
Complete(OwnedUserId),
NeedsLocalpart,
InvalidLocalpart(String),
}
#[async_trait]
@@ -199,7 +198,7 @@ impl Service {
pub async fn complete_session(
&self,
claims: &CoreIdTokenClaims,
supplied_username: Option<String>,
supplied_user_id: Option<OwnedUserId>,
) -> Result<SessionCompletionStatus, &'static str> {
let Some(OidcClient { config, .. }) = self.client.as_ref() else {
return Err("Delegated authentication is not enabled on this server.");
@@ -217,19 +216,10 @@ impl Service {
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())),
}
if let Some(supplied_user_id) = supplied_user_id {
supplied_user_id
} else {
return Ok(SessionCompletionStatus::NeedsLocalpart);
return Ok(SessionCompletionStatus::NeedsUserId);
}
} else if let Some(preferred_username) = claims.preferred_username() {
self.services
+85 -15
View File
@@ -8,7 +8,7 @@ use axum::{
};
use conduwuit_service::{oauth::grant::AuthorizationCodeResponse, oidc::SessionCompletionStatus};
use futures::FutureExt;
use ruma::OwnedServerName;
use ruma::{OwnedServerName, UserId};
use serde::{Deserialize, de::IgnoredAny};
use tower_sessions::Session;
@@ -17,6 +17,7 @@ use crate::{
extract::{Expect, PostForm},
pages::{
GET_POST, Result, TemplateContext,
components::UserCard,
oidc::{OIDC_SESSION_ID_KEY, OidcSession, OidcSessionState},
},
response,
@@ -30,14 +31,27 @@ pub(crate) fn build() -> Router<crate::State> {
template! {
struct OidcComplete use "oidc_complete.html.j2" {
server_name: OwnedServerName,
username_error: Option<String>
body: OidcCompleteBody
}
}
#[derive(Debug)]
enum OidcCompleteBody {
UsernamePrompt {
server_name: OwnedServerName,
username_error: Option<String>,
},
PasswordPrompt {
username: String,
user_card: UserCard,
password_error: bool,
},
}
#[derive(Deserialize)]
struct LoginForm {
username: String,
password: Option<String>,
}
async fn route_complete(
@@ -89,11 +103,71 @@ async fn route_complete(
services.oidc.complete_session(&claims, None).await
},
| OidcSessionState::Authorized { claims } =>
| OidcSessionState::Authorized { claims } => {
let supplied_user_id = if let Some(form) = form {
if let Ok(user_id) = UserId::parse(format!(
"@{}:{}",
&form.username,
services.globals.server_name()
)) && services.users.status(&user_id).await.is_active()
{
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
if let Some(password) = form.password {
if services
.users
.check_password(&user_id, &password)
.await
.is_ok()
{
Some(user_id)
} else {
return response!(OidcComplete::new(
context,
OidcCompleteBody::PasswordPrompt {
username: form.username,
user_card,
password_error: true
}
));
}
} else {
return response!(OidcComplete::new(
context,
OidcCompleteBody::PasswordPrompt {
username: form.username,
user_card,
password_error: false,
}
));
}
} else {
match services
.users
.determine_registration_user_id(Some(form.username), None, None)
.await
{
| Ok(user_id) => Some(user_id),
| Err(err) => {
return response!(OidcComplete::new(
context,
OidcCompleteBody::UsernamePrompt {
server_name: services.globals.server_name().to_owned(),
username_error: Some(err.message()),
}
));
},
}
}
} else {
None
};
services
.oidc
.complete_session(&claims, form.map(|form| form.username))
.await,
.complete_session(&claims, supplied_user_id)
.await
},
}
.map_err(|err| WebError::BadRequest(err.to_owned()))?;
@@ -112,15 +186,11 @@ async fn route_complete(
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)
))
| SessionCompletionStatus::NeedsUserId => {
response!(OidcComplete::new(context, OidcCompleteBody::UsernamePrompt {
server_name: services.globals.server_name().to_owned(),
username_error: None
}))
},
}
}
+38 -18
View File
@@ -17,23 +17,43 @@ Link your account
<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>
{% match body %}
{% when OidcCompleteBody::UsernamePrompt { server_name, username_error } %}
<form method="post">
<p>
To finish linking your account to Matrix, choose a username.
<br>If you have an existing Matrix account, enter its user ID to link it.
</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 link your account.</small>
</p>
<button type="submit">Continue</button>
</form>
{% when OidcCompleteBody::PasswordPrompt { username, user_card, password_error } %}
{{ user_card }}
<form method="post">
<p>To link this legacy account, enter the password you used to use when logging in.</p>
<p>
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password">
{% if password_error %}
<small class="error">Incorrect password</small>
{% endif %}
</p>
<input type="hidden" name="username" value="{{ username }}">
<button type="submit">Continue</button>
</form>
{% endmatch %}
</div>
{% endblock %}