From 7260a399885ed286000cf914a2d8efed1673e78b Mon Sep 17 00:00:00 2001 From: scanash00 Date: Wed, 29 Jul 2026 20:26:06 -0800 Subject: [PATCH] fixes --- README.md | 8 +- crates/tranquil-api/src/lib.rs | 8 + crates/tranquil-api/src/server/mod.rs | 5 +- crates/tranquil-api/src/server/reauth.rs | 3 +- crates/tranquil-api/src/server/session.rs | 11 +- .../src/server/trusted_devices.rs | 67 +++++- crates/tranquil-db-traits/src/lib.rs | 2 +- crates/tranquil-db-traits/src/oauth.rs | 13 +- crates/tranquil-db/src/postgres/oauth.rs | 89 ++++++-- .../src/endpoints/account.rs | 56 +++++ .../src/endpoints/authorize/registration.rs | 3 +- .../src/endpoints/mod.rs | 2 + crates/tranquil-oauth-server/src/lib.rs | 1 + crates/tranquil-pds/src/auth/extractor.rs | 1 + crates/tranquil-pds/src/auth/mod.rs | 4 + crates/tranquil-scopes/src/lib.rs | 1 + crates/tranquil-scopes/src/parser.rs | 200 ++++++++++++++++++ crates/tranquil-scopes/src/permission_set.rs | 41 ++-- crates/tranquil-store/src/metastore/client.rs | 25 +++ .../tranquil-store/src/metastore/handler.rs | 27 +++ crates/tranquil-store/src/metastore/mod.rs | 71 +++++++ .../tranquil-store/src/metastore/oauth_ops.rs | 112 +++++++--- .../src/metastore/oauth_schema.rs | 7 + .../dashboard/SecurityContent.svelte | 71 ++++++- .../migration/AppPasswordStep.svelte | 19 +- frontend/src/lib/api.ts | 32 +++ frontend/src/lib/auth.svelte.ts | 5 + frontend/src/lib/types/api.ts | 9 + frontend/src/locales/en.json | 22 +- frontend/src/locales/fi.json | 13 ++ frontend/src/locales/fr.json | 13 ++ frontend/src/locales/ja.json | 13 ++ frontend/src/locales/ko.json | 13 ++ frontend/src/locales/sv.json | 13 ++ frontend/src/locales/zh.json | 13 ++ .../src/routes/SsoRegisterComplete.svelte | 21 +- frontend/src/styles/base.css | 6 + frontend/src/tests/AppPasswordStep.test.ts | 26 +++ 38 files changed, 958 insertions(+), 88 deletions(-) create mode 100644 crates/tranquil-oauth-server/src/endpoints/account.rs create mode 100644 frontend/src/tests/AppPasswordStep.test.ts diff --git a/README.md b/README.md index d56e005..eea5243 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,9 @@ podman-compose -f docker-compose.prod.yaml up -d We currently don't have a shared space to chat and organize Tranquil things, but we're very interested in changing that in the near future. What do you suggest? Anything but a discord server. ### Core team - -- [@oyster.cafe](https://tangled.org/did:plc:3fwecdnvtcscjnrx2p4n7alz) -- [@nel.pet](https://tangled.org/did:plc:h5wsnqetncv6lu2weom35lg2) +- margin.cafe - [@scanash.com](https://tangled.org/did:plc:3i6uzuatdyk7rwfkrybynf5j) +- Tranquil - [@oyster.cafe](https://tangled.org/did:plc:3fwecdnvtcscjnrx2p4n7alz) +- Tranquil - [@nel.pet](https://tangled.org/did:plc:h5wsnqetncv6lu2weom35lg2) ### Amazing contributors @@ -108,7 +108,7 @@ We currently don't have a shared space to chat and organize Tranquil things, but ### Special thanks -This project is very grateful to [@nonbinary.computer](https://tangled.org/did:plc:yfvwmnlztr4dwkb7hwz55r2g), [@juliet.paris](https://tangled.org/did:plc:hs3aly5l26pozymy4b6hz7ae), [@mary.my.id](https://tangled.org/did:plc:ia76kvnndjutgedggx2ibrem), [@baileytownsend.dev](https://tangled.org/did:plc:rnpkyqnmsw4ipey6eotbdnnf), and [@ptr.pet](https://tangled.org/did:plc:dfl62fgb7wtjj3fcbb72naae) for their help and their code to lean on. +This project is very grateful to [@oyster.cafe](https://tangled.org/did:plc:3fwecdnvtcscjnrx2p4n7alz), and [@nel.pet](https://tangled.org/did:plc:h5wsnqetncv6lu2weom35lg2) for the original Tranquil, and to [@nonbinary.computer](https://tangled.org/did:plc:yfvwmnlztr4dwkb7hwz55r2g), [@juliet.paris](https://tangled.org/did:plc:hs3aly5l26pozymy4b6hz7ae), [@mary.my.id](https://tangled.org/did:plc:ia76kvnndjutgedggx2ibrem), [@baileytownsend.dev](https://tangled.org/did:plc:rnpkyqnmsw4ipey6eotbdnnf), and [@ptr.pet](https://tangled.org/did:plc:dfl62fgb7wtjj3fcbb72naae) for their help and their code to lean on. ## License diff --git a/crates/tranquil-api/src/lib.rs b/crates/tranquil-api/src/lib.rs index 6852e73..2f3aeaa 100644 --- a/crates/tranquil-api/src/lib.rs +++ b/crates/tranquil-api/src/lib.rs @@ -175,6 +175,14 @@ pub fn api_routes() -> axum::Router { "/_account.updateTrustedDevice", post(server::update_trusted_device), ) + .route( + "/_account.listRememberedAppChoices", + get(server::list_remembered_app_choices), + ) + .route( + "/_account.forgetRememberedAppChoice", + post(server::forget_remembered_app_choice), + ) .route( "/_account.createPasskeyAccount", post(server::create_passkey_account), diff --git a/crates/tranquil-api/src/server/mod.rs b/crates/tranquil-api/src/server/mod.rs index 5a7f383..e578efb 100644 --- a/crates/tranquil-api/src/server/mod.rs +++ b/crates/tranquil-api/src/server/mod.rs @@ -59,8 +59,9 @@ pub use totp::{ regenerate_backup_codes, verify_totp_or_backup_for_user, }; pub use trusted_devices::{ - extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device, - trust_device, update_trusted_device, + extend_device_trust, forget_remembered_app_choice, is_device_trusted, + list_remembered_app_choices, list_trusted_devices, revoke_trusted_device, trust_device, + update_trusted_device, }; pub use verify_email::{resend_migration_verification, verify_migration_email}; pub use verify_token::{ diff --git a/crates/tranquil-api/src/server/reauth.rs b/crates/tranquil-api/src/server/reauth.rs index c78adf4..cfe9976 100644 --- a/crates/tranquil-api/src/server/reauth.rs +++ b/crates/tranquil-api/src/server/reauth.rs @@ -74,7 +74,8 @@ pub async fn reauth_password( .log_db_err("fetching password hash")? .ok_or(ApiError::AccountNotFound)?; - let password_valid = tranquil_pds::auth::verify_password(&input.password, password_hash.as_str()); + let password_valid = + tranquil_pds::auth::verify_password(&input.password, password_hash.as_str()); if !password_valid { let app_password_hashes = state diff --git a/crates/tranquil-api/src/server/session.rs b/crates/tranquil-api/src/server/session.rs index 49f1d9a..413359a 100644 --- a/crates/tranquil-api/src/server/session.rs +++ b/crates/tranquil-api/src/server/session.rs @@ -1227,12 +1227,8 @@ pub async fn revoke_session( pub async fn revoke_all_sessions( State(state): State, - headers: HeaderMap, auth: Auth, ) -> Result, ApiError> { - let jti = tranquil_pds::auth::extract_jti_from_headers(&headers) - .ok_or(ApiError::InvalidToken(None))?; - if auth.is_oauth() { state .repos @@ -1240,7 +1236,7 @@ pub async fn revoke_all_sessions( .delete_sessions_by_did(&auth.did) .await .log_db_err("revoking JWT sessions")?; - let token_id = TokenId::from(jti.clone().into_inner()); + let token_id = TokenId::from(auth.session_id.clone()); state .repos .oauth @@ -1251,7 +1247,10 @@ pub async fn revoke_all_sessions( state .repos .session - .delete_sessions_by_did_except_jti(&auth.did, &jti) + .delete_sessions_by_did_except_jti( + &auth.did, + &tranquil_types::Jti::from(auth.session_id.clone()), + ) .await .log_db_err("revoking JWT sessions")?; state diff --git a/crates/tranquil-api/src/server/trusted_devices.rs b/crates/tranquil-api/src/server/trusted_devices.rs index 9e76792..41f7ab8 100644 --- a/crates/tranquil-api/src/server/trusted_devices.rs +++ b/crates/tranquil-api/src/server/trusted_devices.rs @@ -127,11 +127,72 @@ pub async fn revoke_trusted_device( state .repos .oauth - .revoke_device_trust(&input.device_id, &auth.did) + .delete_account_device(&auth.did, &input.device_id) .await - .log_db_err("revoking device trust")?; + .log_db_err("forgetting remembered device")?; + + info!(did = %&auth.did, device_id = %input.device_id, "Remembered device forgotten"); + Ok(Json(SuccessResponse { success: true })) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RememberedAppChoice { + pub client_id: tranquil_types::ClientId, + pub scopes: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListRememberedAppChoicesOutput { + pub choices: Vec, +} + +pub async fn list_remembered_app_choices( + State(state): State, + auth: Auth, +) -> Result, ApiError> { + let rows = state + .repos + .oauth + .list_scope_preference_clients(&auth.did) + .await + .log_db_err("listing remembered app choices")?; + + Ok(Json(ListRememberedAppChoicesOutput { + choices: rows + .into_iter() + .map(|row| RememberedAppChoice { + client_id: row.client_id, + scopes: row + .preferences + .into_iter() + .filter(|preference| preference.granted) + .map(|preference| preference.scope) + .collect(), + }) + .collect(), + })) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ForgetRememberedAppChoiceInput { + pub client_id: tranquil_types::ClientId, +} + +pub async fn forget_remembered_app_choice( + State(state): State, + auth: Auth, + Json(input): Json, +) -> Result, ApiError> { + state + .repos + .oauth + .delete_scope_preferences(&auth.did, &input.client_id) + .await + .log_db_err("forgetting remembered app choice")?; - info!(did = %&auth.did, device_id = %input.device_id, "Trusted device revoked"); Ok(Json(SuccessResponse { success: true })) } diff --git a/crates/tranquil-db-traits/src/lib.rs b/crates/tranquil-db-traits/src/lib.rs index b71a3eb..ff9c630 100644 --- a/crates/tranquil-db-traits/src/lib.rs +++ b/crates/tranquil-db-traits/src/lib.rs @@ -30,7 +30,7 @@ pub use infra::{ pub use invite_code::{InviteCodeError, ValidatedInviteCode}; pub use oauth::{ DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem, RefreshTokenLookup, - ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge, + ScopePreference, ScopePreferenceClientRow, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge, }; pub use repo::{ AccountStatus, ApplyCommitError, ApplyCommitInput, ApplyCommitResult, CommitEventData, diff --git a/crates/tranquil-db-traits/src/oauth.rs b/crates/tranquil-db-traits/src/oauth.rs index 2d904e0..c3aa0f4 100644 --- a/crates/tranquil-db-traits/src/oauth.rs +++ b/crates/tranquil-db-traits/src/oauth.rs @@ -41,12 +41,18 @@ impl std::fmt::Display for TokenFamilyId { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ScopePreference { pub scope: String, pub granted: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScopePreferenceClientRow { + pub client_id: ClientId, + pub preferences: Vec, +} + #[derive(Debug, Clone)] pub struct DeviceAccountRow { pub did: Did, @@ -220,6 +226,7 @@ pub trait OAuthRepository: Send + Sync { async fn update_device_last_seen(&self, device_id: &DeviceId) -> Result<(), DbError>; async fn delete_device(&self, device_id: &DeviceId) -> Result<(), DbError>; async fn upsert_account_device(&self, did: &Did, device_id: &DeviceId) -> Result<(), DbError>; + async fn delete_account_device(&self, did: &Did, device_id: &DeviceId) -> Result<(), DbError>; async fn get_device_accounts( &self, device_id: &DeviceId, @@ -256,6 +263,10 @@ pub trait OAuthRepository: Send + Sync { did: &Did, client_id: &ClientId, ) -> Result, DbError>; + async fn list_scope_preference_clients( + &self, + did: &Did, + ) -> Result, DbError>; async fn upsert_scope_preferences( &self, did: &Did, diff --git a/crates/tranquil-db/src/postgres/oauth.rs b/crates/tranquil-db/src/postgres/oauth.rs index ca70c3a..656b724 100644 --- a/crates/tranquil-db/src/postgres/oauth.rs +++ b/crates/tranquil-db/src/postgres/oauth.rs @@ -2,9 +2,11 @@ use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; use rand::Rng; use sqlx::PgPool; +use std::collections::BTreeMap; + use tranquil_db_traits::{ DbError, DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem, - ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge, + ScopePreference, ScopePreferenceClientRow, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge, }; use tranquil_oauth::{ AuthorizationRequestParameters, AuthorizedClientData, ClientAuth, DeviceData, RequestData, @@ -787,6 +789,21 @@ impl OAuthRepository for PostgresOAuthRepository { Ok(()) } + async fn delete_account_device(&self, did: &Did, device_id: &DeviceId) -> Result<(), DbError> { + sqlx::query( + r#" + DELETE FROM oauth_account_device + WHERE did = $1 AND device_id = $2 + "#, + ) + .bind(did.as_str()) + .bind(device_id.as_str()) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + async fn get_device_accounts( &self, device_id: &DeviceId, @@ -1034,6 +1051,40 @@ impl OAuthRepository for PostgresOAuthRepository { .collect()) } + async fn list_scope_preference_clients( + &self, + did: &Did, + ) -> Result, DbError> { + let rows = sqlx::query_as::<_, (String, String, bool)>( + r#" + SELECT client_id, scope, granted + FROM oauth_scope_preference + WHERE did = $1 + ORDER BY client_id, scope + "#, + ) + .bind(did.as_str()) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + let mut clients = BTreeMap::>::new(); + for (client_id, scope, granted) in rows { + clients + .entry(client_id) + .or_default() + .push(ScopePreference { scope, granted }); + } + + Ok(clients + .into_iter() + .map(|(client_id, preferences)| ScopePreferenceClientRow { + client_id: ClientId::new(client_id), + preferences, + }) + .collect()) + } + async fn upsert_scope_preferences( &self, did: &Did, @@ -1124,28 +1175,42 @@ impl OAuthRepository for PostgresOAuthRepository { } async fn list_trusted_devices(&self, did: &Did) -> Result, DbError> { - let rows = sqlx::query!( + let rows = sqlx::query_as::< + _, + ( + String, + Option, + Option, + Option>, + Option>, + DateTime, + ), + >( r#"SELECT od.id, od.user_agent, od.friendly_name, od.trusted_at, od.trusted_until, od.last_seen_at FROM oauth_device od JOIN oauth_account_device oad ON od.id = oad.device_id - WHERE oad.did = $1 AND od.trusted_until IS NOT NULL AND od.trusted_until > NOW() + WHERE oad.did = $1 ORDER BY od.last_seen_at DESC"#, - did.as_str() ) + .bind(did.as_str()) .fetch_all(&self.pool) .await .map_err(map_sqlx_error)?; Ok(rows .into_iter() - .map(|r| TrustedDeviceRow { - id: DeviceId::from(r.id), - user_agent: r.user_agent, - friendly_name: r.friendly_name, - trusted_at: r.trusted_at, - trusted_until: r.trusted_until, - last_seen_at: r.last_seen_at, - }) + .map( + |(id, user_agent, friendly_name, trusted_at, trusted_until, last_seen_at)| { + TrustedDeviceRow { + id: DeviceId::from(id), + user_agent, + friendly_name, + trusted_at, + trusted_until, + last_seen_at, + } + }, + ) .collect()) } diff --git a/crates/tranquil-oauth-server/src/endpoints/account.rs b/crates/tranquil-oauth-server/src/endpoints/account.rs new file mode 100644 index 0000000..e808faa --- /dev/null +++ b/crates/tranquil-oauth-server/src/endpoints/account.rs @@ -0,0 +1,56 @@ +use axum::{ + Json, + extract::State, + http::{HeaderMap, HeaderValue, header::SET_COOKIE}, + response::{IntoResponse, Response}, +}; +use tranquil_pds::{ + api::{ + SuccessResponse, + error::{ApiError, DbResultExt}, + }, + auth::{Active, Auth}, + state::AppState, +}; + +const DEVICE_COOKIE_NAME: &str = "oauth_device_id"; + +fn extract_device_cookie(headers: &HeaderMap) -> Option { + headers + .get("cookie") + .and_then(|value| value.to_str().ok()) + .and_then(|cookie_header| { + cookie_header.split(';').map(str::trim).find_map(|cookie| { + cookie + .strip_prefix(&format!("{}=", DEVICE_COOKIE_NAME)) + .and_then(|value| { + tranquil_pds::config::AuthConfig::get().verify_device_cookie(value) + }) + .map(tranquil_types::DeviceId::new) + }) + }) +} + +pub async fn forget_current_device( + State(state): State, + headers: HeaderMap, + auth: Auth, +) -> Result { + if let Some(device_id) = extract_device_cookie(&headers) { + state + .repos + .oauth + .delete_account_device(&auth.did, &device_id) + .await + .log_db_err("forgetting current device")?; + } + + let mut response = Json(SuccessResponse { success: true }).into_response(); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_static( + "oauth_device_id=; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=0", + ), + ); + Ok(response) +} diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/registration.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/registration.rs index 3cc0918..6ecd3fd 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/registration.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/registration.rs @@ -155,7 +155,8 @@ pub async fn register_complete( if !password_valid && let Ok(Some(account_hash)) = state.repos.user.get_password_hash_by_did(&did).await { - password_valid = tranquil_pds::auth::verify_password(&form.app_password, account_hash.as_str()); + password_valid = + tranquil_pds::auth::verify_password(&form.app_password, account_hash.as_str()); } if !password_valid { diff --git a/crates/tranquil-oauth-server/src/endpoints/mod.rs b/crates/tranquil-oauth-server/src/endpoints/mod.rs index 1c38a83..4e63532 100644 --- a/crates/tranquil-oauth-server/src/endpoints/mod.rs +++ b/crates/tranquil-oauth-server/src/endpoints/mod.rs @@ -1,9 +1,11 @@ +pub mod account; pub mod authorize; pub mod delegation; pub mod metadata; pub mod par; pub mod token; +pub use account::*; pub use authorize::*; pub use delegation::*; pub use metadata::*; diff --git a/crates/tranquil-oauth-server/src/lib.rs b/crates/tranquil-oauth-server/src/lib.rs index 82f1bbf..9121937 100644 --- a/crates/tranquil-oauth-server/src/lib.rs +++ b/crates/tranquil-oauth-server/src/lib.rs @@ -54,6 +54,7 @@ pub fn oauth_routes() -> axum::Router { ) .route("/token", post(endpoints::token_endpoint)) .route("/revoke", post(endpoints::revoke_token)) + .route("/forget-device", post(endpoints::forget_current_device)) .route("/introspect", post(endpoints::introspect_token)) .route("/sso/providers", get(sso_endpoints::get_sso_providers)) .route("/sso/initiate", post(sso_endpoints::sso_initiate)) diff --git a/crates/tranquil-pds/src/auth/extractor.rs b/crates/tranquil-pds/src/auth/extractor.rs index 4308649..ab3eaa5 100644 --- a/crates/tranquil-pds/src/auth/extractor.rs +++ b/crates/tranquil-pds/src/auth/extractor.rs @@ -238,6 +238,7 @@ async fn verify_oauth_token_and_build_user( status, scope: result.scope, controller_did: None, + session_id: result.token_id.to_string(), auth_source: AuthSource::OAuth, }) } diff --git a/crates/tranquil-pds/src/auth/mod.rs b/crates/tranquil-pds/src/auth/mod.rs index 0a1d71c..244c994 100644 --- a/crates/tranquil-pds/src/auth/mod.rs +++ b/crates/tranquil-pds/src/auth/mod.rs @@ -189,6 +189,7 @@ pub struct AuthenticatedUser { pub status: AccountStatus, pub scope: Option, pub controller_did: Option, + pub session_id: String, pub auth_source: AuthSource, } @@ -489,6 +490,7 @@ async fn validate_bearer_token_with_options_internal( status, scope: token_data.claims.scope.clone(), controller_did, + session_id: jti.to_string(), auth_source: AuthSource::Session, }); } @@ -534,6 +536,7 @@ async fn validate_bearer_token_with_options_internal( status, scope: oauth_info.scope, controller_did: oauth_info.controller_did, + session_id: oauth_info.token_id.to_string(), auth_source: AuthSource::OAuth, }); } else { @@ -626,6 +629,7 @@ pub async fn validate_token_with_dpop( status, scope: result.scope, controller_did: None, + session_id: result.token_id.to_string(), auth_source: AuthSource::OAuth, }) } diff --git a/crates/tranquil-scopes/src/lib.rs b/crates/tranquil-scopes/src/lib.rs index 1160e56..22f3ccb 100644 --- a/crates/tranquil-scopes/src/lib.rs +++ b/crates/tranquil-scopes/src/lib.rs @@ -14,6 +14,7 @@ pub use error::ScopeError; pub use parser::{ AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, IncludeScope, ParsedScope, RepoAction, RepoScope, RpcScope, parse_scope, parse_scope_string, + serialize_scope_string, }; pub use permission_set::{ ExpansionOutcome, FailedSet, FetchedSet, ResolveFailure, ResolvedSetGroup, ScopeExpansionError, diff --git a/crates/tranquil-scopes/src/parser.rs b/crates/tranquil-scopes/src/parser.rs index 9193bf7..bab234a 100644 --- a/crates/tranquil-scopes/src/parser.rs +++ b/crates/tranquil-scopes/src/parser.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; +use std::fmt; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParsedScope { @@ -45,6 +46,20 @@ impl RepoAction { _ => None, } } + + fn as_str(self) -> &'static str { + match self { + Self::Create => "create", + Self::Update => "update", + Self::Delete => "delete", + } + } +} + +impl fmt::Display for RepoAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -109,6 +124,16 @@ impl AccountAttr { _ => None, } } + + fn as_str(self) -> &'static str { + match self { + Self::Email => "email", + Self::Handle => "handle", + Self::Repo => "repo", + Self::Status => "status", + Self::Wildcard => "*", + } + } } impl IdentityAttr { @@ -119,6 +144,13 @@ impl IdentityAttr { _ => None, } } + + fn as_str(self) -> &'static str { + match self { + Self::Handle => "handle", + Self::Wildcard => "*", + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -135,6 +167,104 @@ impl AccountAction { _ => None, } } + + fn as_str(self) -> &'static str { + match self { + Self::Read => "read", + Self::Manage => "manage", + } + } +} + +fn write_query_param( + f: &mut fmt::Formatter<'_>, + first: &mut bool, + key: &str, + value: &str, +) -> fmt::Result { + f.write_str(if *first { "?" } else { "&" })?; + *first = false; + write!(f, "{}={}", key, urlencoding::encode(value)) +} + +impl fmt::Display for RepoScope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "repo:{}", self.collection.as_deref().unwrap_or("*"))?; + let mut first = true; + for action in [RepoAction::Create, RepoAction::Update, RepoAction::Delete] { + if self.actions.contains(&action) { + write_query_param(f, &mut first, "action", action.as_str())?; + } + } + Ok(()) + } +} + +impl fmt::Display for BlobScope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("blob")?; + let mut accepts: Vec<&str> = self.accept.iter().map(String::as_str).collect(); + accepts.sort_unstable(); + let mut first = true; + for accept in accepts { + write_query_param(f, &mut first, "accept", accept)?; + } + Ok(()) + } +} + +impl fmt::Display for RpcScope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.lxm { + Some(lxm) => write!(f, "rpc:{lxm}")?, + None => f.write_str("rpc")?, + } + if let Some(aud) = &self.aud { + write_query_param(f, &mut true, "aud", aud)?; + } + Ok(()) + } +} + +impl fmt::Display for AccountScope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "account:{}", self.attr.as_str())?; + write_query_param(f, &mut true, "action", self.action.as_str()) + } +} + +impl fmt::Display for IdentityScope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "identity:{}", self.attr.as_str()) + } +} + +impl fmt::Display for IncludeScope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "include:{}", self.nsid)?; + if let Some(aud) = &self.aud { + write_query_param(f, &mut true, "aud", aud)?; + } + Ok(()) + } +} + +impl fmt::Display for ParsedScope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Atproto => f.write_str("atproto"), + Self::TransitionGeneric => f.write_str("transition:generic"), + Self::TransitionChat => f.write_str("transition:chat.bsky"), + Self::TransitionEmail => f.write_str("transition:email"), + Self::Repo(scope) => scope.fmt(f), + Self::Blob(scope) => scope.fmt(f), + Self::Rpc(scope) => scope.fmt(f), + Self::Account(scope) => scope.fmt(f), + Self::Identity(scope) => scope.fmt(f), + Self::Include(scope) => scope.fmt(f), + Self::Unknown(scope) => f.write_str(scope), + } + } } fn parse_query_params(query: &str) -> HashMap> { @@ -275,6 +405,14 @@ pub fn parse_scope_string(scope_str: &str) -> Vec { scope_str.split_whitespace().map(parse_scope).collect() } +pub fn serialize_scope_string(scopes: &[ParsedScope]) -> String { + scopes + .iter() + .map(ToString::to_string) + .collect::>() + .join(" ") +} + #[cfg(test)] mod tests { use super::*; @@ -508,4 +646,66 @@ mod tests { _ => panic!("Expected Rpc scope"), } } + + #[test] + fn canonical_repo_serialization_orders_actions() { + let scope = ParsedScope::Repo(RepoScope { + collection: Some("app.bsky.feed.post".to_string()), + actions: [RepoAction::Delete, RepoAction::Create, RepoAction::Update] + .into_iter() + .collect(), + }); + + assert_eq!( + scope.to_string(), + "repo:app.bsky.feed.post?action=create&action=update&action=delete" + ); + assert_eq!(parse_scope(&scope.to_string()), scope); + } + + #[test] + fn canonical_serialization_formats_all_typed_scope_variants() { + let cases = [ + "blob?accept=image%2F%2A&accept=video%2Fmp4", + "rpc:app.bsky.feed.getTimeline?aud=did%3Aweb%3Aapi.bsky.app%23appview", + "account:email?action=manage", + "identity:handle", + "include:app.bsky.authFullApp?aud=did%3Aweb%3Aapi.bsky.app%23appview", + ]; + + for canonical in cases { + let parsed = parse_scope(canonical); + assert_eq!(parsed.to_string(), canonical); + assert_eq!(parse_scope(&parsed.to_string()), parsed); + } + } + + #[test] + fn canonical_serialization_round_trips_fixed_and_unknown_scopes() { + let cases = [ + "atproto", + "transition:generic", + "transition:chat.bsky", + "transition:email", + "future:scope?opaque=value", + ]; + + for original in cases { + let parsed = parse_scope(original); + assert_eq!(parsed.to_string(), original); + assert_eq!(parse_scope(&parsed.to_string()), parsed); + } + } + + #[test] + fn canonical_scope_string_serialization_joins_scopes() { + let scopes = parse_scope_string( + "atproto repo:app.bsky.feed.post?action=delete&action=create identity:*", + ); + + assert_eq!( + serialize_scope_string(&scopes), + "atproto repo:app.bsky.feed.post?action=create&action=delete identity:*" + ); + } } diff --git a/crates/tranquil-scopes/src/permission_set.rs b/crates/tranquil-scopes/src/permission_set.rs index 8897ef0..9f64b73 100644 --- a/crates/tranquil-scopes/src/permission_set.rs +++ b/crates/tranquil-scopes/src/permission_set.rs @@ -1,8 +1,9 @@ +use crate::parser::{ParsedScope, RepoAction, RepoScope, RpcScope}; use hickory_resolver::TokioAsyncResolver; use hickory_resolver::config::{ResolverConfig, ResolverOpts}; use reqwest::Client; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use tracing::debug; use tranquil_types::{Did, Nsid}; @@ -332,7 +333,8 @@ fn is_under_authority(target_nsid: &str, authority: &str) -> bool { .is_some_and(|c| c == '.') } -const DEFAULT_ACTIONS: &[&str] = &["create", "update", "delete"]; +const DEFAULT_ACTIONS: &[RepoAction] = + &[RepoAction::Create, RepoAction::Update, RepoAction::Delete]; fn build_expanded_scopes( permissions: &[PermissionEntry], @@ -346,18 +348,29 @@ fn build_expanded_scopes( .for_each(|perm| match perm.resource.as_str() { "repo" => { if let Some(collections) = &perm.collection { - let actions: Vec<&str> = perm + let actions: Vec = perm .action .as_ref() - .map(|a| a.iter().map(String::as_str).collect()) + .map(|actions| { + actions + .iter() + .filter_map(|action| RepoAction::parse_str(action)) + .collect() + }) .unwrap_or_else(|| DEFAULT_ACTIONS.to_vec()); collections .iter() .filter(|coll| is_under_authority(coll, namespace_authority)) .for_each(|coll| { - actions.iter().for_each(|action| { - scopes.push(format!("repo:{}?action={}", coll, action)); + actions.iter().copied().for_each(|action| { + scopes.push( + ParsedScope::Repo(RepoScope { + collection: Some(coll.clone()), + actions: HashSet::from([action]), + }) + .to_string(), + ); }); }); } @@ -369,11 +382,13 @@ fn build_expanded_scopes( lxms.iter() .filter(|lxm| is_under_authority(lxm, namespace_authority)) .for_each(|lxm| { - let scope = match perm_aud { - Some(aud) => format!("rpc:{}?aud={}", lxm, aud), - None => format!("rpc:{}", lxm), - }; - scopes.push(scope); + scopes.push( + ParsedScope::Rpc(RpcScope { + lxm: Some(lxm.clone()), + aud: perm_aud.map(str::to_string), + }) + .to_string(), + ); }); } } @@ -495,7 +510,7 @@ mod tests { }]; let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); - assert!(expanded.contains("rpc:io.atcr.getManifest?aud=*")); + assert!(expanded.contains("rpc:io.atcr.getManifest?aud=%2A")); assert!(!expanded.contains("com.atproto.repo.getRecord")); } @@ -511,7 +526,7 @@ mod tests { let expanded = build_expanded_scopes(&permissions, Some("did:web:api.example.com"), "io.atcr"); - assert!(expanded.contains("rpc:io.atcr.getManifest?aud=did:web:api.example.com")); + assert!(expanded.contains("rpc:io.atcr.getManifest?aud=did%3Aweb%3Aapi.example.com")); } #[test] diff --git a/crates/tranquil-store/src/metastore/client.rs b/crates/tranquil-store/src/metastore/client.rs index 5e02f40..dfbac2a 100644 --- a/crates/tranquil-store/src/metastore/client.rs +++ b/crates/tranquil-store/src/metastore/client.rs @@ -2933,6 +2933,17 @@ impl tranquil_db_traits::OAuthRepository for MetastoreCl recv(rx).await } + async fn delete_account_device(&self, did: &Did, device_id: &DeviceId) -> Result<(), DbError> { + let (tx, rx) = oneshot::channel(); + self.pool + .send(MetastoreRequest::OAuth(OAuthRequest::DeleteAccountDevice { + did: did.clone(), + device_id: device_id.clone(), + tx, + }))?; + recv(rx).await + } + async fn get_device_accounts( &self, device_id: &DeviceId, @@ -3074,6 +3085,20 @@ impl tranquil_db_traits::OAuthRepository for MetastoreCl recv(rx).await } + async fn list_scope_preference_clients( + &self, + did: &Did, + ) -> Result, DbError> { + let (tx, rx) = oneshot::channel(); + self.pool.send(MetastoreRequest::OAuth( + OAuthRequest::ListScopePreferenceClients { + did: did.clone(), + tx, + }, + ))?; + recv(rx).await + } + async fn upsert_scope_preferences( &self, did: &Did, diff --git a/crates/tranquil-store/src/metastore/handler.rs b/crates/tranquil-store/src/metastore/handler.rs index 9d9efaf..64c2275 100644 --- a/crates/tranquil-store/src/metastore/handler.rs +++ b/crates/tranquil-store/src/metastore/handler.rs @@ -2254,6 +2254,11 @@ pub enum OAuthRequest { device_id: DeviceId, tx: Tx<()>, }, + DeleteAccountDevice { + did: Did, + device_id: DeviceId, + tx: Tx<()>, + }, GetDeviceAccounts { device_id: DeviceId, tx: Tx>, @@ -2304,6 +2309,10 @@ pub enum OAuthRequest { client_id: ClientId, tx: Tx>, }, + ListScopePreferenceClients { + did: Did, + tx: Tx>, + }, UpsertScopePreferences { did: Did, client_id: ClientId, @@ -2398,6 +2407,7 @@ impl OAuthRequest { | Self::Create2faChallenge { did, .. } | Self::CheckUser2faEnabled { did, .. } | Self::GetScopePreferences { did, .. } + | Self::ListScopePreferenceClients { did, .. } | Self::UpsertScopePreferences { did, .. } | Self::DeleteScopePreferences { did, .. } | Self::UpsertAuthorizedClient { did, .. } @@ -2406,6 +2416,7 @@ impl OAuthRequest { | Self::GetDeviceTrustInfo { did, .. } | Self::DeviceBelongsToUser { did, .. } | Self::UpsertAccountDevice { did, .. } + | Self::DeleteAccountDevice { did, .. } | Self::VerifyAccountOnDevice { did, .. } | Self::ListSessionsByDid { did, .. } | Self::DeleteSessionsByDid { did, .. } @@ -4780,6 +4791,14 @@ fn dispatch_oauth(state: &HandlerState, req: OAuthRequest) { .map_err(metastore_to_db); let _ = tx.send(result); } + OAuthRequest::DeleteAccountDevice { did, device_id, tx } => { + let result = state + .metastore + .oauth_ops() + .delete_account_device(&did, &device_id) + .map_err(metastore_to_db); + let _ = tx.send(result); + } OAuthRequest::GetDeviceAccounts { device_id, tx } => { let result = state .metastore @@ -4880,6 +4899,14 @@ fn dispatch_oauth(state: &HandlerState, req: OAuthRequest) { .map_err(metastore_to_db); let _ = tx.send(result); } + OAuthRequest::ListScopePreferenceClients { did, tx } => { + let result = state + .metastore + .oauth_ops() + .list_scope_preference_clients(&did) + .map_err(metastore_to_db); + let _ = tx.send(result); + } OAuthRequest::UpsertScopePreferences { did, client_id, diff --git a/crates/tranquil-store/src/metastore/mod.rs b/crates/tranquil-store/src/metastore/mod.rs index 50af461..a5bdb0c 100644 --- a/crates/tranquil-store/src/metastore/mod.rs +++ b/crates/tranquil-store/src/metastore/mod.rs @@ -771,6 +771,77 @@ mod tests { } } + #[test] + fn oauth_remembered_devices_include_untrusted_and_delete_only_association() { + use chrono::{Duration, Utc}; + use tranquil_oauth::{DeviceData, SessionId}; + use tranquil_types::DeviceId; + + let (_dir, ms) = open_fresh(); + let did = Did::new("did:plc:remembered-device").unwrap(); + let device_id = DeviceId::new("shared-device"); + let last_seen_at = Utc::now() - Duration::minutes(5); + let ops = ms.oauth_ops(); + ops.create_device( + &device_id, + &DeviceData { + session_id: SessionId::from("device-session".to_owned()), + user_agent: Some("Test Browser".to_owned()), + ip_address: "127.0.0.1".to_owned(), + last_seen_at, + }, + ) + .unwrap(); + ops.upsert_account_device(&did, &device_id).unwrap(); + + let devices = ops.list_trusted_devices(&did).unwrap(); + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].id, device_id); + assert_eq!(devices[0].user_agent.as_deref(), Some("Test Browser")); + assert_eq!(devices[0].trusted_at, None); + assert_eq!(devices[0].trusted_until, None); + + ops.delete_account_device(&did, &device_id).unwrap(); + assert!(ops.list_trusted_devices(&did).unwrap().is_empty()); + assert!(ops.get_device(&device_id).unwrap().is_some()); + } + + #[test] + fn oauth_lists_scope_preference_clients_for_did() { + use tranquil_db_traits::ScopePreference; + use tranquil_types::ClientId; + + let (_dir, ms) = open_fresh(); + let did = Did::new("did:plc:scope-preferences").unwrap(); + let other_did = Did::new("did:plc:other-scope-preferences").unwrap(); + let client_a = ClientId::new("https://a.example/client"); + let client_b = ClientId::new("https://b.example/client"); + let preferences = vec![ + ScopePreference { + scope: "atproto".to_owned(), + granted: true, + }, + ScopePreference { + scope: "transition:generic".to_owned(), + granted: false, + }, + ]; + let ops = ms.oauth_ops(); + ops.upsert_scope_preferences(&did, &client_b, &preferences) + .unwrap(); + ops.upsert_scope_preferences(&did, &client_a, &preferences[..1]) + .unwrap(); + ops.upsert_scope_preferences(&other_did, &client_a, &preferences) + .unwrap(); + + let clients = ops.list_scope_preference_clients(&did).unwrap(); + assert_eq!(clients.len(), 2); + assert_eq!(clients[0].client_id, client_a); + assert_eq!(clients[0].preferences, preferences[..1]); + assert_eq!(clients[1].client_id, client_b); + assert_eq!(clients[1].preferences, preferences); + } + fn stamp_format_version(dir: &std::path::Path, version: u64) { let ms = Metastore::open(dir, test_config()).unwrap(); let repo_data = ms.partition(Partition::RepoData); diff --git a/crates/tranquil-store/src/metastore/oauth_ops.rs b/crates/tranquil-store/src/metastore/oauth_ops.rs index 2d234b3..b8de202 100644 --- a/crates/tranquil-store/src/metastore/oauth_ops.rs +++ b/crates/tranquil-store/src/metastore/oauth_ops.rs @@ -12,18 +12,18 @@ use super::oauth_schema::{ UsedRefreshValue, deserialize_family_counter, oauth_2fa_by_request_key, oauth_2fa_challenge_key, oauth_2fa_challenge_prefix, oauth_account_device_key, oauth_auth_by_code_key, oauth_auth_client_key, oauth_auth_request_key, - oauth_auth_request_prefix, oauth_device_key, oauth_device_trust_key, oauth_device_trust_prefix, - oauth_dpop_jti_key, oauth_dpop_jti_prefix, oauth_scope_prefs_key, oauth_token_by_family_key, - oauth_token_by_id_key, oauth_token_by_prev_refresh_key, oauth_token_by_refresh_key, - oauth_token_family_counter_key, oauth_token_key, oauth_token_user_prefix, - oauth_used_refresh_key, serialize_family_counter, + oauth_auth_request_prefix, oauth_device_key, oauth_device_trust_key, oauth_dpop_jti_key, + oauth_dpop_jti_prefix, oauth_scope_prefs_key, oauth_scope_prefs_prefix, + oauth_token_by_family_key, oauth_token_by_id_key, oauth_token_by_prev_refresh_key, + oauth_token_by_refresh_key, oauth_token_family_counter_key, oauth_token_key, + oauth_token_user_prefix, oauth_used_refresh_key, serialize_family_counter, }; use super::scan::point_lookup; use super::users::UserValue; use tranquil_db_traits::{ - DeviceAccountRow, DeviceTrustInfo, OAuthSessionListItem, ScopePreference, TokenFamilyId, - TrustedDeviceRow, TwoFactorChallenge, + DeviceAccountRow, DeviceTrustInfo, OAuthSessionListItem, ScopePreference, + ScopePreferenceClientRow, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge, }; use tranquil_oauth::{AuthorizedClientData, DeviceData, RequestData, TokenData}; use tranquil_types::{ @@ -949,6 +949,18 @@ impl OAuthOps { .map_err(MetastoreError::Fjall) } + pub fn delete_account_device( + &self, + did: &Did, + device_id: &DeviceId, + ) -> Result<(), MetastoreError> { + let user_hash = self.resolve_user_hash_from_did(did.as_str()); + let key = oauth_account_device_key(user_hash, device_id.as_str()); + self.auth + .remove(key.as_slice()) + .map_err(MetastoreError::Fjall) + } + pub fn get_device_accounts( &self, device_id: &DeviceId, @@ -1282,6 +1294,33 @@ impl OAuthOps { } } + pub fn list_scope_preference_clients( + &self, + did: &Did, + ) -> Result, MetastoreError> { + let user_hash = self.resolve_user_hash_from_did(did.as_str()); + let prefix = oauth_scope_prefs_prefix(user_hash); + + self.auth + .prefix(prefix.as_slice()) + .try_fold(Vec::new(), |mut acc, guard| { + let (key_bytes, val_bytes) = guard.into_inner().map_err(MetastoreError::Fjall)?; + let mut reader = super::encoding::KeyReader::new(&key_bytes[prefix.len()..]); + let client_id = reader + .string() + .ok_or(MetastoreError::CorruptData("corrupt scope preferences key"))?; + let value = ScopePrefsValue::deserialize(&val_bytes) + .ok_or(MetastoreError::CorruptData("corrupt scope preferences"))?; + let preferences = serde_json::from_str(&value.prefs_json) + .map_err(|_| MetastoreError::CorruptData("corrupt scope prefs json"))?; + acc.push(ScopePreferenceClientRow { + client_id: ClientId::new(client_id), + preferences, + }); + Ok(acc) + }) + } + pub fn upsert_scope_preferences( &self, did: &Did, @@ -1352,30 +1391,51 @@ impl OAuthOps { pub fn list_trusted_devices(&self, did: &Did) -> Result, MetastoreError> { let user_hash = self.resolve_user_hash_from_did(did.as_str()); - let prefix = oauth_device_trust_prefix(user_hash); - - self.auth - .prefix(prefix.as_slice()) - .try_fold(Vec::new(), |mut acc, guard| { - let (_, val_bytes) = guard.into_inner().map_err(MetastoreError::Fjall)?; - match DeviceTrustValue::deserialize(&val_bytes) { - Some(v) => { + let prefix = super::oauth_schema::oauth_account_device_prefix(user_hash); + + let mut devices = + self.auth + .prefix(prefix.as_slice()) + .try_fold(Vec::new(), |mut acc, guard| { + let (key_bytes, _) = guard.into_inner().map_err(MetastoreError::Fjall)?; + let mut reader = super::encoding::KeyReader::new(&key_bytes[prefix.len()..]); + let device_id = reader + .string() + .ok_or(MetastoreError::CorruptData("corrupt account device key"))?; + let device_key = oauth_device_key(&device_id); + let device: Option = point_lookup( + &self.auth, + device_key.as_slice(), + OAuthDeviceValue::deserialize, + "corrupt oauth device", + )?; + if let Some(device) = device { + let trust_key = oauth_device_trust_key(user_hash, &device_id); + let trust: Option = point_lookup( + &self.auth, + trust_key.as_slice(), + DeviceTrustValue::deserialize, + "corrupt device trust", + )?; acc.push(TrustedDeviceRow { - id: DeviceId::from(v.device_id), - user_agent: v.user_agent, - friendly_name: v.friendly_name, - trusted_at: v.trusted_at_ms.and_then(DateTime::from_timestamp_millis), - trusted_until: v - .trusted_until_ms + id: DeviceId::from(device_id), + user_agent: device.user_agent, + friendly_name: trust.as_ref().and_then(|v| v.friendly_name.clone()), + trusted_at: trust + .as_ref() + .and_then(|v| v.trusted_at_ms) .and_then(DateTime::from_timestamp_millis), - last_seen_at: DateTime::from_timestamp_millis(v.last_seen_at_ms) + trusted_until: trust + .and_then(|v| v.trusted_until_ms) + .and_then(DateTime::from_timestamp_millis), + last_seen_at: DateTime::from_timestamp_millis(device.last_seen_at_ms) .unwrap_or_default(), }); - Ok(acc) } - None => Ok(acc), - } - }) + Ok::<_, MetastoreError>(acc) + })?; + devices.sort_by(|a, b| b.last_seen_at.cmp(&a.last_seen_at)); + Ok(devices) } pub fn get_device_trust_info( diff --git a/crates/tranquil-store/src/metastore/oauth_schema.rs b/crates/tranquil-store/src/metastore/oauth_schema.rs index 07dfdcd..13b1130 100644 --- a/crates/tranquil-store/src/metastore/oauth_schema.rs +++ b/crates/tranquil-store/src/metastore/oauth_schema.rs @@ -470,6 +470,13 @@ pub fn oauth_scope_prefs_key(user_hash: UserHash, client_id: &str) -> SmallVec<[ .build() } +pub fn oauth_scope_prefs_prefix(user_hash: UserHash) -> SmallVec<[u8; 128]> { + KeyBuilder::new() + .tag(KeyTag::OAUTH_SCOPE_PREFS) + .u64(user_hash.raw()) + .build() +} + pub fn oauth_auth_client_key(user_hash: UserHash, client_id: &str) -> SmallVec<[u8; 128]> { KeyBuilder::new() .tag(KeyTag::OAUTH_AUTH_CLIENT) diff --git a/frontend/src/components/dashboard/SecurityContent.svelte b/frontend/src/components/dashboard/SecurityContent.svelte index d5b3666..48d5c94 100644 --- a/frontend/src/components/dashboard/SecurityContent.svelte +++ b/frontend/src/components/dashboard/SecurityContent.svelte @@ -3,7 +3,7 @@ import { api, ApiError } from '../../lib/api' import { _ } from '../../lib/i18n' import { formatDate } from '../../lib/date' - import type { Session } from '../../lib/types/api' + import type { RememberedAppChoice, Session } from '../../lib/types/api' import { toast } from '../../lib/toast.svelte' import ReauthModal from '../ReauthModal.svelte' import SsoIcon from '../SsoIcon.svelte' @@ -60,6 +60,8 @@ let trustedDevicesLoading = $state(true) let editingDeviceId = $state(null) let editDeviceName = $state('') + let rememberedAppChoices = $state([]) + let rememberedAppChoicesLoading = $state(true) let showReauthModal = $state(false) let reauthMethods = $state(['password']) @@ -98,7 +100,8 @@ loadSsoProviders(), loadLinkedAccounts(), loadLegacyLoginPreference(), - loadTrustedDevices() + loadTrustedDevices(), + loadRememberedAppChoices() ]) }) @@ -149,6 +152,37 @@ } } + async function loadRememberedAppChoices() { + rememberedAppChoicesLoading = true + try { + const result = await api.listRememberedAppChoices(session.accessJwt) + rememberedAppChoices = result.choices + } catch { + rememberedAppChoices = [] + } finally { + rememberedAppChoicesLoading = false + } + } + + async function handleForgetAppChoice(clientId: string) { + if (!confirm($_('rememberedApps.forgetConfirm'))) return + try { + await api.forgetRememberedAppChoice(session.accessJwt, clientId) + rememberedAppChoices = rememberedAppChoices.filter(choice => choice.clientId !== clientId) + toast.success($_('rememberedApps.forgotten')) + } catch (e) { + toast.error(e instanceof ApiError ? e.message : $_('common.error')) + } + } + + function appName(clientId: string): string { + try { + return new URL(clientId).hostname + } catch { + return clientId + } + } + function startEditDevice(device: TrustedDevice) { editingDeviceId = device.id editDeviceName = device.friendlyName || '' @@ -395,8 +429,8 @@ {/if}
-

{$_('security.trustedDevices')}

-

{$_('security.trustedDevicesDescription')}

+

{$_('trustedDevices.title')}

+

{$_('trustedDevices.description')}

{#if trustedDevicesLoading}
{$_('common.loading')}
@@ -450,7 +484,34 @@ + + {/each} + + {/if} +
+ +
+

{$_('rememberedApps.title')}

+

{$_('rememberedApps.description')}

+ + {#if rememberedAppChoicesLoading} +
{$_('common.loading')}
+ {:else if rememberedAppChoices.length === 0} +

{$_('rememberedApps.none')}

+ {:else} +
+ {#each rememberedAppChoices as choice} +
+
+ {appName(choice.clientId)} +
+
+ {choice.scopes.join(' ')} +
+
{/each} diff --git a/frontend/src/components/migration/AppPasswordStep.svelte b/frontend/src/components/migration/AppPasswordStep.svelte index 972a824..350f063 100644 --- a/frontend/src/components/migration/AppPasswordStep.svelte +++ b/frontend/src/components/migration/AppPasswordStep.svelte @@ -17,6 +17,7 @@ let copied = $state(false) let acknowledged = $state(false) + let revealed = $state(false) function copyPassword() { navigator.clipboard.writeText(appPassword) @@ -36,10 +37,20 @@
{$_('migration.inbound.appPassword.label')}: {appPasswordName}
- {appPassword} - + {revealed ? appPassword : '••••-••••-••••-••••'} +
+ + +