Automatic merge back to main (#5056)

This commit is contained in:
matrixbot
2025-09-23 17:31:02 +02:00
committed by GitHub
9 changed files with 980 additions and 89 deletions

56
Cargo.lock generated
View File

@@ -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",

View File

@@ -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]

View File

@@ -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",

View File

@@ -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<String>,
/// A human-readable name for the provider
human_name: Option<String>,
/// A brand identifier, e.g. "apple" or "google"
brand_name: Option<String>,
/// When the provider was created
created_at: DateTime<Utc>,
/// When the provider was disabled. If null, the provider is enabled.
disabled_at: Option<DateTime<Utc>>,
}
impl From<mas_data_model::UpstreamOAuthProvider> 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,
},
]
}
}

View File

@@ -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,
),
)
}

View File

@@ -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<FilterParams>")]
#[from_request(via(Query), rejection(RouteError))]
pub struct FilterParams {
/// Retrieve providers that are (or are not) enabled
#[serde(rename = "filter[enabled]")]
enabled: Option<bool>,
}
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<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
#[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<PaginatedResponse<UpstreamOAuthProvider>>, _>(|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<Json<PaginatedResponse<UpstreamOAuthProvider>>, 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::<serde_json::Value>();
// 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::<serde_json::Value>();
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::<serde_json::Value>();
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::<serde_json::Value>();
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::<serde_json::Value>();
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);
}
}

View File

@@ -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};

View File

@@ -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"
}
]
}

View File

@@ -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"
}
}
]