Compare commits

...

42 Commits

Author SHA1 Message Date
Ginger 96c069cd67 chore: Update Cargo.lock 2025-11-11 11:44:07 -05:00
Ginger 9a38395f0a chore: Fix template EOFs 2025-11-11 11:36:28 -05:00
Ginger fdbb1c86b1 chore: Clippy and formatting 2025-11-11 11:36:28 -05:00
lafleur 17f8ec21a3 fix accidental project name regression 2025-11-11 11:36:28 -05:00
lafleur c46104ac0c OIDC: fix build after rebase 2025-11-11 11:36:28 -05:00
lafleur 900ba33a4e OIDC: impl client_registrar over db (sync impl) 2025-11-11 11:36:28 -05:00
lafleur f2e696ae3e OIDC auth flow: correct device registration 2025-11-11 11:36:04 -05:00
lafleur 1088aa5020 OIDC private clients: correct client secret 2025-11-11 11:36:04 -05:00
lafleur 5c2a5de7d3 unlimit log levels and update Cargo.lock 2025-11-11 11:36:04 -05:00
lafleur 600b8cb366 oidc: implement registering devices 2025-11-11 11:34:26 -05:00
lafleur 3bbbfcdd46 WIP: show discrepancy between device_id and client_id 2025-11-11 11:34:26 -05:00
lafleur e9b387414f add services::oidc::user_and_device_from_token(), use in auth 2025-11-11 11:34:26 -05:00
lafleur 426b113c30 OIDC: embed user_id in consent 2025-11-11 11:34:26 -05:00
lafleur ddae8af99f web::login: add form-data CSP rules for localhost 2025-11-11 11:34:26 -05:00
lafleur 769db9b818 add some OIDC docstrings 2025-11-11 11:34:26 -05:00
lafleur 27d9d1d78d fix oxide-auth's redirect_uri comparison
oxide-auth's `RegisteredUrl::IgnorePortOnLocalhost` doesn't work when the host is 127.0.0.1 or [::1].
This commit lets the authentication process translate the host. The new registrar already supports this.
2025-11-11 11:34:26 -05:00
lafleur 2e34ac9f59 basic OIDC client registrar with auth tracing 2025-11-11 11:34:26 -05:00
lafleur 95632103bc OIDC: make response_mode optional
Fractal omits the `response_mode` field when in an auth flow (its value must be
the literal "S256", so it's mainly here for OIDC compliance I guess). Accepting
this lets it proceed to the next authentication step.
2025-11-11 11:34:26 -05:00
lafleur d916bb9f21 support OIDC private clients 2025-11-11 11:34:26 -05:00
lafleur 4e50064740 oidc: add debug/trace logs 2025-11-11 11:34:26 -05:00
lafleur 56bd11013f oidc authorize: make response_mode optional 2025-11-11 11:34:26 -05:00
lafleur 8893bd1613 fix build warning : explicit cast 2025-11-11 11:34:26 -05:00
nexy7574 e7b9446b5a fix build errors 2025-11-11 11:34:26 -05:00
Jade Ellis bc23071dd4 fixup! fix OidcResponse: reimplement IntoResponse 2025-11-11 11:34:26 -05:00
lafleur b602be0921 fix OidcResponse: reimplement IntoResponse 2025-11-11 11:34:26 -05:00
Jade Ellis 0c333c9a05 chore: fix up 2025-11-11 11:34:26 -05:00
lafleur b9b3a466f4 oidc: small cosmetics + typos 2025-11-11 11:34:26 -05:00
lafleur 3803f06392 remove stale debugging logs
I don't have the hd space to do debug builds, so I use tracing::info to debug
on release builds. Silly, right ?
2025-11-11 11:34:26 -05:00
lafleur cadcbd7d49 use config.server_name as title in OIDC pages 2025-11-11 11:34:26 -05:00
lafleur 144036f58b fix oidc_provider discovery message and docstrings 2025-11-11 11:34:26 -05:00
lafleur 2afe656e12 typos oidc_provider discovery 2025-11-11 11:34:26 -05:00
lafleur c89dfe38da fix oidc_provider config section's doc generation 2025-11-11 11:34:26 -05:00
Jade Ellis 1ce1254514 fix: Don't crash when the client URL doesn't have a domain
Having a URL with an IP literal, for example, is allowed
2025-11-11 11:34:26 -05:00
Jade Ellis 5bd1bedad0 fix: Use correct CSP for login page 2025-11-11 11:34:26 -05:00
Jade Ellis 68ca6eabe3 chore: Ignore formatting PR in blame 2025-11-11 11:34:26 -05:00
Jade Ellis e75f5cbbed chore: Fix most clippy issue, format & typos 2025-11-11 11:34:26 -05:00
lafleur 97c692b052 remove stale dependency oxide-auth-axum 2025-11-11 11:34:26 -05:00
lafleur a586ea390c feat(oidc_provider) use askama templates
Implements a custom OidcResponse with CSP headers and oxide-auth processing
compatibility.
2025-11-11 11:34:26 -05:00
lafleur dba528a5e0 rebase on current main 2025-11-11 11:34:26 -05:00
lafleur aafc93f6fb impl MSC2966: register clients dynamically 2025-11-11 11:34:26 -05:00
lafleur cd0c3886fb impl MSC2964: OIDC token flow 2025-11-11 11:34:26 -05:00
lafleur be4ccbc11b impl MSC2965: self-advertise as OIDC authentication provider
MSC2965 proposes to let the homeserver advertise its current OIDC authentication
issuer. These changes let conduwuit advertise itself as the issuer when
[global.auth.enable_oidc_login] is set. It also advertises its account management
endpoint if [global.auth.enable_oidc_account_management] is set.

None of these endpoints are implemented. This commit only implements the bare
advertisement, as requested by the MSC.
2025-11-11 11:34:26 -05:00
34 changed files with 1815 additions and 10 deletions
+3
View File
@@ -7,3 +7,6 @@ f419c64aca300a338096b4e0db4c73ace54f23d0
5998a0d883d31b866f7c8c46433a8857eae51a89
# trailing whitespace and newlines
46c193e74b2ce86c48ce802333a0aabce37fd6e9
# Formatting PRs
fd972f114293ea1be9633b750a703edd661e970d
Generated
+176
View File
@@ -50,6 +50,15 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.21"
@@ -147,6 +156,12 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.6"
@@ -612,6 +627,17 @@ dependencies = [
"digest",
]
[[package]]
name = "blake2b_simd"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06e903a20b159e944f91ec8499fe1e55651480c541ea0a584f5d967c49ad9d99"
dependencies = [
"arrayref",
"arrayvec",
"constant_time_eq",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -777,7 +803,9 @@ version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [
"iana-time-zone",
"num-traits",
"windows-link 0.2.1",
]
[[package]]
@@ -966,6 +994,7 @@ dependencies = [
"bytes",
"conduwuit_core",
"conduwuit_service",
"conduwuit_web",
"const-str",
"ctor",
"futures",
@@ -976,6 +1005,8 @@ dependencies = [
"ipaddress",
"itertools 0.14.0",
"log",
"oxide-auth",
"percent-encoding",
"rand 0.8.5",
"reqwest",
"ruma",
@@ -1142,6 +1173,8 @@ dependencies = [
"log",
"loole",
"lru-cache",
"once_cell",
"oxide-auth",
"rand 0.8.5",
"recaptcha-verify",
"regex",
@@ -1165,13 +1198,19 @@ name = "conduwuit_web"
version = "0.5.0-rc.8"
dependencies = [
"askama",
"async-trait",
"axum 0.7.9",
"conduwuit_build_metadata",
"conduwuit_core",
"conduwuit_service",
"futures",
"oxide-auth",
"percent-encoding",
"rand 0.8.5",
"serde",
"thiserror 2.0.17",
"tracing",
"url",
]
[[package]]
@@ -1235,6 +1274,12 @@ dependencies = [
"typewit",
]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "convert_case"
version = "0.7.1"
@@ -2346,6 +2391,30 @@ dependencies = [
"tracing",
]
[[package]]
name = "iana-time-zone"
version = "0.1.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.0.0"
@@ -3299,6 +3368,27 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "oxide-auth"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c136ac668d12ba0b5b8ce159b95c7600fda826dc599a1e1916f8461c0d16a84"
dependencies = [
"base64 0.21.7",
"chrono",
"hmac",
"once_cell",
"rand 0.8.5",
"rmp-serde",
"rust-argon2",
"serde",
"serde_derive",
"serde_json",
"sha2",
"subtle",
"url",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -3997,6 +4087,28 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rmp"
version = "0.8.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4"
dependencies = [
"byteorder",
"num-traits",
"paste",
]
[[package]]
name = "rmp-serde"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db"
dependencies = [
"byteorder",
"rmp",
"serde",
]
[[package]]
name = "roff"
version = "0.2.2"
@@ -4199,6 +4311,17 @@ dependencies = [
"thiserror 2.0.17",
]
[[package]]
name = "rust-argon2"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d9848531d60c9cbbcf9d166c885316c24bc0e2a9d3eba0956bb6cbbd79bc6e8"
dependencies = [
"base64 0.21.7",
"blake2b_simd",
"constant_time_eq",
]
[[package]]
name = "rust-librocksdb-sys"
version = "0.39.0+10.5.1"
@@ -5866,6 +5989,41 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link 0.1.3",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.1.3"
@@ -5878,6 +6036,24 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
+12
View File
@@ -103,6 +103,9 @@ features = [
"matched-path",
"tokio",
"tracing",
"query",
# Needed for debug_handler.
#"macros",
]
[workspace.dependencies.axum-extra]
@@ -369,6 +372,7 @@ features = [
"unstable-msc2666",
"unstable-msc2867",
"unstable-msc2870",
"unstable-msc2965",
"unstable-msc3026",
"unstable-msc3061",
"unstable-msc3245",
@@ -557,6 +561,14 @@ features = ["sync", "tls-rustls", "rustls-provider"]
[workspace.dependencies.resolv-conf]
version = "0.7.5"
[workspace.dependencies.oxide-auth]
version = "0.6.1"
[workspace.dependencies.once_cell]
version = "1.21.3"
[workspace.dependencies.percent-encoding]
version = "2.3.1"
#
# Patches
#
+20 -1
View File
@@ -23,7 +23,8 @@
# See the docs for reverse proxying and delegation:
# https://continuwuity.org/deploying/generic.html#setting-up-the-reverse-proxy
#
# Also see the `[global.well_known]` config section at the very bottom.
# Also see the `[global.auth]` and `[global.well_known]` config sections
# at the very bottom.
#
# Examples of delegation:
# - https://puppygock.gay/.well-known/matrix/server
@@ -1754,6 +1755,24 @@
#
#dual_protocol = false
[global.auth]
# Use this homeserver as the OIDC authentication reference. It will
# advertise itself as the OIDC authentication issuer to new clients,
# and use the internal user database to answer on the advertised
# endpoints. Note that the legacy Matrix authentication still will be
# reachable.
# Unset by default.
#
#enable_oidc_login =
# Whether this homeserver should provide users with an account management
# interface. Only used if `enable_oidc_login` is set. Note that the
# endpoint is unimplemented at the moment.
# Unset by default.
#
#enable_oidc_account_management =
[global.well_known]
# The server URL that the client well-known file will serve. This should
+3
View File
@@ -94,6 +94,9 @@ sha1.workspace = true
tokio.workspace = true
tracing.workspace = true
ctor.workspace = true
oxide-auth.workspace = true
conduwuit-web.workspace = true
percent-encoding.workspace = true
[lints]
workspace = true
+3 -1
View File
@@ -75,7 +75,9 @@ pub(crate) async fn upload_keys_route(
}
if deser_device_keys.device_id != sender_device {
return Err!(Request(Unknown(
"Device ID in keys uploaded does not match your own device ID"
"Device ID in keys uploaded ({}) does not match your own device ID ({})",
deser_device_keys.device_id,
sender_device,
)));
}
+2
View File
@@ -14,6 +14,7 @@
pub(super) mod media_legacy;
pub(super) mod membership;
pub(super) mod message;
pub(super) mod oidc;
pub(super) mod openid;
pub(super) mod presence;
pub(super) mod profile;
@@ -58,6 +59,7 @@
pub(super) use membership::*;
pub use membership::{join_room_by_id_helper, leave_all_rooms, leave_room, remote_leave_room};
pub(super) use message::*;
pub(super) use oidc::*;
pub(super) use openid::*;
pub(super) use presence::*;
pub(super) use profile::*;
+137
View File
@@ -0,0 +1,137 @@
use axum::extract::{Query, State};
use conduwuit::{Result, err, utils::ReadyExt};
use conduwuit_web::oidc::{
AuthorizationQuery, OidcRequest, OidcResponse, oidc_consent_form, oidc_login_form,
};
use oxide_auth::{
endpoint::{OwnerConsent, Solicitation},
frontends::simple::endpoint::FnSolicitor,
};
use percent_encoding::percent_decode_str;
use ruma::UserId;
use service::oidc::{SCOPE_PREFIX_API, SCOPE_PREFIX_DEVICE};
/// # `GET /_matrix/client/unstable/org.matrix.msc2964/authorize`
///
/// Authenticate a user and device, and solicit the user's consent.
///
/// Redirects to the login page if no token or token not belonging to any user.
/// [super::login::oidc_login] takes it up at the same point, so it's either
/// the client has a token, or the user does user password. Then the user gets
/// access to stage two, [authorize_consent].
pub(crate) async fn authorize(
State(services): State<crate::State>,
Query(query): Query<AuthorizationQuery>,
oauth: OidcRequest,
) -> Result<OidcResponse> {
tracing::trace!("processing OAuth request: {query:#?}");
// Enforce MSC2964's restrictions on OAuth2 flow.
let Ok(scope) = percent_decode_str(&query.scope).decode_utf8() else {
return Err(err!(Request(Unknown("the scope could not be percent-decoded"))));
};
if !scope.contains(&format!("{SCOPE_PREFIX_API}*")) {
return Err(err!(Request(Unknown("the scope does not include the client API"))));
}
if !scope.contains(SCOPE_PREFIX_DEVICE) {
return Err(err!(Request(Unknown("the scope does not include a device ID"))));
}
if query.code_challenge_method != "S256" {
return Err(err!(Request(Unknown("unsupported code challenge method"))));
}
// Redirect to the login page if no token or token not known.
let hostname = services.config.server_name.host();
let Some(token) = oauth.authorization_header() else {
return Ok(oidc_login_form(hostname, &query));
};
tracing::debug!("submitting OIDC authorisation for token : {token:#?}");
// Get the user id from the token and add it to the query.
let (owner_id, _) = services.oidc.user_and_device_from_token(token).await?;
let mut query_with_user_id = query.clone();
query_with_user_id.username = Some(owner_id.localpart().to_owned());
services
.oidc
.endpoint()
.with_solicitor(oidc_consent_form(hostname, &query_with_user_id))
.authorization_flow()
.execute(oauth)
.map_err(|err| err!("authorization failed: {err:?}"))
}
/// Whether a user allows their device to access this homeserver's resources.
#[derive(serde::Deserialize)]
pub(crate) struct Allowance {
allow: Option<String>,
}
/// # `POST /_matrix/client/unstable/org.matrix.msc2964/authorize?allow=[Option<String>]`
///
/// Authorize the device based on the owner's consent. If the owner allows
/// it to access their data, the client may request a token at the
/// [super::token::token] endpoint.
///
/// On the owner's consent, if their specific device is unregistered it will be
/// registered in their device list (not to be confused with the OIDC client
/// registration).
pub(crate) async fn authorize_consent(
Query(Allowance { allow }): Query<Allowance>,
State(services): State<crate::State>,
Query(query): Query<AuthorizationQuery>,
oauth: OidcRequest,
) -> Result<OidcResponse> {
tracing::debug!("processing owner's consent: {:?}", allow);
tracing::trace!("owner's consent request: {:#?}", query);
let Some(owner_id) = allow.clone() else {
return Err(err!(Request(Unknown("the owner did not consent to the client's access"))));
};
let server_name = services.globals.server_name();
let owner_id = UserId::parse_with_server_name(owner_id.clone(), server_name)
.map_err(|err| err!(Request(InvalidUsername("invalid username {owner_id:?}: {err}"))))?;
let Some(matrix_client) = services
.oidc
.client_from_client_id(&query.client_id)
.await?
else {
return Err(err!(Request(Unknown(
"no client has registered client_id {:?}",
query.client_id
))));
};
let scope = query.scope.parse().map_err(|err| {
err!(Request(Unknown("could not parse scope {:?}: {}", query.scope, err)))
})?;
let device_id = services.oidc.device_id_from_scope(&scope)?;
// Check that the device is registered in the owner devices list.
// Note that this is _not_ the OIDC client registration.
let device_is_registered_with_owner = services
.users
.all_device_ids(&owner_id)
.ready_any(|v| v == device_id)
.await;
if !device_is_registered_with_owner {
// TODO get the client's IP from the request.
let client_ip = None;
services
.oidc
.register_device(
&query.client_id,
(&owner_id, &device_id),
matrix_client.name.as_deref(),
client_ip,
)
.await?;
}
services
.oidc
.endpoint()
.with_solicitor(FnSolicitor(move |_: &mut _, _: Solicitation<'_>| match allow.clone() {
| None => OwnerConsent::Denied,
| Some(user_id) => OwnerConsent::Authorized(user_id),
}))
.authorization_flow()
.execute(oauth)
.map_err(|err| err!(Request(Unknown("consent request failed: {err:?}"))))
}
+93
View File
@@ -0,0 +1,93 @@
/// Manual implementation of [MSC2965]'s OIDC server discovery.
///
/// [MSC2965]: https://github.com/matrix-org/matrix-spec-proposals/pull/2965
use axum::extract::State;
use conduwuit::Result;
use ruma::{
api::client::{
discovery::get_authorization_server_metadata::msc2965::{
self, AccountManagementAction, AuthorizationServerMetadata, CodeChallengeMethod,
GrantType, Prompt, ResponseMode, ResponseType,
},
error::{
Error as ClientError, ErrorBody as ClientErrorBody, ErrorKind as ClientErrorKind,
},
},
serde::Raw,
};
use crate::{Ruma, RumaResponse, conduwuit::Error};
/// # `GET /_matrix/client/unstable/org.matrix.msc2965/auth_metadata`
///
/// If `globals.auth.enable_oidc_login` is set, advertise this homeserver's
/// OAuth2 endpoints. Otherwise, MSC2965 requires that the homeserver responds
/// with 404/M_UNRECOGNIZED.
pub(crate) async fn get_auth_metadata(
State(services): State<crate::State>,
_body: Ruma<msc2965::Request>,
) -> Result<RumaResponse<msc2965::Response>> {
let unrecognized_error = Err(Error::Ruma(ClientError::new(
http::StatusCode::NOT_FOUND,
ClientErrorBody::Standard {
kind: ClientErrorKind::Unrecognized,
message: "This homeserver has disabled OIDC authentication.".to_owned(),
},
)));
let Some(ref auth) = services.server.config.auth else {
return unrecognized_error;
};
if !auth.enable_oidc_login {
return unrecognized_error;
}
// Advertise this homeserver's access URL as the issuer URL.
// Unwrap all Url::parse() calls because the issuer URL is validated at startup.
let issuer = services.server.config.well_known.client.as_ref().unwrap();
let account_management_uri = auth.enable_oidc_account_management.then_some(
issuer
.join("/_matrix/client/unstable/org.matrix.msc2964/account")
.unwrap(),
);
// Build up metadata with primitives from ruma::api::client::msc2965.
let metadata = AuthorizationServerMetadata {
issuer: issuer.clone(),
authorization_endpoint: issuer
.join("/_matrix/client/unstable/org.matrix.msc2964/authorize")
.unwrap(),
device_authorization_endpoint: Some(
issuer
.join("/_matrix/client/unstable/org.matrix.msc2964/device")
.unwrap(),
),
token_endpoint: issuer
.join("/_matrix/client/unstable/org.matrix.msc2964/token")
.unwrap(),
registration_endpoint: Some(
issuer
.join("/_matrix/client/unstable/org.matrix.msc2964/device/register")
.unwrap(),
),
revocation_endpoint: issuer
.join("/_matrix/client/unstable/org.matrix.msc2964/revoke")
.unwrap(),
response_types_supported: [ResponseType::Code].into(),
grant_types_supported: [GrantType::AuthorizationCode, GrantType::RefreshToken].into(),
response_modes_supported: [ResponseMode::Fragment, ResponseMode::Query].into(),
code_challenge_methods_supported: [CodeChallengeMethod::S256].into(),
account_management_uri,
account_management_actions_supported: [
AccountManagementAction::Profile,
AccountManagementAction::SessionView,
AccountManagementAction::SessionEnd,
]
.into(),
prompt_values_supported: match services.server.config.allow_registration {
| true => vec![Prompt::Create],
| false => vec![],
},
};
let metadata = Raw::new(&metadata).expect("authorization server metadata should serialize");
Ok(RumaResponse(msc2965::Response::new(metadata)))
}
+48
View File
@@ -0,0 +1,48 @@
use axum::extract::State;
use conduwuit::{Result, err, utils::hash::verify_password};
use conduwuit_web::oidc::{LoginError, LoginQuery, OidcRequest, OidcResponse, oidc_consent_form};
use ruma::user_id::UserId;
//#[axum::debug_handler]
/// # `POST /_matrix/client/unstable/org.matrix.msc2964/login`
///
/// Display a login UI to the user and return an authorization code on success.
/// We presume that the OAuth2 query parameters are provided in the form.
/// With the code, the client may then access stage two,
/// [super::authorize::authorize_consent].
pub(crate) async fn oidc_login(
State(services): State<crate::State>,
request: OidcRequest,
) -> Result<OidcResponse> {
let query: LoginQuery = request.clone().try_into().map_err(|LoginError(err)| {
err!(Request(InvalidParam("Cannot process login form. {err}")))
})?;
tracing::trace!("processing login query {:#?}", query.clone());
// Only accept local usernames. Mostly to simplify things at first.
let user_id =
UserId::parse_with_server_name(query.username.clone(), &services.config.server_name)
.map_err(|e| err!(Request(InvalidUsername("Username is invalid: {e}"))))?;
if !services.users.exists(&user_id).await {
return Err(err!(Request(Unknown("unknown username"))));
}
let valid_hash = services.users.password_hash(&user_id).await?;
if valid_hash.is_empty() {
return Err(err!(Request(UserDeactivated("the user's hash was not found"))));
}
if verify_password(&query.password, &valid_hash).is_err() {
return Err(err!(Request(InvalidParam("password does not match"))));
}
// TODO check if user disabled, etc. See /src/api/client/session.rs
let hostname = services.config.server_name.host();
tracing::info!("logging in {user_id:?}");
services
.oidc
.endpoint()
.with_solicitor(oidc_consent_form(hostname, &query.into()))
.authorization_flow()
.execute(request)
.map_err(|err| err!(Request(Unknown("authorisation failed: {err:?}"))))
}
+27
View File
@@ -0,0 +1,27 @@
//! OIDC
//!
//! Stands for OpenID Connect, and is an authentication scheme relying on
//! OAuth2. The [MSC2964] Matrix Spec Proposal describes an authentication
//! process based on the OIDC flow, with restrictions. See the [sample flow] for
//! details on what's expected.
//!
//! This module implements the needed endpoints. It relies on the [oxide-auth]
//! crate, and the [`service::oidc`] and [`web::oidc`] modules.
//!
//! [MSC2964]: https://github.com/matrix-org/matrix-spec-proposals/pull/2964
//! [oxide-auth]: https://docs.rs/oxide-auth
//! [sample flow]: https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oauth2-profile/proposals/2964-oauth2-profile.md#sample-flow
mod authorize;
mod discovery;
mod login;
mod register;
mod token;
pub(crate) use self::{
authorize::{authorize, authorize_consent},
discovery::get_auth_metadata,
login::oidc_login,
register::register_client,
token::token,
};
+139
View File
@@ -0,0 +1,139 @@
use axum::{Json, extract::State};
use conduwuit::{Result, err};
use conduwuit_service::oidc::normalize_redirect;
use oxide_auth::primitives::prelude::Client;
use reqwest::Url;
use ruma::{ClientSecret, DeviceId, identifiers_validation};
/// The required parameters to register a new client for OAuth2 application.
/// See the required metadata in OAuth2 authorization grant flow in [MSC2966].
///
/// [MSC2966]: https://github.com/matrix-org/matrix-spec-proposals/pull/2966
#[derive(serde::Deserialize, Clone, Debug)]
pub(crate) struct ClientQuery {
/// Human-readable name.
client_name: String,
/// A public page that tells more about the client. All other links must be
/// within.
client_uri: Url,
/// Redirect URIs declared by the client. At least one.
redirect_uris: Vec<Url>,
/// Must include the literal "code".
response_types: Vec<String>,
/// Must include the literals "authorization_code" and "refresh_token".
grant_types: Vec<String>,
/// How the client intends to authenticate its requests. Can be "none",
/// meaning that the client will negotiate its token with the
/// "authorization code" flow.
token_endpoint_auth_method: String,
/// Link to the logo.
logo_uri: Option<Url>,
/// Link to the client's policy.
policy_uri: Option<Url>,
/// Link to the terms of service.
tos_uri: Option<Url>,
/// Can be "native", implying localhost or reserved redirect pages.
/// Defaults to "web" if not present.
application_type: Option<String>,
}
/// A successful response that the client was registered.
#[derive(serde::Serialize, Debug)]
pub(crate) struct ClientResponse {
client_id: String,
/// If the client is private, the secret it authenticates itself with.
client_secret: Option<String>,
/// If there's a `client_secret`, its expiration date in seconds since
/// 1970-01-01T00:00. Some(0) means no expiration date.
client_secret_expires_at: Option<u32>,
client_name: String,
/// Points to the "about" page of the client.
client_uri: Url,
logo_uri: Option<Url>,
tos_uri: Option<Url>,
policy_uri: Option<Url>,
/// Registered redirect uris, which will be matched against when
/// authenticating. If a localhost address, must contain instances of
/// oxide-auth's `RegisteredUrl::IgnorePortOnLocalhost` to let
/// authorization flow through any port over localhost.
redirect_uris: Vec<Url>,
token_endpoint_auth_method: String,
response_types: Vec<String>,
grant_types: Vec<String>,
application_type: Option<String>,
}
/// # `GET /_matrix/client/unstable/org.matrix.msc2964/device/register`
///
/// Register a client, as specified in [MSC2966]. This client, "device" in OIDC
/// parlance, will have the right to submit [super::authorize::authorize]
/// requests.
///
/// [MSC2966]: https://github.com/matrix-org/matrix-spec-proposals/pull/2966
pub(crate) async fn register_client(
State(services): State<crate::State>,
Json(client): Json<ClientQuery>,
) -> Result<Json<ClientResponse>> {
tracing::trace!("processing OIDC device register request for client: {client:#?}");
if client.redirect_uris.is_empty() {
return Err(err!(Request(Unknown(
"the client's registration request should contain at least a redirect_uri"
))));
}
let mut redirect_uris = client.redirect_uris.clone();
let redirect_uri = redirect_uris.pop().expect("at least one redirect_uri");
let redirect_uri = normalize_redirect(redirect_uri);
let remaining_uris = redirect_uris.into_iter().map(normalize_redirect).collect();
let device_id = DeviceId::new();
// Only provide a default scope, we'll test the client's proposed scope for
// consent anyway.
let scope = "default".parse().unwrap();
// TODO check if the users service needs an update.
//services.users.update_device_metadata();
// If the client cannot authenticate itself at the token endpoint, then
// it's a public client. This is usually the case in Matrix.
let is_private = client.token_endpoint_auth_method != "none";
let client_secret = match is_private {
| true => {
let secret = ClientSecret::new();
identifiers_validation::client_secret::validate(secret.as_str())?;
Some(secret.to_string())
},
| false => None,
};
let registration = match is_private {
| true => &Client::confidential(
device_id.as_ref(),
redirect_uri,
scope,
client_secret.as_ref().unwrap().as_bytes(),
)
.with_additional_redirect_uris(remaining_uris),
| _ => &Client::public(device_id.as_ref(), redirect_uri, scope)
.with_additional_redirect_uris(remaining_uris),
};
tracing::trace!("registering OIDC device : {registration:#?}");
services
.oidc
.register_client(Some(client.client_name.clone()), registration);
let client_response = ClientResponse {
client_id: device_id.to_string(),
client_secret,
client_secret_expires_at: if is_private { Some(0) } else { None },
client_name: client.client_name.clone(),
client_uri: client.client_uri.clone(),
redirect_uris: client.redirect_uris.clone(),
logo_uri: client.logo_uri.clone(),
policy_uri: client.policy_uri.clone(),
tos_uri: client.tos_uri.clone(),
token_endpoint_auth_method: client.token_endpoint_auth_method.clone(),
response_types: client.response_types.clone(),
grant_types: client.grant_types.clone(),
application_type: client.application_type,
};
tracing::debug!("OIDC device registered : {client_response:#?}");
Ok(Json(client_response))
}
+35
View File
@@ -0,0 +1,35 @@
use axum::extract::State;
use conduwuit::{Result, err};
use conduwuit_web::oidc::{OidcRequest, OidcResponse};
use oxide_auth::endpoint::QueryParameter;
/// # `POST /_matrix/client/unstable/org.matrix.msc2964/token`
///
/// Depending on `grant_type`, either deliver a new token to a device, and store
/// it in the server's ring, or refresh the token.
pub(crate) async fn token(
State(services): State<crate::State>,
oauth: OidcRequest,
) -> Result<OidcResponse> {
tracing::trace!("processing OpenID token request {:#?}", oauth);
let Some(body) = oauth.body() else {
return Err(err!(Request(Unknown("OAuth request had an empty body"))));
};
let grant_type = body
.unique_value("grant_type")
.map(|value| value.to_string());
let endpoint = services.oidc.endpoint();
tracing::debug!("submitting OpenID token request for grant type {grant_type:?}");
match grant_type.as_deref() {
| Some("authorization_code") => endpoint
.access_token_flow()
.execute(oauth)
.map_err(|err| err!(Request(Unknown("token grant failed: {err:?}")))),
| Some("refresh_token") => endpoint
.refresh_flow()
.execute(oauth)
.map_err(|err| err!(Request(Unknown("token refresh failed: {err:?}")))),
| other => Err(err!(Request(Unknown("unsupported grant type: {other:?}")))),
}
}
+12 -2
View File
@@ -3,7 +3,9 @@
use futures::StreamExt;
use ruma::api::client::{
discovery::{
discover_homeserver::{self, HomeserverInfo, SlidingSyncProxyInfo},
discover_homeserver::{
self, AuthenticationServerInfo, HomeserverInfo, SlidingSyncProxyInfo,
},
discover_support::{self, Contact},
},
error::ErrorKind,
@@ -26,8 +28,16 @@ pub(crate) async fn well_known_client(
Ok(discover_homeserver::Response {
homeserver: HomeserverInfo { base_url: client_url.clone() },
identity_server: None,
sliding_sync_proxy: Some(SlidingSyncProxyInfo { url: client_url }),
sliding_sync_proxy: Some(SlidingSyncProxyInfo { url: client_url.clone() }),
tile_server: None,
authentication: services.config.auth.as_ref().and_then(|auth| {
auth.enable_oidc_login
.then_some(AuthenticationServerInfo::new(
client_url.clone(),
auth.enable_oidc_account_management
.then_some(format!("{client_url}/account")),
))
}),
})
}
+15
View File
@@ -115,6 +115,21 @@ pub fn build(router: Router<State>, server: &Server) -> Router<State> {
.ruma_route(&client::get_protocols_route)
.route("/_matrix/client/unstable/thirdparty/protocols",
get(client::get_protocols_route_unstable))
// MSC2965: OAuth 2.0 Authorization Server Metadata discovery.
.route("/_matrix/client/unstable/org.matrix.msc2965/auth_metadata",
get(client::get_auth_metadata))
// MSC2964: Usage of OAuth 2.0 authorization code grant and refresh token grant.
.route("/_matrix/client/unstable/org.matrix.msc2964/authorize",
get(client::authorize))
.route("/_matrix/client/unstable/org.matrix.msc2964/authorize",
post(client::authorize_consent))
.route("/_matrix/client/unstable/org.matrix.msc2964/login",
post(client::oidc_login))
.route("/_matrix/client/unstable/org.matrix.msc2964/token",
post(client::token))
// MSC2966: Usage of OAuth 2.0 Dynamic Client Registration in Matrix.
.route("/_matrix/client/unstable/org.matrix.msc2964/device/register",
post(client::register_client))
.ruma_route(&client::send_message_event_route)
.ruma_route(&client::send_state_event_for_key_route)
.ruma_route(&client::get_state_events_route)
+9
View File
@@ -289,6 +289,15 @@ pub fn check(config: &Config) -> Result {
));
}
if let Some(auth) = &config.auth {
if auth.enable_oidc_login && config.well_known.client.is_none() {
return Err!(Config(
"auth.enable_oidc_login",
"OIDC authentication is enabled but the well-known client is not set."
));
}
}
Ok(())
}
+25 -2
View File
@@ -53,7 +53,7 @@
### For more information, see:
### https://continuwuity.org/configuration.html
"#,
ignore = "catchall well_known tls blurhashing allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure"
ignore = "catchall auth well_known tls blurhashing allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure"
)]
pub struct Config {
/// The server_name is the pretty name of this server. It is used as a
@@ -62,7 +62,8 @@ pub struct Config {
/// See the docs for reverse proxying and delegation:
/// https://continuwuity.org/deploying/generic.html#setting-up-the-reverse-proxy
///
/// Also see the `[global.well_known]` config section at the very bottom.
/// Also see the `[global.auth]` and `[global.well_known]` config sections
/// at the very bottom.
///
/// Examples of delegation:
/// - https://puppygock.gay/.well-known/matrix/server
@@ -104,6 +105,9 @@ pub struct Config {
#[serde(default)]
pub tls: TlsConfig,
// external structure; separate section
pub auth: Option<AuthConfig>,
/// The UNIX socket continuwuity will listen on.
///
/// continuwuity cannot listen on both an IP address and a UNIX socket. If
@@ -2020,6 +2024,25 @@ pub struct TlsConfig {
pub dual_protocol: bool,
}
#[allow(rustdoc::broken_intra_doc_links, rustdoc::bare_urls)]
#[derive(Clone, Debug, Deserialize, Default)]
#[config_example_generator(filename = "conduwuit-example.toml", section = "global.auth")]
pub struct AuthConfig {
/// Use this homeserver as the OIDC authentication reference. It will
/// advertise itself as the OIDC authentication issuer to new clients,
/// and use the internal user database to answer on the advertised
/// endpoints. Note that the legacy Matrix authentication still will be
/// reachable.
/// Unset by default.
pub enable_oidc_login: bool,
/// Whether this homeserver should provide users with an account management
/// interface. Only used if `enable_oidc_login` is set. Note that the
/// endpoint is unimplemented at the moment.
/// Unset by default.
pub enable_oidc_account_management: bool,
}
#[allow(rustdoc::broken_intra_doc_links, rustdoc::bare_urls)]
#[derive(Clone, Debug, Deserialize, Default)]
#[config_example_generator(filename = "conduwuit-example.toml", section = "global.well_known")]
+8
View File
@@ -434,6 +434,14 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "userroomid_notificationcount",
..descriptor::RANDOM
},
Descriptor {
name: "client_registrar",
..descriptor::RANDOM
},
Descriptor {
name: "deviceid_clientidmap",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "userroomid_invitesender",
..descriptor::RANDOM_SMALL
+2
View File
@@ -125,6 +125,8 @@ ctor.workspace = true
[target.'cfg(all(unix, target_os = "linux"))'.dependencies]
sd-notify.workspace = true
sd-notify.optional = true
oxide-auth.workspace = true
once_cell.workspace = true
[lints]
workspace = true
+1
View File
@@ -19,6 +19,7 @@
pub mod key_backups;
pub mod media;
pub mod moderation;
pub mod oidc;
pub mod presence;
pub mod pusher;
pub mod resolver;
+361
View File
@@ -0,0 +1,361 @@
//! OIDC service.
//!
//! Provides the registrar, authorizer and issuer needed by [api::client::oidc].
//! The whole OIDC OAuth2 flow is taken care of by [oxide-auth].
//!
//! [oxide-auth]: https://docs.rs/oxide-auth
use std::{
borrow::Cow,
sync::{Arc, Mutex},
};
use async_trait::async_trait;
use conduwuit::{Result, err};
use conduwuit_core::utils;
use database::{Deserialized, Json, Map};
use oxide_auth::{
endpoint::{PreGrant, Scope},
frontends::simple::endpoint::{Generic, Vacant},
primitives::{
grant::Grant,
prelude::{
AuthMap, Authorizer, Client, ClientUrl, Issuer, RandomGenerator, Registrar, TokenMap,
},
registrar::{
Argon2, BoundClient, EncodedClient, RegisteredClient, RegisteredUrl, RegistrarError,
},
},
};
use ruma::{
MilliSecondsSinceUnixEpoch, OwnedDeviceId, OwnedUserId, UserId, api::client::device::Device,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{Dep, globals};
pub const SCOPE_PREFIX_DEVICE: &str = "urn:matrix:org.matrix.msc2967.client:device:";
pub const SCOPE_PREFIX_API: &str = "urn:matrix:org.matrix.msc2967.client:api:";
static PASSWORD_POLICY: std::sync::LazyLock<Argon2> = std::sync::LazyLock::new(Argon2::default);
/// A client app that connects to continuwuity via OIDC, as recorded in the
/// database.
#[derive(Clone, Serialize, Deserialize)]
pub struct OidcClient {
/// The name published by the app itself.
pub name: Option<String>,
/// A device id that we'll generate on OIDC registration.
pub device_id: Option<String>,
/// The device's coordinates recorded by oxide-auth.
pub client: EncodedClient,
}
struct Services {
globals: Dep<globals::Service>,
}
struct Data {
client_registrar: Arc<Map>,
deviceid_clientidmap: Arc<Map>,
userid_devicelistversion: Arc<Map>,
userdeviceid_metadata: Arc<Map>,
}
pub struct Service {
/// Authorization tokens are 16 byte random keys to a memory hash map.
///
/// Will be reinitialised on continuwuity's restart.
authorizer: Mutex<AuthMap<RandomGenerator>>,
/// Bearer tokens are also random generated but 256-bit tokens, since they
/// live longer.
///
/// We could also use a `TokenSigner::ephemeral` here to create signed
/// tokens which can be read and parsed by anyone, but not maliciously
/// created. However, they can not be revoked and thus don't offer even
/// longer lived refresh tokens.
///
/// Will be reinitialised on continuwuity's restart.
issuer: Mutex<TokenMap<RandomGenerator>>,
services: Services,
db: Data,
}
#[async_trait]
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
// TODO implement authorizer and issuer inside database so that token
// requests survive server restarts.
Ok(Arc::new(Self {
authorizer: Mutex::new(AuthMap::new(RandomGenerator::new(16))),
issuer: Mutex::new(TokenMap::new(RandomGenerator::new(16))),
services: Services {
globals: args.depend::<globals::Service>("globals"),
},
db: Data {
client_registrar: args.db["client_registrar"].clone(),
deviceid_clientidmap: args.db["deviceid_clientidmap"].clone(),
userid_devicelistversion: args.db["userid_devicelistversion"].clone(),
userdeviceid_metadata: args.db["userdeviceid_metadata"].clone(),
},
}))
}
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
}
impl Service {
/// Register an OIDC client in the client_registrar for future
/// authentication flows.
pub fn register_client(&self, display_name: Option<String>, client: &Client) {
let client = client.clone().encode(&*PASSWORD_POLICY);
let client_id = client.client_id.clone();
let oidc_client = OidcClient {
name: display_name,
// Matrix clients have no device_id at registration time.
device_id: None,
client,
};
self.db.client_registrar.put(client_id, Json(oidc_client));
}
/// Register a device in the main continuwuity database. This should only
/// happen on successful authentication and consent, and will register the
/// client's device_id.
pub async fn register_device(
&self,
client_id: &str,
(user_id, device_id): (&OwnedUserId, &OwnedDeviceId),
display_name: Option<&str>,
client_ip: Option<String>,
) -> Result<()> {
let device_key = (user_id, device_id);
let device = Device {
device_id: device_id.into(),
display_name: display_name.map(ToOwned::to_owned),
last_seen_ip: client_ip,
last_seen_ts: Some(MilliSecondsSinceUnixEpoch::now()),
};
increment(&self.db.userid_devicelistversion, user_id.as_bytes()).await;
self.db.userdeviceid_metadata.put(device_key, Json(device));
let mut client: OidcClient = self
.db
.client_registrar
.get(client_id)
.await?
.deserialized()?;
client.device_id = Some(device_id.to_string());
self.db
.client_registrar
.put(client_id.to_owned(), Json(client));
self.db
.deviceid_clientidmap
.put(device_id, client_id.to_owned());
Ok(())
}
fn grant_from_token(&self, token: &str) -> Option<Grant> {
let issuer = self.issuer.lock().expect("lockable issuer");
issuer
.recover_token(token)
.expect("infallible recover_token implementation")
}
pub async fn client_from_client_id(&self, client_id: &str) -> Result<Option<OidcClient>> {
self.db
.client_registrar
.get(client_id)
.await?
.deserialized()
}
pub async fn client_from_device_id(
&self,
device_id: OwnedDeviceId,
) -> Result<Option<OidcClient>> {
let client_id: String = self
.db
.deviceid_clientidmap
.get(&device_id)
.await?
.deserialized()?;
self.db
.client_registrar
.get(&client_id)
.await?
.deserialized()
}
pub fn device_id_from_scope(&self, scope: &Scope) -> Result<OwnedDeviceId> {
let Some(device_id) = scope.iter().find(|s| s.starts_with(SCOPE_PREFIX_DEVICE)) else {
tracing::warn!("device_id not found in scope {scope:?}");
return Err(err!(Request(InvalidParam("something went wrong with the scope"))));
};
let device_id = device_id.replace(SCOPE_PREFIX_DEVICE, "");
Ok(device_id.into())
}
pub async fn user_and_device_from_token(
&self,
token: &str,
) -> Result<(OwnedUserId, OwnedDeviceId)> {
let Some(Grant { owner_id, client_id, .. }) = self.grant_from_token(token) else {
return Err(err!(Request(MissingToken("unknown token: {token:?}"))));
};
let server_name = self.services.globals.server_name();
let owner_id =
UserId::parse_with_server_name(owner_id.clone(), server_name).map_err(|err| {
err!(Request(InvalidUsername("invalid username {owner_id:?}: {err}")))
})?;
let client = self
.client_from_client_id(&client_id)
.await?
.expect("validated client_id");
let Some(device_id) = client.device_id else {
return Err(err!(Request(Unknown("this client has no device_id yet"))));
};
let device_id = OwnedDeviceId::from(device_id);
Ok((owner_id, device_id))
}
/// The oxide-auth carry-all endpoint.
pub fn endpoint(
&self,
) -> Generic<impl Registrar + '_, impl Authorizer + '_, impl Issuer + '_> {
Generic {
registrar: self,
authorizer: self.authorizer.lock().unwrap(),
issuer: self.issuer.lock().unwrap(),
// Solicitor configured later.
solicitor: Vacant,
// Scope configured later.
scopes: Vacant,
response: Vacant,
}
}
}
async fn increment(db: &Arc<Map>, key: &[u8]) {
let old = db.get(key).await;
let new = utils::increment(old.ok().as_deref());
db.insert(key, new);
}
/// Substitute "127.0.0.1" and "[::1]" for "localhost" to let oxide-auth compare
/// them ignoring their port.
pub fn normalize_redirect_hostname(url: &mut Url) {
let new_host = url.host_str().map(|h| {
h.replace("127.0.0.1", "localhost")
.replace("[::1]", "localhost")
});
url.set_host(new_host.as_deref())
.expect("replaceable redirect hostname");
}
/// If `url` is a localhost (either 'localhost', '127.0.0.1' or '[::1]'), wrap
/// it in an `IgnorePortOnLocalhost`, so that oxide-auth ignores the port when
/// comparing it with the registered ones.
#[must_use]
pub fn normalize_redirect(mut url: Url) -> RegisteredUrl {
normalize_redirect_hostname(&mut url);
match url.host_str() {
| Some("localhost") => RegisteredUrl::IgnorePortOnLocalhost(url.into()),
| _ => RegisteredUrl::Semantic(url),
}
}
/// Let this service act as an oxide-auth `Registrar`.
impl Registrar for Service {
fn bound_redirect<'a>(
&self,
bound: ClientUrl<'a>,
) -> Result<BoundClient<'a>, RegistrarError> {
let client_handle = self
.db
.client_registrar
.get_blocking(bound.client_id.as_ref())
.map_err(|_| RegistrarError::Unspecified)?;
let oidc_client: OidcClient = client_handle
.deserialized()
.map_err(|_| RegistrarError::Unspecified)?;
let client = oidc_client.client;
// Perform exact matching as motivated in the rfc, but substitute
// "127.0.0.1" and "[::1]" for "localhost" to let oxide-auth ignore
// their port.
let redirect_uri = bound.redirect_uri;
let normalized_uri = redirect_uri.clone().map(|u| normalize_redirect(u.to_url()));
let redirect_uri = match normalized_uri {
| None => client.redirect_uri,
| Some(url) => {
let original = std::iter::once(&client.redirect_uri);
let alternatives = client.additional_redirect_uris.iter();
if original
.chain(alternatives)
.any(|registered| *registered == url)
{
// If normalized_uri is Some(url), so is redirect_uri, so unwrap().
redirect_uri.unwrap().into_owned().into()
} else {
tracing::debug!(
"the request's redirect url didn't match any registered. bound: {:?}, \
in client {:#?}",
url,
client
);
return Err(RegistrarError::Unspecified);
}
},
};
Ok(BoundClient {
client_id: bound.client_id,
redirect_uri: Cow::Owned(redirect_uri),
})
}
fn negotiate(
&self,
bound: BoundClient<'_>,
_scope: Option<Scope>,
) -> Result<PreGrant, RegistrarError> {
let client_handle = self
.db
.client_registrar
.get_blocking(bound.client_id.as_ref())
.map_err(|_| RegistrarError::Unspecified)?;
let oidc_client: OidcClient = client_handle
.deserialized()
.map_err(|_| RegistrarError::Unspecified)?;
Ok(PreGrant {
client_id: bound.client_id.into_owned(),
redirect_uri: bound.redirect_uri.into_owned(),
// Always use the client's scope.
scope: oidc_client.client.default_scope,
})
}
fn check(&self, client_id: &str, passphrase: Option<&[u8]>) -> Result<(), RegistrarError> {
let client_handle = self
.db
.client_registrar
.get_blocking(client_id)
.map_err(|_| RegistrarError::Unspecified)?;
let oidc_client: OidcClient = client_handle
.deserialized()
.map_err(|_| RegistrarError::Unspecified)?;
let client = oidc_client.client;
RegisteredClient::new(&client, &*PASSWORD_POLICY).check_authentication(passphrase)
}
}
+3 -1
View File
@@ -11,7 +11,7 @@
account_data, admin, announcements, appservice, client, config, emergency, federation,
globals, key_backups,
manager::Manager,
media, moderation, presence, pusher, resolver, rooms, sending, server_keys, service,
media, moderation, oidc, presence, pusher, resolver, rooms, sending, server_keys, service,
service::{Args, Map, Service},
sync, transaction_ids, uiaa, users,
};
@@ -39,6 +39,7 @@ pub struct Services {
pub users: Arc<users::Service>,
pub moderation: Arc<moderation::Service>,
pub announcements: Arc<announcements::Service>,
pub oidc: Arc<oidc::Service>,
manager: Mutex<Option<Arc<Manager>>>,
pub(crate) service: Arc<Map>,
@@ -107,6 +108,7 @@ macro_rules! build {
users: build!(users::Service),
moderation: build!(moderation::Service),
announcements: build!(announcements::Service),
oidc: build!(oidc::Service),
manager: Mutex::new(None),
service,
+33 -3
View File
@@ -29,7 +29,7 @@
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{Dep, account_data, admin, appservice, globals, rooms};
use crate::{Dep, account_data, admin, appservice, globals, oidc, rooms};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserSuspension {
@@ -52,6 +52,7 @@ struct Services {
admin: Dep<admin::Service>,
appservice: Dep<appservice::Service>,
globals: Dep<globals::Service>,
oidc: Dep<oidc::Service>,
state_accessor: Dep<rooms::state_accessor::Service>,
state_cache: Dep<rooms::state_cache::Service>,
}
@@ -89,6 +90,7 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
account_data: args.depend::<account_data::Service>("account_data"),
admin: args.depend::<admin::Service>("admin"),
appservice: args.depend::<appservice::Service>("appservice"),
oidc: args.depend::<oidc::Service>("oidc"),
globals: args.depend::<globals::Service>("globals"),
state_accessor: args
.depend::<rooms::state_accessor::Service>("rooms::state_accessor"),
@@ -270,7 +272,18 @@ pub async fn count(&self) -> usize { self.db.userid_password.count().await }
/// Find out which user an access token belongs to.
pub async fn find_from_token(&self, token: &str) -> Result<(OwnedUserId, OwnedDeviceId)> {
self.db.token_userdeviceid.get(token).await.deserialized()
if self
.services
.server
.config
.auth
.as_ref()
.is_some_and(|auth| auth.enable_oidc_login)
{
self.services.oidc.user_and_device_from_token(token).await
} else {
self.db.token_userdeviceid.get(token).await.deserialized()
}
}
/// Returns an iterator over all users on this homeserver (offered for
@@ -536,7 +549,24 @@ pub async fn add_one_time_key(
// Only existing devices should be able to call this, but we shouldn't assert
// either...
let key = (user_id, device_id);
if self.db.userdeviceid_metadata.qry(&key).await.is_err() {
if self
.services
.server
.config
.auth
.as_ref()
.is_some_and(|auth| auth.enable_oidc_login)
{
if self
.services
.oidc
.client_from_device_id(device_id.into())
.await?
.is_none()
{
return Err!(Database(error!(?user_id, ?device_id, "Device has no metadata.")));
}
} else if self.db.userdeviceid_metadata.qry(&key).await.is_err() {
return Err!(Database(error!(
?user_id,
?device_id,
+6
View File
@@ -22,6 +22,8 @@ crate-type = [
[dependencies]
conduwuit-build-metadata.workspace = true
conduwuit-service.workspace = true
conduwuit-core.workspace = true
async-trait.workspace = true
askama = "0.14.0"
@@ -30,6 +32,10 @@ futures.workspace = true
tracing.workspace = true
rand.workspace = true
thiserror.workspace = true
serde.workspace = true
url.workspace = true
percent-encoding.workspace = true
oxide-auth.workspace = true
[lints]
workspace = true
+1
View File
@@ -8,6 +8,7 @@
};
use conduwuit_build_metadata::{GIT_REMOTE_COMMIT_URL, GIT_REMOTE_WEB_URL, version_tag};
use conduwuit_service::state;
pub mod oidc;
pub fn build() -> Router<state::State> {
let router = Router::<state::State>::new();
+60
View File
@@ -0,0 +1,60 @@
use askama::Template;
use conduwuit_build_metadata::version_tag;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
// Imports needed by askama templates.
use crate::{GIT_REMOTE_COMMIT_URL, GIT_REMOTE_WEB_URL};
mod authorize;
mod consent;
mod error;
mod login;
mod request;
mod response;
pub use authorize::AuthorizationQuery;
pub use consent::oidc_consent_form;
pub use error::OidcError;
pub use login::{LoginError, LoginQuery, oidc_login_form};
pub use request::OidcRequest;
pub use response::OidcResponse;
/// The parameters for the OIDC login page template.
#[derive(Template)]
#[template(path = "login.html.j2")]
pub(crate) struct LoginPageTemplate<'a> {
nonce: &'a str,
hostname: &'a str,
route: &'a str,
client_id: &'a str,
client_secret: Option<&'a str>,
redirect_uri: &'a str,
scope: &'a str,
state: &'a str,
code_challenge: &'a str,
code_challenge_method: &'a str,
response_type: &'a str,
response_mode: &'a str,
}
/// The parameters for the OIDC consent page template.
#[derive(Template)]
#[template(path = "consent.html.j2")]
pub(crate) struct ConsentPageTemplate<'a> {
nonce: &'a str,
hostname: &'a str,
route: &'a str,
user_id: &'a str,
client_id: &'a str,
client_secret: Option<&'a str>,
redirect_uri: &'a str,
scope: &'a str,
state: &'a str,
code_challenge: &'a str,
code_challenge_method: &'a str,
response_type: &'a str,
response_mode: &'a str,
}
pub(crate) fn encode(text: &str) -> String {
utf8_percent_encode(text, NON_ALPHANUMERIC).to_string()
}
+49
View File
@@ -0,0 +1,49 @@
use url::Url;
use super::LoginQuery;
/// The set of parameters required for an OIDC authorization request.
#[derive(serde::Deserialize, Debug, Clone)]
pub struct AuthorizationQuery {
pub client_id: String,
pub client_secret: Option<String>,
pub redirect_uri: Url,
pub scope: String,
pub state: String,
pub code_challenge: String,
pub code_challenge_method: String,
pub response_type: String,
pub response_mode: Option<String>,
pub username: Option<String>,
}
impl From<LoginQuery> for AuthorizationQuery {
fn from(value: LoginQuery) -> Self {
let LoginQuery {
client_id,
client_secret,
redirect_uri,
scope,
state,
code_challenge,
code_challenge_method,
response_type,
response_mode,
username,
..
} = value;
Self {
client_id,
client_secret,
redirect_uri,
scope,
state,
code_challenge,
code_challenge_method,
response_type,
response_mode: Some(response_mode),
username: Some(username),
}
}
}
+55
View File
@@ -0,0 +1,55 @@
use askama::Template;
use axum::http::StatusCode;
use oxide_auth::frontends::simple::request::Body;
use super::{AuthorizationQuery, ConsentPageTemplate, OidcResponse, encode};
/// A web consent solicitor form for the OIDC authentication flow.
///
/// Asks the resource owner for their consent to let a client access their data
/// on this server.
#[must_use]
pub fn oidc_consent_form(hostname: &str, query: &AuthorizationQuery) -> OidcResponse {
// The target request route.
let route = "/_matrix/client/unstable/org.matrix.msc2964/authorize";
let nonce = rand::random::<u64>().to_string();
let body = Some(Body::Text(consent_page(hostname, query, route, &nonce)));
OidcResponse {
status: StatusCode::OK,
location: None,
www_authenticate: None,
body,
nonce: Some(nonce),
}
}
/// Render the html contents of the user consent page.
fn consent_page(hostname: &str, query: &AuthorizationQuery, route: &str, nonce: &str) -> String {
let response_mode = query
.response_mode
.as_deref()
.unwrap_or("fragment")
.to_owned();
let user_id = query
.username
.clone()
.expect("user_id in authorization query");
let template = ConsentPageTemplate {
nonce,
hostname,
route,
user_id: &encode(&user_id),
client_id: &encode(query.client_id.as_str()),
client_secret: query.client_secret.as_deref(),
redirect_uri: &encode(query.redirect_uri.as_str()),
scope: &encode(query.scope.as_str()),
state: &encode(query.state.as_str()),
code_challenge: &encode(query.code_challenge.as_str()),
code_challenge_method: &encode(query.code_challenge_method.as_str()),
response_type: &encode(query.response_type.as_str()),
response_mode: &encode(response_mode.as_str()),
};
template.render().expect("consent page render")
}
+78
View File
@@ -0,0 +1,78 @@
use axum::{
http::{StatusCode, header::InvalidHeaderValue},
response::{IntoResponse, Response},
};
use oxide_auth::frontends::{dev::OAuthError, simple::endpoint::Error};
use super::OidcRequest;
#[derive(Debug)]
/// The error type for Oxide Auth operations
pub enum OidcError {
/// Errors occurring in Endpoint operations
Endpoint(OAuthError),
/// Errors occurring in Endpoint operations
Header(InvalidHeaderValue),
/// Errors with the request encoding
Encoding,
/// Request body could not be parsed as a form
Form,
/// Request query was absent or could not be parsed
Query,
/// Request query was absent or could not be parsed
Body,
/// The Authorization header was invalid
Authorization,
/// General internal server error
InternalError(Option<String>),
}
impl std::fmt::Display for OidcError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
| Self::Endpoint(ref e) => write!(f, "Endpoint, {e}"),
| Self::Header(ref e) => write!(f, "Couldn't set header, {e}"),
| Self::Encoding => write!(f, "Error decoding request"),
| Self::Form => write!(f, "Request is not a form"),
| Self::Query => write!(f, "No query present"),
| Self::Body => write!(f, "No body present"),
| Self::Authorization => write!(f, "Request has invalid Authorization headers"),
| Self::InternalError(None) => write!(f, "An internal server error occurred"),
| Self::InternalError(Some(ref e)) =>
write!(f, "An internal server error occurred: {e}"),
}
}
}
impl std::error::Error for OidcError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match *self {
| Self::Endpoint(ref e) => e.source(),
| Self::Header(ref e) => e.source(),
| _ => None,
}
}
}
impl IntoResponse for OidcError {
fn into_response(self) -> Response {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
}
}
impl From<Error<OidcRequest>> for OidcError {
fn from(e: Error<OidcRequest>) -> Self {
match e {
| Error::Web(e) => e,
| Error::OAuth(e) => e.into(),
}
}
}
impl From<OAuthError> for OidcError {
fn from(e: OAuthError) -> Self { Self::Endpoint(e) }
}
impl From<InvalidHeaderValue> for OidcError {
fn from(e: InvalidHeaderValue) -> Self { Self::Header(e) }
}
+126
View File
@@ -0,0 +1,126 @@
use std::{borrow::Cow, str::FromStr};
use askama::Template;
use axum::http::StatusCode;
use oxide_auth::{endpoint::QueryParameter, frontends::simple::request::Body};
use url::Url;
use super::{AuthorizationQuery, LoginPageTemplate, OidcRequest, OidcResponse};
/// The set of query parameters a client needs to get authorization.
#[derive(serde::Deserialize, Debug, Clone)]
pub struct LoginQuery {
pub username: String,
pub password: String,
pub client_id: String,
pub client_secret: Option<String>,
pub redirect_uri: Url,
pub scope: String,
pub state: String,
pub code_challenge: String,
pub code_challenge_method: String,
pub response_type: String,
pub response_mode: String,
}
#[derive(Debug)]
pub struct LoginError(pub String);
impl TryFrom<OidcRequest> for LoginQuery {
type Error = LoginError;
fn try_from(value: OidcRequest) -> Result<Self, LoginError> {
let body = value.body().expect("body in OidcRequest");
let Some(username) = body.unique_value("username") else {
return Err(LoginError("missing field: username".to_owned()));
};
let Some(password) = body.unique_value("password") else {
return Err(LoginError("missing field: password".to_owned()));
};
let Some(client_id) = body.unique_value("client_id") else {
return Err(LoginError("missing field: client_id".to_owned()));
};
let Some(redirect_uri) = body.unique_value("redirect_uri") else {
return Err(LoginError("missing field: redirect_uri".to_owned()));
};
let Some(scope) = body.unique_value("scope") else {
return Err(LoginError("missing field: scope".to_owned()));
};
let Some(state) = body.unique_value("state") else {
return Err(LoginError("missing field: state".to_owned()));
};
let Some(code_challenge) = body.unique_value("code_challenge") else {
return Err(LoginError("missing field: code_challenge".to_owned()));
};
let Some(code_challenge_method) = body.unique_value("code_challenge_method") else {
return Err(LoginError("missing field: code_challenge_method".to_owned()));
};
let Some(response_type) = body.unique_value("response_type") else {
return Err(LoginError("missing field: response_type".to_owned()));
};
let Ok(redirect_uri) = Url::from_str(&redirect_uri) else {
return Err(LoginError("invalid field: redirect_uri".to_owned()));
};
// response_mode is not strictly needed : it must be the literal "fragment"
// when over https. It's required by the spec but Fractal doesn't provide it.
let response_mode = body
.unique_value("response_mode")
.unwrap_or(Cow::Borrowed("fragment"));
let client_secret = body.unique_value("client_secret").map(|s| s.to_string());
Ok(Self {
username: username.to_string(),
password: password.to_string(),
client_id: client_id.to_string(),
client_secret,
redirect_uri,
scope: scope.to_string(),
state: state.to_string(),
code_challenge: code_challenge.to_string(),
code_challenge_method: code_challenge_method.to_string(),
response_type: response_type.to_string(),
response_mode: response_mode.to_string(),
})
}
}
/// A web login form for the OIDC authentication flow.
///
/// The returned `OidcResponse` handles CSP headers to allow that form.
#[must_use]
pub fn oidc_login_form(hostname: &str, query: &AuthorizationQuery) -> OidcResponse {
// The target request route.
let route = "/_matrix/client/unstable/org.matrix.msc2964/login";
let nonce = rand::random::<u64>().to_string();
let body = Some(Body::Text(login_page(hostname, query, route, &nonce)));
OidcResponse {
status: StatusCode::OK,
location: None,
www_authenticate: None,
body,
nonce: Some(nonce),
}
}
/// Render the html contents of the login page.
fn login_page(hostname: &str, query: &AuthorizationQuery, route: &str, nonce: &str) -> String {
let response_mode = query.response_mode.as_deref().unwrap_or("fragment");
let template = LoginPageTemplate {
nonce,
hostname,
route,
client_id: query.client_id.as_str(),
client_secret: query.client_secret.as_deref(),
redirect_uri: query.redirect_uri.as_str(),
scope: query.scope.as_str(),
state: query.state.as_str(),
code_challenge: query.code_challenge.as_str(),
code_challenge_method: query.code_challenge_method.as_str(),
response_type: query.response_type.as_str(),
response_mode,
};
template.render().expect("login template render")
}
+118
View File
@@ -0,0 +1,118 @@
use std::borrow::Cow;
use async_trait::async_trait;
use axum::{
extract::{Form, FromRequest, FromRequestParts, Query, Request},
http::header,
};
use oxide_auth::endpoint::{NormalizedParameter, QueryParameter, WebRequest};
use super::{OidcError, OidcResponse};
/// An OIDC authentication request.
///
/// Expected to receive GET and POST requests to the `authorize` endpoint, or
/// POST requests to the `login` endpoint.
///
/// Mostly adapted from the OAuthRequest struct in the [oxide-auth-axum] crate.
/// [oxide-auth-axum]: https://docs.rs/oxide-auth-axum
#[derive(Clone, Debug)]
pub struct OidcRequest {
pub(crate) auth: Option<String>,
pub(crate) query: Option<NormalizedParameter>,
pub(crate) body: Option<NormalizedParameter>,
}
impl OidcRequest {
/// Fetch the authorization header from the request
#[must_use]
pub fn authorization_header(&self) -> Option<&str> { self.auth.as_deref() }
/// Fetch the query for this request
#[must_use]
pub fn query(&self) -> Option<&NormalizedParameter> { self.query.as_ref() }
/// Fetch the query mutably
pub fn query_mut(&mut self) -> Option<&mut NormalizedParameter> { self.query.as_mut() }
/// Fetch the body of the request
#[must_use]
pub fn body(&self) -> Option<&NormalizedParameter> { self.body.as_ref() }
}
impl WebRequest for OidcRequest {
type Error = OidcError;
type Response = OidcResponse;
fn query(&mut self) -> Result<Cow<'_, dyn QueryParameter + 'static>, Self::Error> {
self.query
.as_ref()
.map(|q| {
let q: &dyn QueryParameter = q;
Cow::Borrowed(q)
})
.ok_or(OidcError::Query)
}
fn urlbody(&mut self) -> Result<Cow<'_, dyn QueryParameter + 'static>, Self::Error> {
self.body
.as_ref()
.map(|q| {
let q: &dyn QueryParameter = q;
Cow::Borrowed(q)
})
.ok_or(OidcError::Body)
}
fn authheader(&mut self) -> Result<Option<Cow<'_, str>>, Self::Error> {
Ok(self.auth.as_deref().map(Cow::Borrowed))
}
}
#[async_trait]
impl<S> FromRequest<S> for OidcRequest
where
S: Send + Sync,
{
type Rejection = OidcError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let mut all_auth = req.headers().get_all(header::AUTHORIZATION).iter();
let optional = all_auth.next();
let auth = if all_auth.next().is_some() {
return Err(OidcError::Authorization);
} else {
optional.and_then(|hv| hv.to_str().ok().map(str::to_owned))
};
let (mut parts, body) = req.into_parts();
let query = Query::from_request_parts(&mut parts, state)
.await
.ok()
.map(|q: Query<NormalizedParameter>| q.0);
let req = Request::from_parts(parts, body);
let body = Form::from_request(req, state)
.await
.ok()
.map(|b: Form<NormalizedParameter>| b.0);
// If the query is empty and the body has a request, copy it over
// because login forms are POST requests but OAuth flow expects
// arguments in query.
let query = match query {
| None => body.clone(),
| Some(params) => {
//if params == NormalizedParameter::new() {
if params.unique_value("client_id").is_none() {
body.clone()
} else {
Some(params)
}
},
};
Ok(Self { auth, query, body })
}
}
+117
View File
@@ -0,0 +1,117 @@
use axum::{
body::Body,
http::{Response, StatusCode, header},
response::IntoResponse,
};
use oxide_auth::{
endpoint::{OwnerConsent, OwnerSolicitor, Solicitation, WebRequest, WebResponse},
frontends::simple::request::Body as OAuthRequestBody,
};
use url::Url;
use super::{LoginQuery, OidcError, OidcRequest, oidc_consent_form};
/// A Web response that can be processed by the OIDC authentication flow before
/// being sent over.
#[derive(Default, Clone, Debug)]
pub struct OidcResponse {
pub(crate) status: StatusCode,
pub(crate) location: Option<Url>,
pub(crate) www_authenticate: Option<String>,
pub(crate) body: Option<OAuthRequestBody>,
pub(crate) nonce: Option<String>,
}
impl IntoResponse for OidcResponse {
fn into_response(self) -> Response<Body> {
let csp_src = match self.nonce {
| Some(nonce) => &format!("default-src 'nonce-{nonce}';"),
| None => "default-src 'none';",
};
let csp_form_action =
"form-action 'self' http://localhost http://127.0.0.1 http://[::1];";
let content_csp = format!("{csp_src} {csp_form_action}");
let content_type = match self.body {
| Some(OAuthRequestBody::Json(_)) => "application/json",
| _ => "text/html",
};
let mut response = Response::builder()
.status(self.status)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_SECURITY_POLICY, content_csp);
if let Some(location) = self.location {
response = response.header(header::LOCATION, location.as_str());
}
// Transform from OAuthRequestBody to String.
let body_content = self.body.map(|b| b.as_str().to_owned()).unwrap_or_default();
response.body(body_content.into()).unwrap()
}
}
/// OidcResponse uses [super::oidc_consent_form] to be turned into an owner
/// consent solicitation.
impl OwnerSolicitor<OidcRequest> for OidcResponse {
fn check_consent(
&mut self,
request: &mut OidcRequest,
_: Solicitation<'_>,
) -> OwnerConsent<<OidcRequest as WebRequest>::Response> {
// TODO find a way to pass the hostname to the template.
let hostname = "Continuwuity";
let query: LoginQuery = request
.clone()
.try_into()
.expect("login query from OidcRequest");
OwnerConsent::InProgress(oidc_consent_form(hostname, &query.into()))
}
}
impl WebResponse for OidcResponse {
type Error = OidcError;
fn ok(&mut self) -> Result<(), Self::Error> {
self.status = StatusCode::OK;
Ok(())
}
/// A response which will redirect the user-agent to which the response is
/// issued.
fn redirect(&mut self, url: Url) -> Result<(), Self::Error> {
self.status = StatusCode::FOUND;
self.location = Some(url);
Ok(())
}
/// Set the response status to 400.
fn client_error(&mut self) -> Result<(), Self::Error> {
self.status = StatusCode::BAD_REQUEST;
Ok(())
}
/// Set the response status to 401 and add a `WWW-Authenticate` header.
fn unauthorized(&mut self, header_value: &str) -> Result<(), Self::Error> {
self.status = StatusCode::UNAUTHORIZED;
self.www_authenticate = Some(header_value.to_owned());
Ok(())
}
/// A pure text response with no special media type set.
fn body_text(&mut self, text: &str) -> Result<(), Self::Error> {
self.body = Some(OAuthRequestBody::Text(text.to_owned()));
Ok(())
}
/// Json response data, with media type `aplication/json.
fn body_json(&mut self, data: &str) -> Result<(), Self::Error> {
self.body = Some(OAuthRequestBody::Json(data.to_owned()));
Ok(())
}
}
+16
View File
@@ -0,0 +1,16 @@
{% extends "_layout.html.j2" %}
{%- block content -%}
<div class="panel">
<h1 class-"project-name">{{ hostname }}</h1>
<p>
'{{ client_id }}' (at {{ redirect_uri }}) is requesting permission for '{{ scope }}'
</p>
<form method="post">
<input type="submit" value="Accept" formaction="{{ route }}?client_id={{ client_id }}{%- if let Some(secret)
= client_secret -%}&client_secret={{ secret }}{%- endif -%}&redirect_uri={{ redirect_uri }}&scope={{ scope
}}&state={{ state }}&code_challenge={{ code_challenge }}&code_challenge_method={{ code_challenge_method
}}&response_type={{ response_type }}&response_mode={{ response_mode }}&allow={{ user_id }}">
<input type="submit" value="Deny" formaction="{{ route }}?client_id={{ client_id }}{%- if let Some(secret) = client_secret -%}&client_secret={{ secret }}{%- endif -%}&redirect_uri={{ redirect_uri }}&scope={{scope }}&state={{ state }}&code_challenge={{ code_challenge }}&code_challenge_method={{ code_challenge_method }}&response_type={{ response_type }}&response_mode={{ response_mode }}&deny=true">
</form>
</div>
{%- endblock content -%}
+22
View File
@@ -0,0 +1,22 @@
{% extends "_layout.html.j2" %}
{%- block content -%}
<div class="panel">
<h1 class-"project-name">{{ hostname }}</h1>
<form action="{{ route }}" method="post">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<input type="hidden" name="client_id" value="{{ client_id }}">
{%- if let Some(secret) = client_secret -%}
<input type="hidden" name="client_secret" value="{{ secret }}">
{%- endif -%}
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
<input type="hidden" name="scope" value="{{ scope }}">
<input type="hidden" name="state" value="{{ state }}">
<input type="hidden" name="code_challenge" value="{{ code_challenge }}">
<input type="hidden" name="code_challenge_method" value="{{ code_challenge_method }}">
<input type="hidden" name="response_type" value="{{ response_type }}">
<input type="hidden" name="response_mode" value="{{ response_mode }}">
<button type="submit">Login</button>
</form>
</div>
{%- endblock content -%}