diff --git a/Cargo.lock b/Cargo.lock index 1c00b8a00..191e76f2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3106,7 +3106,7 @@ dependencies = [ [[package]] name = "mas-axum-utils" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "anyhow", "axum", @@ -3140,7 +3140,7 @@ dependencies = [ [[package]] name = "mas-cli" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "anyhow", "axum", @@ -3213,7 +3213,7 @@ dependencies = [ [[package]] name = "mas-config" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "anyhow", "camino", @@ -3245,7 +3245,7 @@ dependencies = [ [[package]] name = "mas-context" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "console", "opentelemetry", @@ -3261,7 +3261,7 @@ dependencies = [ [[package]] name = "mas-data-model" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "base64ct", "chrono", @@ -3284,7 +3284,7 @@ dependencies = [ [[package]] name = "mas-email" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "async-trait", "lettre", @@ -3295,7 +3295,7 @@ dependencies = [ [[package]] name = "mas-handlers" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "aide", "anyhow", @@ -3375,7 +3375,7 @@ dependencies = [ [[package]] name = "mas-http" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "futures-util", "headers", @@ -3396,7 +3396,7 @@ dependencies = [ [[package]] name = "mas-i18n" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "camino", "icu_calendar", @@ -3418,7 +3418,7 @@ dependencies = [ [[package]] name = "mas-i18n-scan" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "camino", "clap", @@ -3432,7 +3432,7 @@ dependencies = [ [[package]] name = "mas-iana" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "schemars 0.8.22", "serde", @@ -3440,7 +3440,7 @@ dependencies = [ [[package]] name = "mas-iana-codegen" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "anyhow", "async-trait", @@ -3456,7 +3456,7 @@ dependencies = [ [[package]] name = "mas-jose" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "base64ct", "chrono", @@ -3486,7 +3486,7 @@ dependencies = [ [[package]] name = "mas-keystore" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "aead", "base64ct", @@ -3514,7 +3514,7 @@ dependencies = [ [[package]] name = "mas-listener" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "anyhow", "bytes", @@ -3539,7 +3539,7 @@ dependencies = [ [[package]] name = "mas-matrix" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "anyhow", "async-trait", @@ -3549,7 +3549,7 @@ dependencies = [ [[package]] name = "mas-matrix-synapse" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "anyhow", "async-trait", @@ -3566,7 +3566,7 @@ dependencies = [ [[package]] name = "mas-oidc-client" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "assert_matches", "async-trait", @@ -3602,7 +3602,7 @@ dependencies = [ [[package]] name = "mas-policy" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "anyhow", "arc-swap", @@ -3619,7 +3619,7 @@ dependencies = [ [[package]] name = "mas-router" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "axum", "serde", @@ -3630,7 +3630,7 @@ dependencies = [ [[package]] name = "mas-spa" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "camino", "serde", @@ -3639,7 +3639,7 @@ dependencies = [ [[package]] name = "mas-storage" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "async-trait", "chrono", @@ -3661,7 +3661,7 @@ dependencies = [ [[package]] name = "mas-storage-pg" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "async-trait", "chrono", @@ -3688,7 +3688,7 @@ dependencies = [ [[package]] name = "mas-tasks" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "anyhow", "async-trait", @@ -3720,7 +3720,7 @@ dependencies = [ [[package]] name = "mas-templates" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "anyhow", "arc-swap", @@ -3750,7 +3750,7 @@ dependencies = [ [[package]] name = "mas-tower" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "http", "opentelemetry", @@ -4020,7 +4020,7 @@ dependencies = [ [[package]] name = "oauth2-types" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "assert_matches", "base64ct", @@ -6103,7 +6103,7 @@ dependencies = [ [[package]] name = "syn2mas" -version = "1.3.0-rc.0" +version = "1.3.0" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index b99c6aa31..dda7224d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["crates/*"] resolver = "2" # Updated in the CI with a `sed` command -package.version = "1.3.0-rc.0" +package.version = "1.3.0" package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial" package.authors = ["Element Backend Team"] package.edition = "2024" @@ -34,35 +34,35 @@ broken_intra_doc_links = "deny" [workspace.dependencies] # Workspace crates -mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.3.0-rc.0" } -mas-cli = { path = "./crates/cli/", version = "=1.3.0-rc.0" } -mas-config = { path = "./crates/config/", version = "=1.3.0-rc.0" } -mas-context = { path = "./crates/context/", version = "=1.3.0-rc.0" } -mas-data-model = { path = "./crates/data-model/", version = "=1.3.0-rc.0" } -mas-email = { path = "./crates/email/", version = "=1.3.0-rc.0" } -mas-graphql = { path = "./crates/graphql/", version = "=1.3.0-rc.0" } -mas-handlers = { path = "./crates/handlers/", version = "=1.3.0-rc.0" } -mas-http = { path = "./crates/http/", version = "=1.3.0-rc.0" } -mas-i18n = { path = "./crates/i18n/", version = "=1.3.0-rc.0" } -mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.3.0-rc.0" } -mas-iana = { path = "./crates/iana/", version = "=1.3.0-rc.0" } -mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.3.0-rc.0" } -mas-jose = { path = "./crates/jose/", version = "=1.3.0-rc.0" } -mas-keystore = { path = "./crates/keystore/", version = "=1.3.0-rc.0" } -mas-listener = { path = "./crates/listener/", version = "=1.3.0-rc.0" } -mas-matrix = { path = "./crates/matrix/", version = "=1.3.0-rc.0" } -mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.3.0-rc.0" } -mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.3.0-rc.0" } -mas-policy = { path = "./crates/policy/", version = "=1.3.0-rc.0" } -mas-router = { path = "./crates/router/", version = "=1.3.0-rc.0" } -mas-spa = { path = "./crates/spa/", version = "=1.3.0-rc.0" } -mas-storage = { path = "./crates/storage/", version = "=1.3.0-rc.0" } -mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.3.0-rc.0" } -mas-tasks = { path = "./crates/tasks/", version = "=1.3.0-rc.0" } -mas-templates = { path = "./crates/templates/", version = "=1.3.0-rc.0" } -mas-tower = { path = "./crates/tower/", version = "=1.3.0-rc.0" } -oauth2-types = { path = "./crates/oauth2-types/", version = "=1.3.0-rc.0" } -syn2mas = { path = "./crates/syn2mas", version = "=1.3.0-rc.0" } +mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.3.0" } +mas-cli = { path = "./crates/cli/", version = "=1.3.0" } +mas-config = { path = "./crates/config/", version = "=1.3.0" } +mas-context = { path = "./crates/context/", version = "=1.3.0" } +mas-data-model = { path = "./crates/data-model/", version = "=1.3.0" } +mas-email = { path = "./crates/email/", version = "=1.3.0" } +mas-graphql = { path = "./crates/graphql/", version = "=1.3.0" } +mas-handlers = { path = "./crates/handlers/", version = "=1.3.0" } +mas-http = { path = "./crates/http/", version = "=1.3.0" } +mas-i18n = { path = "./crates/i18n/", version = "=1.3.0" } +mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.3.0" } +mas-iana = { path = "./crates/iana/", version = "=1.3.0" } +mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.3.0" } +mas-jose = { path = "./crates/jose/", version = "=1.3.0" } +mas-keystore = { path = "./crates/keystore/", version = "=1.3.0" } +mas-listener = { path = "./crates/listener/", version = "=1.3.0" } +mas-matrix = { path = "./crates/matrix/", version = "=1.3.0" } +mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.3.0" } +mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.3.0" } +mas-policy = { path = "./crates/policy/", version = "=1.3.0" } +mas-router = { path = "./crates/router/", version = "=1.3.0" } +mas-spa = { path = "./crates/spa/", version = "=1.3.0" } +mas-storage = { path = "./crates/storage/", version = "=1.3.0" } +mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.3.0" } +mas-tasks = { path = "./crates/tasks/", version = "=1.3.0" } +mas-templates = { path = "./crates/templates/", version = "=1.3.0" } +mas-tower = { path = "./crates/tower/", version = "=1.3.0" } +oauth2-types = { path = "./crates/oauth2-types/", version = "=1.3.0" } +syn2mas = { path = "./crates/syn2mas", version = "=1.3.0" } # OpenAPI schema generation and validation [workspace.dependencies.aide] diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index 2670d35ab..e5e158be3 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -91,6 +91,11 @@ fn finish(t: TransformOpenApi) -> TransformOpenApi { ), ..Default::default() }) + .tag(Tag { + name: "upstream-oauth-provider".to_owned(), + description: Some("Manage upstream OAuth 2.0 providers".to_owned()), + ..Tag::default() + }) .security_scheme("oauth2", oauth_security_scheme(None)) .security_scheme( "token", diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index 2f6648402..c21e22fd7 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -695,3 +695,79 @@ impl UserRegistrationToken { ] } } + +/// An upstream OAuth 2.0 provider +#[derive(Serialize, JsonSchema)] +pub struct UpstreamOAuthProvider { + #[serde(skip)] + id: Ulid, + + /// The OIDC issuer of the provider + issuer: Option, + + /// A human-readable name for the provider + human_name: Option, + + /// A brand identifier, e.g. "apple" or "google" + brand_name: Option, + + /// When the provider was created + created_at: DateTime, + + /// When the provider was disabled. If null, the provider is enabled. + disabled_at: Option>, +} + +impl From for UpstreamOAuthProvider { + fn from(provider: mas_data_model::UpstreamOAuthProvider) -> Self { + Self { + id: provider.id, + issuer: provider.issuer, + human_name: provider.human_name, + brand_name: provider.brand_name, + created_at: provider.created_at, + disabled_at: provider.disabled_at, + } + } +} + +impl Resource for UpstreamOAuthProvider { + const KIND: &'static str = "upstream-oauth-provider"; + const PATH: &'static str = "/api/admin/v1/upstream-oauth-providers"; + + fn id(&self) -> Ulid { + self.id + } +} + +impl UpstreamOAuthProvider { + /// Samples of upstream OAuth 2.0 providers + pub fn samples() -> [Self; 3] { + [ + Self { + id: Ulid::from_bytes([0x01; 16]), + issuer: Some("https://accounts.google.com".to_owned()), + human_name: Some("Google".to_owned()), + brand_name: Some("google".to_owned()), + created_at: DateTime::default(), + disabled_at: None, + }, + Self { + id: Ulid::from_bytes([0x02; 16]), + issuer: Some("https://appleid.apple.com".to_owned()), + human_name: Some("Apple ID".to_owned()), + brand_name: Some("apple".to_owned()), + created_at: DateTime::default(), + disabled_at: Some(DateTime::default()), + }, + Self { + id: Ulid::from_bytes([0x03; 16]), + issuer: None, + human_name: Some("Custom OAuth Provider".to_owned()), + brand_name: None, + created_at: DateTime::default(), + disabled_at: None, + }, + ] + } +} diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index afe71a05f..8a182bf2f 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -23,6 +23,7 @@ mod oauth2_sessions; mod policy_data; mod site_config; mod upstream_oauth_links; +mod upstream_oauth_providers; mod user_emails; mod user_registration_tokens; mod user_sessions; @@ -187,4 +188,11 @@ where self::upstream_oauth_links::delete_doc, ), ) + .api_route( + "/upstream-oauth-providers", + get_with( + self::upstream_oauth_providers::list, + self::upstream_oauth_providers::list_doc, + ), + ) } diff --git a/crates/handlers/src/admin/v1/upstream_oauth_providers/list.rs b/crates/handlers/src/admin/v1/upstream_oauth_providers/list.rs new file mode 100644 index 000000000..dc5f2cc9c --- /dev/null +++ b/crates/handlers/src/admin/v1/upstream_oauth_providers/list.rs @@ -0,0 +1,554 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{ + Json, + extract::{Query, rejection::QueryRejection}, + response::IntoResponse, +}; +use axum_macros::FromRequestParts; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_storage::{Page, upstream_oauth2::UpstreamOAuthProviderFilter}; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, UpstreamOAuthProvider}, + params::Pagination, + response::{ErrorResponse, PaginatedResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)] +#[serde(rename = "UpstreamOAuthProviderFilter")] +#[aide(input_with = "Query")] +#[from_request(via(Query), rejection(RouteError))] +pub struct FilterParams { + /// Retrieve providers that are (or are not) enabled + #[serde(rename = "filter[enabled]")] + enabled: Option, +} + +impl std::fmt::Display for FilterParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut sep = '?'; + + if let Some(enabled) = self.enabled { + write!(f, "{sep}filter[enabled]={enabled}")?; + sep = '&'; + } + + let _ = sep; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Invalid filter parameters")] + InvalidFilter(#[from] QueryRejection), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, + }; + + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("listUpstreamOAuthProviders") + .summary("List upstream OAuth 2.0 providers") + .tag("upstream-oauth-provider") + .response_with::<200, Json>, _>(|t| { + let providers = UpstreamOAuthProvider::samples(); + let pagination = mas_storage::Pagination::first(providers.len()); + let page = Page { + edges: providers.into(), + has_next_page: true, + has_previous_page: false, + }; + + t.description("Paginated response of upstream OAuth 2.0 providers") + .example(PaginatedResponse::new( + page, + pagination, + 42, + UpstreamOAuthProvider::PATH, + )) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_providers.list", skip_all)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + Pagination(pagination): Pagination, + params: FilterParams, +) -> Result>, RouteError> { + let base = format!("{path}{params}", path = UpstreamOAuthProvider::PATH); + let filter = UpstreamOAuthProviderFilter::new(); + + let filter = match params.enabled { + Some(true) => filter.enabled_only(), + Some(false) => filter.disabled_only(), + None => filter, + }; + + let page = repo + .upstream_oauth_provider() + .list(filter, pagination) + .await?; + let count = repo.upstream_oauth_provider().count(filter).await?; + + Ok(Json(PaginatedResponse::new( + page.map(UpstreamOAuthProvider::from), + pagination, + count, + &base, + ))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use mas_data_model::{ + UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, + UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderPkceMode, + UpstreamOAuthProviderTokenAuthMethod, + }; + use mas_iana::jose::JsonWebSignatureAlg; + use mas_storage::{ + RepositoryAccess, + upstream_oauth2::{UpstreamOAuthProviderParams, UpstreamOAuthProviderRepository}, + }; + use oauth2_types::scope::{OPENID, Scope}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + async fn create_test_providers(state: &mut TestState) { + let mut repo = state.repository().await.unwrap(); + + // Create an enabled provider + let enabled_params = UpstreamOAuthProviderParams { + issuer: Some("https://accounts.google.com".to_owned()), + human_name: Some("Google".to_owned()), + brand_name: Some("google".to_owned()), + discovery_mode: UpstreamOAuthProviderDiscoveryMode::Oidc, + pkce_mode: UpstreamOAuthProviderPkceMode::Auto, + jwks_uri_override: None, + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + fetch_userinfo: true, + userinfo_signed_response_alg: None, + client_id: "google-client-id".to_owned(), + encrypted_client_secret: Some("encrypted-secret".to_owned()), + token_endpoint_signing_alg: None, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::ClientSecretPost, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + response_mode: None, + scope: Scope::from_iter([OPENID]), + claims_imports: UpstreamOAuthProviderClaimsImports::default(), + additional_authorization_parameters: vec![], + forward_login_hint: false, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + ui_order: 0, + }; + + repo.upstream_oauth_provider() + .add(&mut state.rng(), &state.clock, enabled_params) + .await + .unwrap(); + + // Create a disabled provider + let disabled_params = UpstreamOAuthProviderParams { + issuer: Some("https://appleid.apple.com".to_owned()), + human_name: Some("Apple ID".to_owned()), + brand_name: Some("apple".to_owned()), + discovery_mode: UpstreamOAuthProviderDiscoveryMode::Oidc, + pkce_mode: UpstreamOAuthProviderPkceMode::S256, + jwks_uri_override: None, + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + fetch_userinfo: true, + userinfo_signed_response_alg: None, + client_id: "apple-client-id".to_owned(), + encrypted_client_secret: Some("encrypted-secret".to_owned()), + token_endpoint_signing_alg: None, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::ClientSecretPost, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + response_mode: None, + scope: Scope::from_iter([OPENID]), + claims_imports: UpstreamOAuthProviderClaimsImports::default(), + additional_authorization_parameters: vec![], + forward_login_hint: false, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + ui_order: 1, + }; + + let disabled_provider = repo + .upstream_oauth_provider() + .add(&mut state.rng(), &state.clock, disabled_params) + .await + .unwrap(); + + // Disable the provider + repo.upstream_oauth_provider() + .disable(&state.clock, disabled_provider) + .await + .unwrap(); + + // Create another enabled provider + let another_enabled_params = UpstreamOAuthProviderParams { + issuer: Some("https://login.microsoftonline.com/common/v2.0".to_owned()), + human_name: Some("Microsoft".to_owned()), + brand_name: Some("microsoft".to_owned()), + discovery_mode: UpstreamOAuthProviderDiscoveryMode::Oidc, + pkce_mode: UpstreamOAuthProviderPkceMode::Auto, + jwks_uri_override: None, + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + fetch_userinfo: true, + userinfo_signed_response_alg: None, + client_id: "microsoft-client-id".to_owned(), + encrypted_client_secret: Some("encrypted-secret".to_owned()), + token_endpoint_signing_alg: None, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::ClientSecretPost, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + response_mode: None, + scope: Scope::from_iter([OPENID]), + claims_imports: UpstreamOAuthProviderClaimsImports::default(), + additional_authorization_parameters: vec![], + forward_login_hint: false, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + ui_order: 2, + }; + + repo.upstream_oauth_provider() + .add(&mut state.rng(), &state.clock, another_enabled_params) + .await + .unwrap(); + + Box::new(repo).save().await.unwrap(); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_list_all_providers(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_providers(&mut state).await; + + let request = Request::get("/api/admin/v1/upstream-oauth-providers") + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + // Should return all providers + assert_eq!(body["data"].as_array().unwrap().len(), 3); + + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "issuer": "https://appleid.apple.com", + "human_name": "Apple ID", + "brand_name": "apple", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "issuer": "https://login.microsoftonline.com/common/v2.0", + "human_name": "Microsoft", + "brand_name": "microsoft", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG09AVTNSQFMSR34AJC" + } + }, + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?page[first]=10", + "first": "/api/admin/v1/upstream-oauth-providers?page[first]=10", + "last": "/api/admin/v1/upstream-oauth-providers?page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_filter_by_enabled_true(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_providers(&mut state).await; + + let request = Request::get("/api/admin/v1/upstream-oauth-providers?filter[enabled]=true") + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "issuer": "https://login.microsoftonline.com/common/v2.0", + "human_name": "Microsoft", + "brand_name": "microsoft", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG09AVTNSQFMSR34AJC" + } + }, + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=true&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=true&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=true&page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_filter_by_enabled_false(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_providers(&mut state).await; + + let request = Request::get("/api/admin/v1/upstream-oauth-providers?filter[enabled]=false") + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "issuer": "https://appleid.apple.com", + "human_name": "Apple ID", + "brand_name": "apple", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=false&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=false&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=false&page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_pagination(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_providers(&mut state).await; + + // Test first page with limit of 2 + let request = Request::get("/api/admin/v1/upstream-oauth-providers?page[first]=2") + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "issuer": "https://appleid.apple.com", + "human_name": "Apple ID", + "brand_name": "apple", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "issuer": "https://login.microsoftonline.com/common/v2.0", + "human_name": "Microsoft", + "brand_name": "microsoft", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG09AVTNSQFMSR34AJC" + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?page[first]=2", + "first": "/api/admin/v1/upstream-oauth-providers?page[first]=2", + "last": "/api/admin/v1/upstream-oauth-providers?page[last]=2", + "next": "/api/admin/v1/upstream-oauth-providers?page[after]=01FSHN9AG09AVTNSQFMSR34AJC&page[first]=2" + } + } + "#); + + // Extract the ID of the last item for pagination + let last_item_id = body["data"][1]["id"].as_str().unwrap(); + let request = Request::get(format!( + "/api/admin/v1/upstream-oauth-providers?page[first]=2&page[after]={last_item_id}", + )) + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?page[after]=01FSHN9AG09AVTNSQFMSR34AJC&page[first]=2", + "first": "/api/admin/v1/upstream-oauth-providers?page[first]=2", + "last": "/api/admin/v1/upstream-oauth-providers?page[last]=2" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invalid_filter(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + + let request = + Request::get("/api/admin/v1/upstream-oauth-providers?filter[enabled]=invalid") + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + } +} diff --git a/crates/handlers/src/admin/v1/upstream_oauth_providers/mod.rs b/crates/handlers/src/admin/v1/upstream_oauth_providers/mod.rs new file mode 100644 index 000000000..a04301246 --- /dev/null +++ b/crates/handlers/src/admin/v1/upstream_oauth_providers/mod.rs @@ -0,0 +1,8 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +mod list; + +pub use self::list::{doc as list_doc, handler as list}; diff --git a/docs/api/spec.json b/docs/api/spec.json index 3348cf722..166436454 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -3222,6 +3222,143 @@ } } } + }, + "/api/admin/v1/upstream-oauth-providers": { + "get": { + "tags": [ + "upstream-oauth-provider" + ], + "summary": "List upstream OAuth 2.0 providers", + "operationId": "listUpstreamOAuthProviders", + "parameters": [ + { + "in": "query", + "name": "page[before]", + "description": "Retrieve the items before the given ID", + "schema": { + "description": "Retrieve the items before the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[after]", + "description": "Retrieve the items after the given ID", + "schema": { + "description": "Retrieve the items after the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[first]", + "description": "Retrieve the first N items", + "schema": { + "description": "Retrieve the first N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[last]", + "description": "Retrieve the last N items", + "schema": { + "description": "Retrieve the last N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[enabled]", + "description": "Retrieve providers that are (or are not) enabled", + "schema": { + "description": "Retrieve providers that are (or are not) enabled", + "type": "boolean", + "nullable": true + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "Paginated response of upstream OAuth 2.0 providers", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_for_UpstreamOAuthProvider" + }, + "example": { + "meta": { + "count": 42 + }, + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01040G2081040G2081040G2081", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "1970-01-01T00:00:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01040G2081040G2081040G2081" + } + }, + { + "type": "upstream-oauth-provider", + "id": "02081040G2081040G2081040G2", + "attributes": { + "issuer": "https://appleid.apple.com", + "human_name": "Apple ID", + "brand_name": "apple", + "created_at": "1970-01-01T00:00:00Z", + "disabled_at": "1970-01-01T00:00:00Z" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/02081040G2081040G2081040G2" + } + }, + { + "type": "upstream-oauth-provider", + "id": "030C1G60R30C1G60R30C1G60R3", + "attributes": { + "issuer": null, + "human_name": "Custom OAuth Provider", + "brand_name": null, + "created_at": "1970-01-01T00:00:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/030C1G60R30C1G60R30C1G60R3" + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?page[first]=3", + "first": "/api/admin/v1/upstream-oauth-providers?page[first]=3", + "last": "/api/admin/v1/upstream-oauth-providers?page[last]=3", + "next": "/api/admin/v1/upstream-oauth-providers?page[after]=030C1G60R30C1G60R30C1G60R3&page[first]=3" + } + } + } + } + } + } + } } }, "components": { @@ -4717,6 +4854,105 @@ "$ref": "#/components/schemas/SelfLinks" } } + }, + "UpstreamOAuthProviderFilter": { + "type": "object", + "properties": { + "filter[enabled]": { + "description": "Retrieve providers that are (or are not) enabled", + "type": "boolean", + "nullable": true + } + } + }, + "PaginatedResponse_for_UpstreamOAuthProvider": { + "description": "A top-level response with a page of resources", + "type": "object", + "required": [ + "data", + "links", + "meta" + ], + "properties": { + "meta": { + "description": "Response metadata", + "$ref": "#/components/schemas/PaginationMeta" + }, + "data": { + "description": "The list of resources", + "type": "array", + "items": { + "$ref": "#/components/schemas/SingleResource_for_UpstreamOAuthProvider" + } + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/PaginationLinks" + } + } + }, + "SingleResource_for_UpstreamOAuthProvider": { + "description": "A single resource, with its type, ID, attributes and related links", + "type": "object", + "required": [ + "attributes", + "id", + "links", + "type" + ], + "properties": { + "type": { + "description": "The type of the resource", + "type": "string" + }, + "id": { + "description": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "attributes": { + "description": "The attributes of the resource", + "$ref": "#/components/schemas/UpstreamOAuthProvider" + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/SelfLinks" + } + } + }, + "UpstreamOAuthProvider": { + "description": "An upstream OAuth 2.0 provider", + "type": "object", + "required": [ + "created_at" + ], + "properties": { + "issuer": { + "description": "The OIDC issuer of the provider", + "type": "string", + "nullable": true + }, + "human_name": { + "description": "A human-readable name for the provider", + "type": "string", + "nullable": true + }, + "brand_name": { + "description": "A brand identifier, e.g. \"apple\" or \"google\"", + "type": "string", + "nullable": true + }, + "created_at": { + "description": "When the provider was created", + "type": "string", + "format": "date-time" + }, + "disabled_at": { + "description": "When the provider was disabled. If null, the provider is enabled.", + "type": "string", + "format": "date-time", + "nullable": true + } + } } } }, @@ -4768,6 +5004,10 @@ { "name": "upstream-oauth-link", "description": "Manage links between local users and identities from upstream OAuth 2.0 providers" + }, + { + "name": "upstream-oauth-provider", + "description": "Manage upstream OAuth 2.0 providers" } ] } diff --git a/frontend/.storybook/locales.ts b/frontend/.storybook/locales.ts index 090812bf0..c2acf82ed 100644 --- a/frontend/.storybook/locales.ts +++ b/frontend/.storybook/locales.ts @@ -27,7 +27,7 @@ export type LocalazyMetadata = { }; const localazyMetadata: LocalazyMetadata = { - projectUrl: "https://localazy.com/p/matrix-authentication-service", + projectUrl: "https://localazy.com/p/matrix-authentication-service!v1.3", baseLocale: "en", languages: [ { @@ -172,21 +172,21 @@ const localazyMetadata: LocalazyMetadata = { file: "frontend.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", - "da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", - "de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", - "en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", - "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", - "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", - "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", - "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", - "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", - "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", - "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", - "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", - "sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", - "uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", - "zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" + "cs": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", + "da": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", + "de": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", + "en": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", + "et": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", + "fi": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", + "fr": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", + "hu": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", + "nb_NO": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", + "nl": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", + "pt": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", + "ru": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", + "sv": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", + "uk": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", + "zh#Hans": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" } }, { @@ -194,21 +194,21 @@ const localazyMetadata: LocalazyMetadata = { file: "file.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", - "da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", - "de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", - "en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", - "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", - "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", - "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", - "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", - "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", - "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", - "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", - "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", - "sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", - "uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", - "zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" + "cs": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", + "da": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", + "de": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", + "en": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", + "et": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", + "fi": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", + "fr": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", + "hu": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", + "nb_NO": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", + "nl": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", + "pt": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", + "ru": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", + "sv": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", + "uk": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", + "zh#Hans": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" } } ]