···11+-- Sessions are now stored in encrypted cookies, not the database.
22+-- This migration removes the oauth_sessions table.
33+44+DROP TABLE IF EXISTS oauth_sessions;
+18
migrations/20260118000001_mcp_clients.sql
···11+-- MCP client credentials for OAuth flow
22+-- Stores dynamically registered MCP clients
33+44+CREATE TABLE mcp_clients (
55+ id VARCHAR(64) PRIMARY KEY,
66+ client_id VARCHAR(512) UNIQUE NOT NULL,
77+ client_secret VARCHAR(512) NOT NULL,
88+ redirect_uri VARCHAR(1024) NOT NULL,
99+ client_name VARCHAR(256),
1010+ did VARCHAR(512),
1111+ dpop_jwk TEXT NOT NULL,
1212+ issuer VARCHAR(512),
1313+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1414+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1515+);
1616+1717+CREATE INDEX idx_mcp_clients_client_id ON mcp_clients(client_id);
1818+CREATE INDEX idx_mcp_clients_did ON mcp_clients(did);
+8
migrations/20260118000002_mcp_configuration.sql
···11+-- MCP configuration per user (DID)
22+-- Controls permissions and safety settings for MCP clients
33+44+CREATE TABLE mcp_configuration (
55+ did VARCHAR(512) PRIMARY KEY,
66+ allow_dangerous BOOLEAN NOT NULL DEFAULT FALSE,
77+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
88+);
···11use anyhow::Result;
22-use atproto_identity::key::{IdentityDocumentKeyResolver, identify_key, to_public};
22+use atproto_identity::key::{IdentityDocumentKeyResolver, to_public};
33use atproto_identity::resolve::{
44 HickoryDnsResolver, InnerIdentityResolver, SharedIdentityResolver,
55};
···2323};
2424use tokio_util::sync::CancellationToken;
25252626-use chrono::Duration;
2727-use smokesignal::config::OAuthBackendConfig;
2828-use smokesignal::task_identity_refresh::{IdentityRefreshTask, IdentityRefreshTaskConfig};
2929-use smokesignal::task_oauth_requests_cleanup::{
3030- OAuthRequestsCleanupTask, OAuthRequestsCleanupTaskConfig,
3131-};
3226use sqlx::PgPool;
3327use std::{collections::HashMap, env, str::FromStr, sync::Arc};
3428use tokio::net::TcpListener;
···114108 let oauth_storage = PostgresOAuthRequestStorage::new_arc(pool.clone());
115109 let document_storage = PostgresDidDocumentStorage::new_arc(pool.clone());
116110117117- // Create a key provider populated with signing keys from OAuth backend config
118118- let mut key_provider_keys =
119119- if let OAuthBackendConfig::ATProtocol { signing_keys } = &config.oauth_backend {
120120- signing_keys
121121- .as_ref()
122122- .iter()
123123- .filter_map(|(key_id, private_key)| match identify_key(private_key) {
124124- Ok(key_data) => Some((key_id.clone(), key_data)),
125125- Err(err) => {
126126- tracing::error!(?err, ?key_id, "failed to identify key for key provider");
127127- None
128128- }
129129- })
130130- .collect()
131131- } else {
132132- HashMap::new() // Empty for AIP backend
133133- };
111111+ // Create a key provider populated with OAuth client credentials key from config
112112+ let oauth_key = config.oauth_client_credentials_key.as_ref().clone();
113113+ let oauth_public_key = to_public(&oauth_key)
114114+ .map(|pk| pk.to_string())
115115+ .expect("oauth public key");
116116+ let mut key_provider_keys: HashMap<String, _> = HashMap::new();
117117+ key_provider_keys.insert(oauth_public_key, oauth_key.clone());
134118 key_provider_keys.insert(public_service_key, service_key.0.clone());
135119 let key_provider = Arc::new(SimpleKeyProvider::new(key_provider_keys));
136120137137- // Create OAuth client config (only for AT Protocol backend)
121121+ // Create OAuth client config for AT Protocol
138122 let oauth_client_config = OAuthClientConfig {
139139- client_id: config.external_base.clone(),
123123+ client_id: format!("https://{}/oauth-client-metadata.json", config.external_base),
140124 jwks_uri: None,
141141- signing_keys: if let OAuthBackendConfig::ATProtocol { signing_keys } = &config.oauth_backend
142142- {
143143- signing_keys
144144- .as_ref()
145145- .iter()
146146- .filter_map(|value| identify_key(value.1).ok())
147147- .collect()
148148- } else {
149149- Vec::new() // Empty for AIP backend
150150- },
151151- redirect_uris: format!("{}/oauth/callback", config.external_base),
125125+ signing_keys: vec![oauth_key],
126126+ redirect_uris: format!("https://{}/oauth/callback", config.external_base),
152127 client_name: Some("Smoke Signal".to_string()),
153153- client_uri: Some(config.external_base.clone()),
154154- logo_uri: Some(format!(
155155- "https://{}/logo-160x160x.png",
156156- config.external_base
157157- )),
158158- tos_uri: Some("https://smokesignal.events/terms-of-service".to_string()),
159159- policy_uri: Some("https://smokesignal.events/privacy-policy".to_string()),
160160- scope: Some("atproto transition:generic transition:email".to_string()),
128128+ client_uri: Some(format!("https://{}", config.external_base)),
129129+ logo_uri: Some(format!("https://{}/logo-160x160x.png", config.external_base)),
130130+ tos_uri: Some(format!("https://{}/terms-of-service", config.external_base)),
131131+ policy_uri: Some(format!("https://{}/privacy-policy", config.external_base)),
132132+ scope: Some(config.oauth_scope()),
161133 };
162134163135 let identity_resolver = Arc::new(SharedIdentityResolver(Arc::new(InnerIdentityResolver {
···231203232204 let mcp_session_manager = Arc::new(smokesignal::mcp::session::MemoryMcpSessionManager::new());
233205234234- // Create AIP auth cache if using AIP backend
235235- let aip_auth_cache = match &config.oauth_backend {
236236- OAuthBackendConfig::AIP { hostname, .. } => {
237237- // Extract hostname without https:// prefix
238238- let aip_hostname = hostname.trim_start_matches("https://").to_string();
239239- let cache = Arc::new(smokesignal::aip_auth_cache::AipAuthCache::new(
240240- http_client.clone(),
241241- aip_hostname,
242242- None, // Use default TTL
243243- ));
244244- tracing::info!("AIP auth cache initialized");
245245- Some(cache)
246246- }
247247- _ => {
248248- tracing::info!("AIP auth cache not initialized (not using AIP backend)");
249249- None
250250- }
251251- };
252252-253206 let web_context = WebContext::new(
254207 pool.clone(),
255208 cache_pool.clone(),
···269222 service_document,
270223 service_key,
271224 mcp_session_manager,
272272- aip_auth_cache,
273225 );
274226275227 let app = build_router(web_context.clone());
···369321 tracker.spawn(async move {
370322 if let Err(err) = tap_processor.run().await {
371323 tracing::error!(error = ?err, "TAP processor failed");
372372- }
373373- inner_token.cancel();
374374- });
375375- }
376376-377377- // Spawn OAuth requests cleanup task if enabled
378378- if config.enable_task_oauth_requests_cleanup {
379379- let cleanup_task_config = OAuthRequestsCleanupTaskConfig {
380380- sleep_interval: Duration::hours(1), // Run once per hour
381381- };
382382- let cleanup_task =
383383- OAuthRequestsCleanupTask::new(cleanup_task_config, pool.clone(), token.clone());
384384-385385- let inner_token = token.clone();
386386- tracker.spawn(async move {
387387- if let Err(err) = cleanup_task.run().await {
388388- tracing::error!("OAuth requests cleanup task failed: {}", err);
389389- }
390390- inner_token.cancel();
391391- });
392392- }
393393-394394- // Spawn identity refresh task if enabled
395395- if config.enable_task_identity_refresh {
396396- let identity_refresh_config = IdentityRefreshTaskConfig {
397397- sleep_interval: Duration::hours(1), // Run once per hour
398398- worker_id: "dev".to_string(),
399399- };
400400- let identity_refresh_task = IdentityRefreshTask::new(
401401- identity_refresh_config,
402402- pool.clone(),
403403- document_storage.clone(),
404404- identity_resolver.clone(),
405405- token.clone(),
406406- );
407407-408408- let inner_token = token.clone();
409409- tracker.spawn(async move {
410410- if let Err(err) = identity_refresh_task.run().await {
411411- tracing::error!("Identity refresh task failed: {}", err);
412324 }
413325 inner_token.cancel();
414326 });
+42-121
src/config.rs
···11use anyhow::Result;
22-use atproto_identity::key::{KeyData, KeyType, identify_key, to_public};
22+use atproto_identity::key::{KeyData, identify_key, to_public};
33use axum_extra::extract::cookie::Key;
44use base64::{Engine as _, engine::general_purpose};
55-use ordermap::OrderMap;
6576use crate::config_errors::ConfigError;
87···1817#[derive(Clone)]
1918pub struct CertificateBundles(Vec<String>);
20192121-#[derive(Clone, Debug)]
2222-pub struct SigningKeys(OrderMap<String, String>);
2020+#[derive(Clone)]
2121+pub struct OAuthClientCredentialsKey(KeyData);
23222423#[derive(Clone)]
2524pub struct AdminDIDs(Vec<String>);
···2726#[derive(Clone)]
2827pub struct DnsNameservers(Vec<std::net::IpAddr>);
29283030-#[derive(Clone, Debug)]
3131-pub enum OAuthBackendConfig {
3232- ATProtocol {
3333- signing_keys: SigningKeys,
3434- },
3535- AIP {
3636- hostname: String,
3737- client_id: String,
3838- client_secret: String,
3939- },
4040-}
41294230#[derive(Clone)]
4331pub struct ServiceKey(KeyData);
···5846 pub redis_url: String,
5947 pub admin_dids: AdminDIDs,
6048 pub dns_nameservers: DnsNameservers,
6161- pub oauth_backend: OAuthBackendConfig,
6262- pub enable_task_oauth_requests_cleanup: bool,
6363- pub enable_task_identity_refresh: bool,
4949+ pub oauth_client_credentials_key: OAuthClientCredentialsKey,
6450 pub enable_tap: bool,
6551 pub content_storage: String,
6652 pub service_key: ServiceKey,
···7359 pub facets_tags_max: usize,
7460 pub facets_links_max: usize,
7561 pub facets_max: usize,
6262+ /// Optional MCP JWT signing key (defaults to http_cookie_key if not set)
6363+ pub mcp_jwt_key: Option<String>,
7664}
77657866impl Config {
···112100113101 let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?;
114102115115- // Create OAuth backend configuration based on environment variables
116116- let oauth_backend_type = default_env("OAUTH_BACKEND", "pds");
117117- let oauth_backend = match oauth_backend_type.to_lowercase().as_str() {
118118- "pds" => {
119119- let signing_keys: SigningKeys =
120120- require_env("SIGNING_KEYS").and_then(|value| value.try_into())?;
121121- OAuthBackendConfig::ATProtocol { signing_keys }
122122- }
123123- "aip" => {
124124- let hostname =
125125- require_env("AIP_HOSTNAME").map(|value| format!("https://{value}"))?;
126126- let client_id = require_env("AIP_CLIENT_ID")?;
127127- let client_secret = require_env("AIP_CLIENT_SECRET")?;
128128- OAuthBackendConfig::AIP {
129129- hostname,
130130- client_id,
131131- client_secret,
132132- }
133133- }
134134- _ => return Err(ConfigError::InvalidOAuthBackend(oauth_backend_type).into()),
135135- };
136136-137137- // Parse optional task enablement flags
138138- let enable_task_oauth_requests_cleanup =
139139- default_env("ENABLE_TASK_OAUTH_REQUESTS_CLEANUP", "true")
140140- .parse::<bool>()
141141- .unwrap_or(true);
142142- let enable_task_identity_refresh = default_env("ENABLE_TASK_IDENTITY_REFRESH", "true")
143143- .parse::<bool>()
144144- .unwrap_or(true);
103103+ // Parse OAuth client credentials key for AT Protocol OAuth
104104+ let oauth_client_credentials_key: OAuthClientCredentialsKey =
105105+ require_env("OAUTH_CLIENT_CREDENTIALS_KEY").and_then(|value| value.try_into())?;
145106146107 let enable_tap = parse_bool_env("ENABLE_TAP", true);
147108···189150 .parse::<usize>()
190151 .unwrap_or(10);
191152153153+ // Parse optional MCP JWT key (defaults to http_cookie_key if not set)
154154+ let mcp_jwt_key_str = optional_env("MCP_JWT_KEY");
155155+ let mcp_jwt_key = if mcp_jwt_key_str.is_empty() {
156156+ None
157157+ } else {
158158+ Some(mcp_jwt_key_str)
159159+ };
160160+192161 Ok(Self {
193162 version: version()?,
194163 http_port,
···204173 redis_url,
205174 admin_dids,
206175 dns_nameservers,
207207- oauth_backend,
208208- enable_task_oauth_requests_cleanup,
209209- enable_task_identity_refresh,
176176+ oauth_client_credentials_key,
210177 enable_tap,
211178 content_storage,
212179 service_key,
···219186 facets_tags_max,
220187 facets_links_max,
221188 facets_max,
189189+ mcp_jwt_key,
222190 })
223191 }
224192225225- pub fn select_oauth_signing_key(&self) -> Result<(String, String)> {
226226- match &self.oauth_backend {
227227- OAuthBackendConfig::ATProtocol { signing_keys } => {
228228- let item = signing_keys.as_ref().first();
229229- item.map(|(key, value)| (key.clone(), value.clone()))
230230- .ok_or(ConfigError::SigningKeysEmpty.into())
231231- }
232232- OAuthBackendConfig::AIP { .. } => {
233233- Err(ConfigError::SigningKeysNotAvailableForAIP.into())
234234- }
235235- }
193193+ /// Returns the OAuth client credentials key as (public_key_did, key_data) pair
194194+ pub fn select_oauth_signing_key(&self) -> Result<(String, KeyData)> {
195195+ let key_data = self.oauth_client_credentials_key.as_ref().clone();
196196+ let public_key = to_public(&key_data)?;
197197+ Ok((public_key.to_string(), key_data))
236198 }
237199238200 /// Check if a DID is in the admin allow list
239201 pub fn is_admin(&self, did: &str) -> bool {
240202 self.admin_dids.as_ref().contains(&did.to_string())
203203+ }
204204+205205+ /// Returns the OAuth scope string with the external_base interpolated
206206+ pub fn oauth_scope(&self) -> String {
207207+ format!(
208208+ "atproto include:community.lexicon.calendar.authFull?aud=did:web:{}#smokesignal include:events.smokesignal.authFull blob:*/*",
209209+ self.external_base
210210+ )
241211 }
242212}
243213···366336 }
367337}
368338369369-impl AsRef<OrderMap<String, String>> for SigningKeys {
370370- fn as_ref(&self) -> &OrderMap<String, String> {
371371- &self.0
372372- }
373373-}
374374-375375-impl TryFrom<String> for SigningKeys {
339339+impl TryFrom<String> for OAuthClientCredentialsKey {
376340 type Error = anyhow::Error;
377341 fn try_from(value: String) -> Result<Self, Self::Error> {
378378- // Allow empty signing keys for initial pass with in-memory storage
379342 if value.is_empty() {
380380- return Ok(Self(OrderMap::new()));
343343+ return Err(ConfigError::EmptySigningKeys.into());
381344 }
382345383383- // Parse semicolon-separated DID method key strings
384384- let private_keys: Vec<&str> = value.split(';').filter(|s| !s.is_empty()).collect();
346346+ identify_key(&value)
347347+ .map(OAuthClientCredentialsKey)
348348+ .map_err(|err| err.into())
349349+ }
350350+}
385351386386- if private_keys.is_empty() {
387387- return Ok(Self(OrderMap::new()));
388388- }
389389-390390- let mut signing_keys = OrderMap::new();
391391-392392- for private_key in private_keys {
393393- // Verify this is a private key using identify_key
394394- let key_data = match identify_key(private_key) {
395395- Ok(key_data) => key_data,
396396- Err(err) => {
397397- tracing::error!(?err, ?private_key, "failed to identify key");
398398- continue;
399399- }
400400- };
401401-402402- match key_data.0 {
403403- KeyType::P256Public | KeyType::K256Public => {
404404- tracing::error!(?private_key, "public key found");
405405- continue;
406406- }
407407- _ => {}
408408- }
409409-410410- // Generate public key using to_public and use it as the identifier
411411- let public_key = match to_public(&key_data) {
412412- Ok(public_key) => public_key,
413413- Err(err) => {
414414- tracing::error!(
415415- ?err,
416416- ?private_key,
417417- "unable to derive public key from signing key"
418418- );
419419- continue;
420420- }
421421- };
422422-423423- // Store the original DID method key string as the value
424424- // This allows us to call identify_key again later when we need the KeyData
425425- signing_keys.insert(public_key.to_string(), private_key.to_string());
426426- }
427427-428428- // Check if we have any valid keys
429429- if signing_keys.is_empty() {
430430- return Err(ConfigError::EmptySigningKeys.into());
431431- }
432432-433433- Ok(Self(signing_keys))
352352+impl AsRef<KeyData> for OAuthClientCredentialsKey {
353353+ fn as_ref(&self) -> &KeyData {
354354+ &self.0
434355 }
435356}
436357
+3-99
src/config_errors.rs
···1313 #[error("error-smokesignal-config-1 {0} must be set")]
1414 EnvVarRequired(String),
15151616- /// Error when the signing keys file cannot be read.
1717- ///
1818- /// This error occurs when the application fails to read the file
1919- /// containing signing keys, typically due to file system permissions
2020- /// or missing file issues.
2121- #[error("error-smokesignal-config-2 Unable to read signing keys file: {0:?}")]
2222- ReadSigningKeysFailed(std::io::Error),
2323-2424- /// Error when the signing keys file cannot be parsed.
2525- ///
2626- /// This error occurs when the signing keys file contains malformed JSON
2727- /// that cannot be properly deserialized.
2828- #[error("error-smokesignal-config-3 Unable to parse signing keys file: {0:?}")]
2929- ParseSigningKeysFailed(serde_json::Error),
3030-3116 /// Error when no valid signing keys are found.
3217 ///
3333- /// This error occurs when the signing keys file does not contain any
3434- /// valid keys that the application can use for signing operations.
3535- #[error("error-smokesignal-config-4 Signing keys must contain at least one valid key")]
1818+ /// This error occurs when the OAuth client credentials key is empty
1919+ /// or cannot be identified as a valid key.
2020+ #[error("error-smokesignal-config-4 OAuth client credentials key must be a valid key")]
3621 EmptySigningKeys,
37223838- /// Error when no valid OAuth active keys are found.
3939- ///
4040- /// This error occurs when the configuration does not include any
4141- /// valid keys that can be used for OAuth operations.
4242- #[error("error-smokesignal-config-5 OAuth active keys must contain at least one valid key")]
4343- EmptyOAuthActiveKeys,
4444-4545- /// Error when no valid invitation active keys are found.
4646- ///
4747- /// This error occurs when the configuration does not include any
4848- /// valid keys that can be used for invitation operations.
4949- #[error(
5050- "error-smokesignal-config-6 Invitation active keys must contain at least one valid key"
5151- )]
5252- EmptyInvitationActiveKeys,
5353-5423 /// Error when the PORT environment variable cannot be parsed.
5524 ///
5625 /// This error occurs when the PORT environment variable contains a value
···7948 #[error("error-smokesignal-config-10 One of GIT_HASH or CARGO_PKG_VERSION must be set")]
8049 VersionNotSet,
81508282- /// Error when a referenced signing key is not found.
8383- ///
8484- /// This error occurs when attempting to use a signing key that
8585- /// does not exist in the loaded signing keys configuration.
8686- #[error("error-smokesignal-config-11 Signing key not found")]
8787- SigningKeyNotFound,
8888-8951 /// Error when a DNS nameserver IP cannot be parsed.
9052 ///
9153 /// This error occurs when the DNS_NAMESERVERS environment variable contains
9254 /// an IP address that cannot be parsed as a valid IpAddr.
9355 #[error("error-smokesignal-config-12 Unable to parse nameserver IP '{0}': {1}")]
9456 NameserverParsingFailed(String, std::net::AddrParseError),
9595-9696- /// Error when the signing keys file is not found.
9797- ///
9898- /// This error occurs when the file specified in the SIGNING_KEYS environment
9999- /// variable does not exist on the file system.
100100- #[error("error-smokesignal-config-13 Signing keys file not found: {0}")]
101101- SigningKeysFileNotFound(String),
102102-103103- /// Error when the signing keys file is empty.
104104- ///
105105- /// This error occurs when the file specified in the SIGNING_KEYS environment
106106- /// variable exists but contains no data.
107107- #[error("error-smokesignal-config-14 Signing keys file is empty")]
108108- EmptySigningKeysFile,
109109-110110- /// Error when the JWKS structure doesn't contain any keys.
111111- ///
112112- /// This error occurs when the signing keys file contains a valid JWKS structure,
113113- /// but the 'keys' array is empty.
114114- #[error("error-smokesignal-config-15 No keys found in JWKS")]
115115- MissingKeysInJWKS,
116116-117117- /// Error when signing keys fail validation.
118118- ///
119119- /// This error occurs when the signing keys file contains keys
120120- /// that fail validation checks (such as having invalid format).
121121- #[error("error-smokesignal-config-16 Signing keys validation failed: {0:?}")]
122122- SigningKeysValidationFailed(Vec<String>),
123123-124124- /// Error when AIP OAuth configuration is incomplete.
125125- ///
126126- /// This error occurs when oauth_backend is set to "aip" but
127127- /// required AIP configuration values are missing.
128128- #[error(
129129- "error-smokesignal-config-17 When oauth_backend is 'aip', AIP_HOSTNAME, AIP_CLIENT_ID, and AIP_CLIENT_SECRET must all be set"
130130- )]
131131- AipConfigurationIncomplete,
132132-133133- /// Error when oauth_backend has an invalid value.
134134- ///
135135- /// This error occurs when the OAUTH_BACKEND environment variable
136136- /// contains a value other than "aip" or "pds".
137137- #[error("error-smokesignal-config-18 oauth_backend must be either 'aip' or 'pds', got: {0}")]
138138- InvalidOAuthBackend(String),
139139-140140- /// Error when signing keys list is empty.
141141- ///
142142- /// This error occurs when attempting to select an OAuth signing key
143143- /// but the signing keys list contains no entries.
144144- #[error("error-smokesignal-config-19 signing keys is empty")]
145145- SigningKeysEmpty,
146146-147147- /// Error when signing keys are not available for AIP OAuth backend.
148148- ///
149149- /// This error occurs when attempting to access signing keys
150150- /// while using the AIP OAuth backend, which doesn't support signing keys.
151151- #[error("error-smokesignal-config-20 signing keys not available for AIP OAuth backend")]
152152- SigningKeysNotAvailableForAIP,
1535715458 /// Error when the EMAIL_SECRET_KEY cannot be decoded.
15559 ///
-234
src/http/auth_utils.rs
···11-use anyhow::Result;
22-use atproto_client::client::get_dpop_json_with_headers;
33-use deadpool_redis::redis::AsyncCommands;
44-use http::HeaderMap;
55-use reqwest::Client;
66-use sha2::{Digest, Sha256};
77-88-use crate::atproto::auth::create_dpop_auth_from_aip_session;
99-use crate::config::OAuthBackendConfig;
1010-use crate::http::context::WebContext;
111use crate::http::errors::LoginError;
1212-use crate::http::errors::web_error::WebError;
1313-use crate::http::middleware_auth::Auth;
1414-1515-/// TTL for AIP session ready cache entries (5 minutes).
1616-const AIP_SESSION_READY_CACHE_TTL_SECS: i64 = 300;
1717-1818-/// Result of checking if an AIP session is ready for AT Protocol operations.
1919-pub(crate) enum AipSessionStatus {
2020- /// Session is valid and ready for operations.
2121- Ready,
2222- /// Session is stale and requires re-authentication.
2323- Stale,
2424- /// Not an AIP session (PDS or unauthenticated).
2525- NotAip,
2626-}
2727-2828-/// Check if an AIP session is still valid by requesting service auth from the user's PDS.
2929-///
3030-/// Calls `com.atproto.server.getServiceAuth` on the user's PDS with:
3131-/// - `aud`: did:web:[aip_hostname] (the AIP server)
3232-/// - `lxm`: tools.graze.aip.ready (the target method)
3333-///
3434-/// Returns `true` if the session is valid, `false` if stale (401 during exchange
3535-/// or service auth request).
3636-pub(crate) async fn check_aip_session_ready(
3737- http_client: &Client,
3838- aip_hostname: &str,
3939- user_pds: &str,
4040- access_token: &str,
4141-) -> Result<bool> {
4242- // Step 1: Attempt to get DPoP auth via AIP session exchange
4343- // This is where stale tokens are typically detected (401 response)
4444- let dpop_auth =
4545- match create_dpop_auth_from_aip_session(http_client, aip_hostname, access_token).await {
4646- Ok(auth) => auth,
4747- Err(e) => {
4848- let error_str = e.to_string();
4949- if error_str.contains("401")
5050- || error_str.contains("Unauthorized")
5151- || error_str.contains("invalid_token")
5252- || error_str.contains("expired")
5353- {
5454- tracing::debug!("AIP session exchange returned stale token - session is stale");
5555- return Ok(false);
5656- }
5757- return Err(e);
5858- }
5959- };
6060-6161- // Step 2: Call getServiceAuth on the user's PDS
6262- // Strip https:// prefix since config stores the full URL but did:web needs just the hostname
6363- let aip_did = format!("did:web:{}", aip_hostname.trim_start_matches("https://"));
6464- let service_auth_url = format!("{}/xrpc/tools.graze.aip.ready", user_pds,);
6565-6666- let mut headers = HeaderMap::default();
6767- headers.append("atproto-proxy", format!("{aip_did}#aip").parse()?);
6868-6969- let response =
7070- get_dpop_json_with_headers(http_client, &dpop_auth, &service_auth_url, &headers).await;
7171-7272- match response {
7373- Ok(json_value) => {
7474- let is_empty = json_value
7575- .as_object()
7676- .is_some_and(|payload| payload.is_empty());
7777- Ok(is_empty)
7878- }
7979- Err(e) => {
8080- let error_str = e.to_string();
8181- if error_str.contains("401")
8282- || error_str.contains("Unauthorized")
8383- || error_str.contains("invalid_token")
8484- || error_str.contains("expired")
8585- {
8686- tracing::debug!("PDS getServiceAuth returned stale token - session is stale");
8787- Ok(false)
8888- } else {
8989- Err(anyhow::anyhow!(
9090- "error-smokesignal-aip-ready-1 Service auth check failed: {}",
9191- error_str
9292- ))
9393- }
9494- }
9595- }
9696-}
9797-9898-/// Generate a cache key for AIP session ready status.
9999-///
100100-/// Uses a SHA-256 hash of the access token to avoid storing raw tokens in Redis.
101101-fn aip_session_cache_key(access_token: &str) -> String {
102102- let mut hasher = Sha256::new();
103103- hasher.update(access_token.as_bytes());
104104- let hash = hasher.finalize();
105105- format!("aip_session_ready:{:x}", hash)
106106-}
107107-108108-/// Check Redis cache for AIP session ready status.
109109-///
110110-/// Returns `Some(true)` if cached as ready, `Some(false)` if cached as stale,
111111-/// or `None` on cache miss or error.
112112-async fn get_cached_aip_session_status(
113113- web_context: &WebContext,
114114- access_token: &str,
115115-) -> Option<bool> {
116116- let cache_key = aip_session_cache_key(access_token);
117117-118118- let mut conn = match web_context.cache_pool.get().await {
119119- Ok(conn) => conn,
120120- Err(e) => {
121121- tracing::debug!(?e, "Failed to get Redis connection for AIP session cache");
122122- return None;
123123- }
124124- };
125125-126126- match conn.get::<_, Option<String>>(&cache_key).await {
127127- Ok(Some(value)) => {
128128- tracing::debug!(cache_key = %cache_key, value = %value, "AIP session cache hit");
129129- Some(value == "ready")
130130- }
131131- Ok(None) => {
132132- tracing::debug!(cache_key = %cache_key, "AIP session cache miss");
133133- None
134134- }
135135- Err(e) => {
136136- tracing::debug!(?e, "Redis error reading AIP session cache");
137137- None
138138- }
139139- }
140140-}
141141-142142-/// Cache AIP session ready status in Redis with TTL.
143143-async fn cache_aip_session_status(web_context: &WebContext, access_token: &str, is_ready: bool) {
144144- let cache_key = aip_session_cache_key(access_token);
145145- let value = if is_ready { "ready" } else { "stale" };
146146-147147- let mut conn = match web_context.cache_pool.get().await {
148148- Ok(conn) => conn,
149149- Err(e) => {
150150- tracing::debug!(
151151- ?e,
152152- "Failed to get Redis connection for AIP session cache write"
153153- );
154154- return;
155155- }
156156- };
157157-158158- if let Err(e) = conn
159159- .set_ex::<_, _, ()>(&cache_key, value, AIP_SESSION_READY_CACHE_TTL_SECS as u64)
160160- .await
161161- {
162162- tracing::debug!(?e, "Failed to cache AIP session status");
163163- } else {
164164- tracing::debug!(
165165- cache_key = %cache_key,
166166- value = %value,
167167- ttl_secs = AIP_SESSION_READY_CACHE_TTL_SECS,
168168- "Cached AIP session status"
169169- );
170170- }
171171-}
172172-173173-/// Check if the current AIP session is ready for AT Protocol operations.
174174-///
175175-/// This calls the AIP ready endpoint to validate the access token.
176176-/// Results are cached in Redis for 5 minutes to avoid repeated validation.
177177-/// For PDS sessions, this is a no-op (returns NotAip).
178178-pub(crate) async fn require_valid_aip_session(
179179- web_context: &WebContext,
180180- auth: &Auth,
181181-) -> Result<AipSessionStatus, WebError> {
182182- match auth {
183183- Auth::Aip {
184184- profile,
185185- access_token,
186186- stale,
187187- } => {
188188- // If already marked stale, don't bother checking again
189189- if *stale {
190190- return Ok(AipSessionStatus::Stale);
191191- }
192192-193193- // Check Redis cache first
194194- if let Some(cached_ready) =
195195- get_cached_aip_session_status(web_context, access_token).await
196196- {
197197- return if cached_ready {
198198- Ok(AipSessionStatus::Ready)
199199- } else {
200200- Ok(AipSessionStatus::Stale)
201201- };
202202- }
203203-204204- // Get AIP hostname from config
205205- let aip_hostname = match &web_context.config.oauth_backend {
206206- OAuthBackendConfig::AIP { hostname, .. } => hostname,
207207- _ => return Ok(AipSessionStatus::NotAip),
208208- };
209209-210210- // Check the ready endpoint using the user's PDS
211211- let is_ready = check_aip_session_ready(
212212- &web_context.http_client,
213213- aip_hostname,
214214- &profile.pds,
215215- access_token,
216216- )
217217- .await
218218- .map_err(|e| {
219219- tracing::warn!(?e, "AIP ready check failed");
220220- WebError::InternalError
221221- })?;
222222-223223- // Cache the result
224224- cache_aip_session_status(web_context, access_token, is_ready).await;
225225-226226- if is_ready {
227227- Ok(AipSessionStatus::Ready)
228228- } else {
229229- Ok(AipSessionStatus::Stale)
230230- }
231231- }
232232- Auth::Pds { .. } => Ok(AipSessionStatus::NotAip),
233233- Auth::Unauthenticated => Ok(AipSessionStatus::NotAip),
234234- }
235235-}
23622373/// Validates and filters incoming login requests.
2384///
···11+//! Cookie utilities for session management.
22+//!
33+//! This module provides the `SessionCookie` struct and utilities for encoding/decoding
44+//! session data directly in browser cookies rather than storing tokens in the database.
55+66+use anyhow::Result;
77+use chrono::{DateTime, Utc};
88+use serde::{Deserialize, Serialize};
99+1010+use crate::http::errors::WebSessionError;
1111+1212+/// Session data stored directly in the browser cookie.
1313+///
1414+/// This struct contains all the data needed to authenticate requests without
1515+/// requiring a database lookup. The cookie is encrypted using the HTTP_COOKIE_KEY.
1616+#[derive(Clone, PartialEq, Serialize, Deserialize)]
1717+pub(crate) struct SessionCookie {
1818+ /// The user's DID (Decentralized Identifier)
1919+ pub did: String,
2020+ /// The OAuth access token for AT Protocol requests
2121+ pub access_token: String,
2222+ /// The OAuth refresh token for obtaining new access tokens
2323+ pub refresh_token: Option<String>,
2424+ /// When the access token expires
2525+ pub expires_at: DateTime<Utc>,
2626+ /// The DPoP private key in did:key format for proof-of-possession
2727+ pub dpop_private_key: String,
2828+ /// The issuer (authorization server) URL
2929+ pub issuer: String,
3030+}
3131+3232+impl SessionCookie {
3333+ /// Check if the access token has expired
3434+ pub fn is_expired(&self) -> bool {
3535+ Utc::now() >= self.expires_at
3636+ }
3737+3838+ /// Check if the access token will expire within the given duration
3939+ pub fn expires_within(&self, duration: chrono::Duration) -> bool {
4040+ Utc::now() + duration >= self.expires_at
4141+ }
4242+}
4343+4444+impl TryFrom<String> for SessionCookie {
4545+ type Error = anyhow::Error;
4646+4747+ fn try_from(value: String) -> Result<Self, Self::Error> {
4848+ serde_json::from_str(&value)
4949+ .map_err(WebSessionError::DeserializeFailed)
5050+ .map_err(Into::into)
5151+ }
5252+}
5353+5454+impl TryInto<String> for SessionCookie {
5555+ type Error = anyhow::Error;
5656+5757+ fn try_into(self) -> Result<String, Self::Error> {
5858+ serde_json::to_string(&self)
5959+ .map_err(WebSessionError::SerializeFailed)
6060+ .map_err(Into::into)
6161+ }
6262+}
6363+6464+#[cfg(test)]
6565+mod tests {
6666+ use super::*;
6767+ use chrono::Duration;
6868+6969+ #[test]
7070+ fn test_session_cookie_serialization() {
7171+ let session = SessionCookie {
7272+ did: "did:plc:test123".to_string(),
7373+ access_token: "test_access_token".to_string(),
7474+ refresh_token: Some("test_refresh_token".to_string()),
7575+ expires_at: Utc::now() + Duration::hours(1),
7676+ dpop_private_key: "did:key:test".to_string(),
7777+ issuer: "https://bsky.social".to_string(),
7878+ };
7979+8080+ let serialized: String = session.clone().try_into().unwrap();
8181+ let deserialized: SessionCookie = serialized.try_into().unwrap();
8282+8383+ assert_eq!(session.did, deserialized.did);
8484+ assert_eq!(session.access_token, deserialized.access_token);
8585+ assert_eq!(session.refresh_token, deserialized.refresh_token);
8686+ assert_eq!(session.dpop_private_key, deserialized.dpop_private_key);
8787+ assert_eq!(session.issuer, deserialized.issuer);
8888+ }
8989+9090+ #[test]
9191+ fn test_is_expired() {
9292+ let expired_session = SessionCookie {
9393+ did: "did:plc:test123".to_string(),
9494+ access_token: "test".to_string(),
9595+ refresh_token: None,
9696+ expires_at: Utc::now() - Duration::hours(1),
9797+ dpop_private_key: "did:key:test".to_string(),
9898+ issuer: "https://bsky.social".to_string(),
9999+ };
100100+101101+ let valid_session = SessionCookie {
102102+ did: "did:plc:test123".to_string(),
103103+ access_token: "test".to_string(),
104104+ refresh_token: None,
105105+ expires_at: Utc::now() + Duration::hours(1),
106106+ dpop_private_key: "did:key:test".to_string(),
107107+ issuer: "https://bsky.social".to_string(),
108108+ };
109109+110110+ assert!(expired_session.is_expired());
111111+ assert!(!valid_session.is_expired());
112112+ }
113113+}
-7
src/http/errors/blob_error.rs
···4141 #[error("error-smokesignal-blob-5 No avatar file provided")]
4242 NoAvatarFile,
43434444- /// Error when authentication backend doesn't match configuration.
4545- ///
4646- /// This error occurs when the user's authentication method (PDS/AIP)
4747- /// doesn't match the configured OAuth backend.
4848- #[error("error-smokesignal-blob-7 Mismatched auth and backend config")]
4949- MismatchedAuthBackend,
5050-5144 /// Error when parsing an existing profile record fails.
5245 ///
5346 /// This error occurs when deserializing a profile record from the PDS
-6
src/http/errors/delete_event_errors.rs
···1515 )]
1616 EventNotFound { aturi: String },
17171818- /// Error when there is an authentication configuration mismatch.
1919- ///
2020- /// This error occurs when the authentication type doesn't match the
2121- /// configured OAuth backend, indicating a system configuration issue.
2222- #[error("error-smokesignal-delete-event-2 Authentication configuration mismatch")]
2323- AuthenticationConfigurationMismatch,
2418}
-21
src/http/errors/login_error.rs
···4646 /// due to expiration or invalid state parameter.
4747 #[error("error-smokesignal-login-6 oauth request not found in storage")]
4848 OAuthRequestNotFound,
4949-5050- /// Error when HTTP request for AIP OAuth userinfo fails.
5151- ///
5252- /// This error occurs during AIP OAuth flow when the HTTP request
5353- /// to retrieve user information from the AIP server fails.
5454- #[error("error-smokesignal-login-7 HTTP request for userinfo failed")]
5555- AipUserinfoRequestFailed,
5656-5757- /// Error when parsing AIP OAuth userinfo response fails.
5858- ///
5959- /// This error occurs during AIP OAuth flow when the JSON response
6060- /// from the AIP userinfo endpoint cannot be parsed.
6161- #[error("error-smokesignal-login-8 Parsing HTTP response for userinfo failed")]
6262- AipUserinfoParsingFailed,
6363-6464- /// Error when AIP OAuth server returns an error response.
6565- ///
6666- /// This error occurs when the AIP OAuth server returns an error
6767- /// in the userinfo response instead of valid user claims.
6868- #[error("error-smokesignal-login-9 AIP OAuth server error: {message}")]
6969- AipServerError { message: String },
7049}
-40
src/http/errors/web_error.rs
···158158 /// such as creating, viewing, or deactivating LFG records.
159159 #[error(transparent)]
160160 LfgError(#[from] LfgError),
161161-162162- /// The AIP session has expired and the user must re-authenticate.
163163- ///
164164- /// This error occurs when an AT Protocol operation is attempted with a stale
165165- /// AIP session token. The user should be redirected to the login page.
166166- /// The contained string is the destination URL to redirect to after re-authentication.
167167- ///
168168- /// **Error Code:** `error-smokesignal-web-3`
169169- #[error(
170170- "error-smokesignal-web-3 Session expired, re-authentication required (destination: {0})"
171171- )]
172172- SessionStale(String),
173173-174174- /// An internal server error occurred.
175175- ///
176176- /// This is a generic error for internal failures that don't fit other categories.
177177- ///
178178- /// **Error Code:** `error-smokesignal-web-4`
179179- #[error("error-smokesignal-web-4 Internal server error")]
180180- InternalError,
181161}
182162183163/// Implementation of Axum's `IntoResponse` trait for WebError.
···190170 fn into_response(self) -> Response {
191171 match self {
192172 WebError::MiddlewareAuthError(err) => err.into_response(),
193193- WebError::SessionStale(destination) => {
194194- // For HTMX requests, use HX-Redirect to force a full page navigation to login
195195- // For regular requests, this will also work as the browser follows the redirect
196196- // Include the destination so users return to where they were after re-authenticating
197197- let encoded_destination = urlencoding::encode(&destination);
198198- let redirect_url = format!(
199199- "/oauth/login?reason=session_expired&destination={}",
200200- encoded_destination
201201- );
202202- (
203203- StatusCode::UNAUTHORIZED,
204204- [("HX-Redirect", redirect_url)],
205205- "Session expired. Please log in again.",
206206- )
207207- .into_response()
208208- }
209209- WebError::InternalError => {
210210- tracing::error!("Internal server error");
211211- StatusCode::INTERNAL_SERVER_ERROR.into_response()
212212- }
213173 _ => {
214174 tracing::error!(error = ?self, "internal server error");
215175 StatusCode::INTERNAL_SERVER_ERROR.into_response()
+4-19
src/http/handle_accept_rsvp.rs
···12121313use crate::{
1414 atproto::{
1515- auth::{create_dpop_auth_from_aip_session, create_dpop_auth_from_oauth_session},
1515+ auth::create_dpop_auth_from_session,
1616 lexicon::acceptance::{Acceptance, NSID as ACCEPTANCE_NSID, TypedAcceptance},
1717 },
1818- config::OAuthBackendConfig,
1918 http::{
2019 acceptance_utils::{
2120 format_error_html, format_success_html, send_acceptance_email_notification,
2221 verify_event_organizer_authorization,
2322 },
2424- auth_utils::{AipSessionStatus, require_valid_aip_session},
2523 context::WebContext,
2624 errors::{WebError, acceptance_error::AcceptanceError},
2725 middleware_auth::Auth,
···4341 Form(form): Form<AcceptRsvpForm>,
4442) -> Result<impl IntoResponse, WebError> {
4543 let current_handle = auth.require("/accept_rsvp")?;
4444+ let session = auth.session().ok_or(AcceptanceError::NotAuthorized)?;
46454746 // Get the RSVP from storage
4847 let rsvp = match rsvp_get(&web_context.pool, &form.rsvp_aturi).await {
···113112114113 let record_key = Tid::new().to_string();
115114116116- // Check AIP session validity before attempting AT Protocol operation
117117- if let AipSessionStatus::Stale = require_valid_aip_session(&web_context, &auth).await? {
118118- return Err(WebError::SessionStale("/".to_string()));
119119- }
120120-121121- // Create DPoP auth based on OAuth backend type
122122- let dpop_auth = match (&auth, &web_context.config.oauth_backend) {
123123- (Auth::Pds { session, .. }, OAuthBackendConfig::ATProtocol { .. }) => {
124124- create_dpop_auth_from_oauth_session(session)?
125125- }
126126- (Auth::Aip { access_token, .. }, OAuthBackendConfig::AIP { hostname, .. }) => {
127127- create_dpop_auth_from_aip_session(&web_context.http_client, hostname, access_token)
128128- .await?
129129- }
130130- _ => return Err(AcceptanceError::NotAuthorized.into()),
131131- };
115115+ // Create DPoP auth from session
116116+ let dpop_auth = create_dpop_auth_from_session(session)?;
132117133118 let typed_acceptance = TypedAcceptance::new(acceptance.clone());
134119
···11+//! MCP JWT utilities for wrapping and unwrapping AT Protocol tokens.
22+//!
33+//! MCP clients receive JWT-wrapped tokens that contain the underlying
44+//! AT Protocol access token along with additional metadata.
55+66+use anyhow::{Context, Result, anyhow};
77+use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
88+use chrono::{Duration, Utc};
99+use serde::{Deserialize, Serialize};
1010+use sha2::Sha256;
1111+1212+/// Claims for the MCP access token JWT.
1313+#[derive(Debug, Serialize, Deserialize)]
1414+pub struct McpTokenClaims {
1515+ /// Subject (user DID)
1616+ pub sub: String,
1717+ /// Issuer (smokesignal server)
1818+ pub iss: String,
1919+ /// Audience (client_id)
2020+ pub aud: String,
2121+ /// Expiration time (Unix timestamp)
2222+ pub exp: i64,
2323+ /// Issued at (Unix timestamp)
2424+ pub iat: i64,
2525+ /// JWT ID (unique identifier)
2626+ pub jti: String,
2727+ /// Wrapped AT Protocol access token
2828+ pub atproto_access_token: String,
2929+ /// Client ID
3030+ pub client_id: String,
3131+ /// MCP client internal ID
3232+ pub mcp_client_id: String,
3333+}
3434+3535+/// Create an MCP access token by wrapping an AT Protocol token.
3636+pub fn wrap_token(
3737+ secret_key: &[u8],
3838+ issuer: &str,
3939+ client_id: &str,
4040+ mcp_client_id: &str,
4141+ did: &str,
4242+ atproto_access_token: &str,
4343+ expires_in_secs: i64,
4444+) -> Result<String> {
4545+ let now = Utc::now();
4646+ let exp = now + Duration::seconds(expires_in_secs);
4747+4848+ let claims = McpTokenClaims {
4949+ sub: did.to_string(),
5050+ iss: issuer.to_string(),
5151+ aud: client_id.to_string(),
5252+ exp: exp.timestamp(),
5353+ iat: now.timestamp(),
5454+ jti: ulid::Ulid::new().to_string(),
5555+ atproto_access_token: atproto_access_token.to_string(),
5656+ client_id: client_id.to_string(),
5757+ mcp_client_id: mcp_client_id.to_string(),
5858+ };
5959+6060+ encode_jwt(secret_key, &claims)
6161+}
6262+6363+/// Unwrap an MCP token to extract the underlying AT Protocol token and claims.
6464+pub fn unwrap_token(secret_key: &[u8], token: &str) -> Result<McpTokenClaims> {
6565+ let claims: McpTokenClaims = decode_jwt(secret_key, token)?;
6666+6767+ // Verify expiration
6868+ let now = Utc::now().timestamp();
6969+ if claims.exp < now {
7070+ return Err(anyhow!("Token has expired"));
7171+ }
7272+7373+ Ok(claims)
7474+}
7575+7676+/// Encode claims into a JWT using HS256.
7777+fn encode_jwt<T: Serialize>(secret_key: &[u8], claims: &T) -> Result<String> {
7878+ // Header: {"alg":"HS256","typ":"JWT"}
7979+ let header = r#"{"alg":"HS256","typ":"JWT"}"#;
8080+ let header_b64 = URL_SAFE_NO_PAD.encode(header);
8181+8282+ // Payload
8383+ let payload = serde_json::to_string(claims).context("Failed to serialize JWT claims")?;
8484+ let payload_b64 = URL_SAFE_NO_PAD.encode(&payload);
8585+8686+ // Signature
8787+ let message = format!("{}.{}", header_b64, payload_b64);
8888+ let signature = hmac_sha256(secret_key, message.as_bytes());
8989+ let signature_b64 = URL_SAFE_NO_PAD.encode(signature);
9090+9191+ Ok(format!("{}.{}.{}", header_b64, payload_b64, signature_b64))
9292+}
9393+9494+/// Decode and verify a JWT using HS256.
9595+fn decode_jwt<T: for<'de> Deserialize<'de>>(secret_key: &[u8], token: &str) -> Result<T> {
9696+ let parts: Vec<&str> = token.split('.').collect();
9797+ if parts.len() != 3 {
9898+ return Err(anyhow!("Invalid JWT format"));
9999+ }
100100+101101+ let header_b64 = parts[0];
102102+ let payload_b64 = parts[1];
103103+ let signature_b64 = parts[2];
104104+105105+ // Verify signature
106106+ let message = format!("{}.{}", header_b64, payload_b64);
107107+ let expected_signature = hmac_sha256(secret_key, message.as_bytes());
108108+ let expected_signature_b64 = URL_SAFE_NO_PAD.encode(expected_signature);
109109+110110+ if signature_b64 != expected_signature_b64 {
111111+ return Err(anyhow!("Invalid JWT signature"));
112112+ }
113113+114114+ // Decode payload
115115+ let payload_bytes = URL_SAFE_NO_PAD
116116+ .decode(payload_b64)
117117+ .context("Failed to decode JWT payload")?;
118118+119119+ let claims: T =
120120+ serde_json::from_slice(&payload_bytes).context("Failed to deserialize JWT claims")?;
121121+122122+ Ok(claims)
123123+}
124124+125125+/// Compute HMAC-SHA256.
126126+fn hmac_sha256(key: &[u8], message: &[u8]) -> Vec<u8> {
127127+ use hmac::{Hmac, Mac};
128128+ type HmacSha256 = Hmac<Sha256>;
129129+130130+ let mut mac =
131131+ HmacSha256::new_from_slice(key).expect("HMAC can take key of any size");
132132+ mac.update(message);
133133+ mac.finalize().into_bytes().to_vec()
134134+}
135135+136136+/// Generate a secure random secret for JWT signing.
137137+pub fn generate_jwt_secret() -> String {
138138+ use rand::RngCore;
139139+ let mut key = [0u8; 32];
140140+ rand::rng().fill_bytes(&mut key);
141141+ hex::encode(key)
142142+}
143143+144144+#[cfg(test)]
145145+mod tests {
146146+ use super::*;
147147+148148+ #[test]
149149+ fn test_wrap_unwrap_token() {
150150+ let secret = b"test_secret_key_for_jwt_signing_123";
151151+ let token = wrap_token(
152152+ secret,
153153+ "https://smokesignal.events",
154154+ "https://client.example.com/mcp",
155155+ "mcp_123",
156156+ "did:plc:abc123",
157157+ "atproto_token_xyz",
158158+ 3600,
159159+ )
160160+ .unwrap();
161161+162162+ let claims = unwrap_token(secret, &token).unwrap();
163163+164164+ assert_eq!(claims.sub, "did:plc:abc123");
165165+ assert_eq!(claims.atproto_access_token, "atproto_token_xyz");
166166+ assert_eq!(claims.client_id, "https://client.example.com/mcp");
167167+ assert_eq!(claims.mcp_client_id, "mcp_123");
168168+ }
169169+170170+ #[test]
171171+ fn test_invalid_signature() {
172172+ let secret1 = b"secret_key_1";
173173+ let secret2 = b"secret_key_2";
174174+175175+ let token = wrap_token(
176176+ secret1,
177177+ "https://smokesignal.events",
178178+ "https://client.example.com/mcp",
179179+ "mcp_123",
180180+ "did:plc:abc123",
181181+ "atproto_token_xyz",
182182+ 3600,
183183+ )
184184+ .unwrap();
185185+186186+ let result = unwrap_token(secret2, &token);
187187+ assert!(result.is_err());
188188+ }
189189+}
+51-83
src/http/middleware_auth.rs
···55 response::Response,
66};
77use axum_extra::extract::PrivateCookieJar;
88-use serde::{Deserialize, Serialize};
98use tracing::{debug, trace};
1091110use crate::{
1212- config::Config, http::context::WebContext, http::errors::WebSessionError,
1111+ config::Config,
1212+ http::context::WebContext,
1313+ http::cookies::SessionCookie,
1314 storage::identity_profile::model::IdentityProfile,
1415};
15161616-use crate::{storage::oauth::model::OAuthSession, storage::oauth::web_session_lookup};
1717-1817use super::errors::middleware_errors::MiddlewareAuthError;
19182020-pub(crate) const AUTH_COOKIE_NAME: &str = "session20251231";
2121-2222-#[derive(Clone, PartialEq, Serialize, Deserialize)]
2323-pub(crate) enum WebSession {
2424- Pds { did: String, session_group: String },
2525- Aip { did: String, access_token: String },
2626-}
2727-2828-impl TryFrom<String> for WebSession {
2929- type Error = anyhow::Error;
3030-3131- fn try_from(value: String) -> Result<Self, Self::Error> {
3232- serde_json::from_str(&value)
3333- .map_err(WebSessionError::DeserializeFailed)
3434- .map_err(Into::into)
3535- }
3636-}
3737-3838-impl TryInto<String> for WebSession {
3939- type Error = anyhow::Error;
4040-4141- fn try_into(self) -> Result<String, Self::Error> {
4242- serde_json::to_string(&self)
4343- .map_err(WebSessionError::SerializeFailed)
4444- .map_err(Into::into)
4545- }
4646-}
1919+/// Cookie name for session storage.
2020+/// Updated version to force re-authentication when cookie format changes.
2121+pub(crate) const AUTH_COOKIE_NAME: &str = "session20260118";
47224823#[derive(Clone)]
4924pub(crate) enum Auth {
5025 Unauthenticated,
5151- Pds {
2626+ Authenticated {
5227 profile: IdentityProfile,
5353- session: OAuthSession,
5454- },
5555- Aip {
5656- profile: IdentityProfile,
5757- access_token: String,
5858- /// Session is stale when the AIP ready endpoint returns 401.
5959- /// When true, AT Protocol operations will fail until re-authentication.
6060- stale: bool,
2828+ session: SessionCookie,
6129 },
6230}
6331···6533 /// Get the profile if authenticated, None otherwise
6634 pub(crate) fn profile(&self) -> Option<&IdentityProfile> {
6735 match self {
6868- Auth::Pds { profile, .. } | Auth::Aip { profile, .. } => Some(profile),
3636+ Auth::Authenticated { profile, .. } => Some(profile),
3737+ Auth::Unauthenticated => None,
3838+ }
3939+ }
4040+4141+ /// Get the session if authenticated, None otherwise
4242+ pub(crate) fn session(&self) -> Option<&SessionCookie> {
4343+ match self {
4444+ Auth::Authenticated { session, .. } => Some(session),
6945 Auth::Unauthenticated => None,
7046 }
7147 }
4848+7249 /// Requires authentication and redirects to login with a signed token containing the original destination
7350 ///
7451 /// This creates a redirect URL with a signed token containing the destination,
7552 /// which the login handler can verify and redirect back to after successful authentication.
7653 pub(crate) fn require(&self, location: &str) -> Result<IdentityProfile, MiddlewareAuthError> {
7754 match self {
7878- Auth::Pds { profile, .. } | Auth::Aip { profile, .. } => {
5555+ Auth::Authenticated { profile, .. } => {
7956 trace!(did = %profile.did, "User authenticated");
8057 return Ok(profile.clone());
8158 }
···9067 /// Use this when you don't need to return to the original page after login
9168 pub(crate) fn require_flat(&self) -> Result<IdentityProfile, MiddlewareAuthError> {
9269 match self {
9393- Auth::Pds { profile, .. } | Auth::Aip { profile, .. } => {
7070+ Auth::Authenticated { profile, .. } => {
9471 trace!(did = %profile.did, "User authenticated");
9572 return Ok(profile.clone());
9673 }
···10986 config: &Config,
11087 ) -> Result<IdentityProfile, MiddlewareAuthError> {
11188 match self {
112112- Auth::Pds { profile, .. } | Auth::Aip { profile, .. } => {
8989+ Auth::Authenticated { profile, .. } => {
11390 if config.is_admin(&profile.did) {
11491 debug!(did = %profile.did, "Admin authenticated");
11592 return Ok(profile.clone());
···142119 web_context.config.http_cookie_key.as_ref().clone(),
143120 );
144121122122+ // Try to get session from cookie
145123 let session = cookie_jar
146124 .get(AUTH_COOKIE_NAME)
147125 .map(|user_cookie| user_cookie.value().to_owned())
148148- .and_then(|inner_value| WebSession::try_from(inner_value).ok());
126126+ .and_then(|inner_value| SessionCookie::try_from(inner_value).ok());
149127150150- if let Some(web_session) = session {
151151- match web_session {
152152- WebSession::Pds { did, session_group } => {
153153- trace!(?session_group, "Found PDS session cookie");
128128+ if let Some(session_cookie) = session {
129129+ trace!(did = %session_cookie.did, "Found session cookie");
154130155155- match web_session_lookup(&web_context.pool, &session_group, Some(&did)).await {
156156- Ok(record) => {
157157- debug!(?session_group, "Session validated");
158158- return Ok(Auth::Pds {
159159- profile: record.0,
160160- session: record.1,
161161- });
162162- }
163163- Err(err) => {
164164- debug!(?session_group, ?err, "Invalid session");
165165- return Ok(Auth::Unauthenticated);
166166- }
167167- };
131131+ // Check if token is expired
132132+ if session_cookie.is_expired() {
133133+ debug!(did = %session_cookie.did, "Session token expired");
134134+ // Token is expired, but we could potentially refresh it
135135+ // For now, treat as unauthenticated and let user re-login
136136+ // A more sophisticated approach would auto-refresh here
137137+ return Ok(Auth::Unauthenticated);
138138+ }
139139+140140+ // Look up the user's profile from the database
141141+ match crate::storage::identity_profile::handle_for_did(
142142+ &web_context.pool,
143143+ &session_cookie.did,
144144+ )
145145+ .await
146146+ {
147147+ Ok(profile) => {
148148+ debug!(did = %session_cookie.did, "Session validated");
149149+ return Ok(Auth::Authenticated {
150150+ profile,
151151+ session: session_cookie,
152152+ });
168153 }
169169- WebSession::Aip { did, access_token } => {
170170- trace!(?access_token, "Found AIP session cookie with access token");
171171- // For AIP OAuth, try to fetch the handle from the database
172172- let profile = sqlx::query_as::<_, IdentityProfile>(
173173- "SELECT * FROM identity_profiles WHERE did = $1",
174174- )
175175- .bind(&did)
176176- .fetch_one(&web_context.pool)
177177- .await
178178- .ok();
179179-180180- if let Some(profile) = profile {
181181- return Ok(Auth::Aip {
182182- profile,
183183- access_token,
184184- stale: false,
185185- });
186186- } else {
187187- return Ok(Auth::Unauthenticated);
188188- }
154154+ Err(err) => {
155155+ debug!(did = %session_cookie.did, ?err, "Failed to look up profile");
156156+ return Ok(Auth::Unauthenticated);
189157 }
190190- }
158158+ };
191159 }
192160193161 trace!("No session cookie found");
+5-5
src/http/mod.rs
···33pub mod auth_utils;
44pub mod cache_countries;
55pub mod context;
66+pub mod cookies;
67pub mod errors;
78pub mod event_form;
89pub mod event_validation;
···2627pub mod handle_admin_rsvp_accepts;
2728pub mod handle_admin_rsvps;
2829pub mod handle_admin_search_index;
3030+pub mod handle_api_mcp_configuration;
2931pub mod handle_blob;
3032pub mod handle_bulk_accept_rsvps;
3133pub mod handle_content;
···4951pub mod handle_mailgun_webhook;
5052pub mod handle_manage_event;
5153pub mod handle_manage_event_content;
5252-pub mod handle_oauth_aip_callback;
5353-pub mod handle_oauth_aip_login;
5454-pub mod handle_oauth_callback;
5555-pub mod handle_oauth_login;
5656-pub mod handle_oauth_logout;
5454+pub mod handle_mcp_oauth;
5555+pub mod handle_oauth;
5756pub mod handle_policy;
5857pub mod handle_preview_description;
5958pub mod handle_profile;
···7473pub mod lfg_form;
7574pub mod location_edit_status;
7675pub mod macros;
7676+pub mod mcp_jwt;
7777pub mod middleware_auth;
7878pub mod middleware_i18n;
7979pub mod pagination;
···2828 /// JSON Web Key (JWK) for DPoP operations, typically due to invalid JSON format.
2929 #[error("error-smokesignal-oauth-model-3 Failed to deserialize DPoP JWK: {0:?}")]
3030 DpopJwkDeserializationFailed(serde_json::Error),
3131-3232- /// Error when required OAuth session data is missing.
3333- ///
3434- /// This error occurs when attempting to use an OAuth session
3535- /// that is missing critical data needed for authentication or
3636- /// authorization operations, such as tokens or session identifiers.
3737- #[error("error-smokesignal-oauth-model-4 Missing required OAuth session data")]
3838- MissingRequiredOAuthSessionData(),
3939-4040- /// Error when an OAuth session has expired.
4141- ///
4242- /// This error occurs when attempting to use an OAuth session
4343- /// that has exceeded its validity period and is no longer usable
4444- /// for authentication or authorization purposes.
4545- #[error("error-smokesignal-oauth-model-5 OAuth session has expired")]
4646- OAuthSessionExpired(),
4731}
48324933/// Represents errors that can occur during storage and database operations.
+141
src/storage/mcp_clients.rs
···11+//! MCP client storage operations.
22+//!
33+//! Stores dynamically registered MCP clients for OAuth flow.
44+55+use chrono::{DateTime, Utc};
66+use serde::{Deserialize, Serialize};
77+use sqlx::FromRow;
88+99+use super::{StoragePool, errors::StorageError};
1010+1111+/// An MCP client registered for OAuth.
1212+#[derive(Clone, Debug, FromRow, Serialize, Deserialize)]
1313+pub struct McpClient {
1414+ /// Unique internal ID
1515+ pub id: String,
1616+ /// OAuth client_id (URL format)
1717+ pub client_id: String,
1818+ /// OAuth client_secret
1919+ pub client_secret: String,
2020+ /// OAuth redirect_uri
2121+ pub redirect_uri: String,
2222+ /// Human-readable client name
2323+ pub client_name: Option<String>,
2424+ /// DID of the authenticated user (after OAuth completes)
2525+ pub did: Option<String>,
2626+ /// DPoP JWK for token binding
2727+ pub dpop_jwk: String,
2828+ /// ATProto authorization server issuer
2929+ pub issuer: Option<String>,
3030+ /// When the client was registered
3131+ pub created_at: DateTime<Utc>,
3232+ /// When the client was last updated
3333+ pub updated_at: DateTime<Utc>,
3434+}
3535+3636+/// Create a new MCP client registration.
3737+pub async fn mcp_client_create(
3838+ pool: &StoragePool,
3939+ id: &str,
4040+ client_id: &str,
4141+ client_secret: &str,
4242+ redirect_uri: &str,
4343+ client_name: Option<&str>,
4444+ dpop_jwk: &str,
4545+) -> Result<McpClient, StorageError> {
4646+ let now = Utc::now();
4747+4848+ sqlx::query_as::<_, McpClient>(
4949+ r#"
5050+ INSERT INTO mcp_clients (id, client_id, client_secret, redirect_uri, client_name, dpop_jwk, created_at, updated_at)
5151+ VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
5252+ RETURNING *
5353+ "#,
5454+ )
5555+ .bind(id)
5656+ .bind(client_id)
5757+ .bind(client_secret)
5858+ .bind(redirect_uri)
5959+ .bind(client_name)
6060+ .bind(dpop_jwk)
6161+ .bind(now)
6262+ .fetch_one(pool)
6363+ .await
6464+ .map_err(StorageError::UnableToExecuteQuery)
6565+}
6666+6767+/// Get an MCP client by its client_id.
6868+pub async fn mcp_client_get_by_client_id(
6969+ pool: &StoragePool,
7070+ client_id: &str,
7171+) -> Result<McpClient, StorageError> {
7272+ sqlx::query_as::<_, McpClient>("SELECT * FROM mcp_clients WHERE client_id = $1")
7373+ .bind(client_id)
7474+ .fetch_one(pool)
7575+ .await
7676+ .map_err(|err| match err {
7777+ sqlx::Error::RowNotFound => StorageError::RowNotFound("mcp_client".to_string(), err),
7878+ other => StorageError::UnableToExecuteQuery(other),
7979+ })
8080+}
8181+8282+/// Get an MCP client by its internal ID.
8383+pub async fn mcp_client_get_by_id(pool: &StoragePool, id: &str) -> Result<McpClient, StorageError> {
8484+ sqlx::query_as::<_, McpClient>("SELECT * FROM mcp_clients WHERE id = $1")
8585+ .bind(id)
8686+ .fetch_one(pool)
8787+ .await
8888+ .map_err(|err| match err {
8989+ sqlx::Error::RowNotFound => StorageError::RowNotFound("mcp_client".to_string(), err),
9090+ other => StorageError::UnableToExecuteQuery(other),
9191+ })
9292+}
9393+9494+/// Update an MCP client with authentication info after OAuth completes.
9595+pub async fn mcp_client_update_auth(
9696+ pool: &StoragePool,
9797+ client_id: &str,
9898+ did: &str,
9999+ issuer: &str,
100100+) -> Result<(), StorageError> {
101101+ let now = Utc::now();
102102+103103+ sqlx::query(
104104+ r#"
105105+ UPDATE mcp_clients
106106+ SET did = $1, issuer = $2, updated_at = $3
107107+ WHERE client_id = $4
108108+ "#,
109109+ )
110110+ .bind(did)
111111+ .bind(issuer)
112112+ .bind(now)
113113+ .bind(client_id)
114114+ .execute(pool)
115115+ .await
116116+ .map_err(StorageError::UnableToExecuteQuery)?;
117117+118118+ Ok(())
119119+}
120120+121121+/// Delete an MCP client by its client_id.
122122+pub async fn mcp_client_delete(pool: &StoragePool, client_id: &str) -> Result<(), StorageError> {
123123+ sqlx::query("DELETE FROM mcp_clients WHERE client_id = $1")
124124+ .bind(client_id)
125125+ .execute(pool)
126126+ .await
127127+ .map_err(StorageError::UnableToExecuteQuery)?;
128128+129129+ Ok(())
130130+}
131131+132132+/// Delete an MCP client by its internal ID.
133133+pub async fn mcp_client_delete_by_id(pool: &StoragePool, id: &str) -> Result<(), StorageError> {
134134+ sqlx::query("DELETE FROM mcp_clients WHERE id = $1")
135135+ .bind(id)
136136+ .execute(pool)
137137+ .await
138138+ .map_err(StorageError::UnableToExecuteQuery)?;
139139+140140+ Ok(())
141141+}
+68
src/storage/mcp_configuration.rs
···11+//! MCP configuration storage operations.
22+//!
33+//! Stores per-user MCP configuration settings.
44+55+use chrono::{DateTime, Utc};
66+use serde::{Deserialize, Serialize};
77+use sqlx::FromRow;
88+99+use super::{StoragePool, errors::StorageError};
1010+1111+/// MCP configuration for a user.
1212+#[derive(Clone, Debug, FromRow, Serialize, Deserialize)]
1313+pub struct McpConfiguration {
1414+ /// User's DID
1515+ pub did: String,
1616+ /// Allow dangerous operations (delete, apply_writes, etc.)
1717+ pub allow_dangerous: bool,
1818+ /// When the configuration was last updated
1919+ pub updated_at: DateTime<Utc>,
2020+}
2121+2222+/// Get MCP configuration for a user.
2323+/// Returns None if no configuration exists (default settings apply).
2424+pub async fn mcp_config_get(
2525+ pool: &StoragePool,
2626+ did: &str,
2727+) -> Result<Option<McpConfiguration>, StorageError> {
2828+ sqlx::query_as::<_, McpConfiguration>("SELECT * FROM mcp_configuration WHERE did = $1")
2929+ .bind(did)
3030+ .fetch_optional(pool)
3131+ .await
3232+ .map_err(StorageError::UnableToExecuteQuery)
3333+}
3434+3535+/// Upsert MCP configuration for a user.
3636+pub async fn mcp_config_upsert(
3737+ pool: &StoragePool,
3838+ did: &str,
3939+ allow_dangerous: bool,
4040+) -> Result<McpConfiguration, StorageError> {
4141+ let now = Utc::now();
4242+4343+ sqlx::query_as::<_, McpConfiguration>(
4444+ r#"
4545+ INSERT INTO mcp_configuration (did, allow_dangerous, updated_at)
4646+ VALUES ($1, $2, $3)
4747+ ON CONFLICT (did)
4848+ DO UPDATE SET allow_dangerous = $2, updated_at = $3
4949+ RETURNING *
5050+ "#,
5151+ )
5252+ .bind(did)
5353+ .bind(allow_dangerous)
5454+ .bind(now)
5555+ .fetch_one(pool)
5656+ .await
5757+ .map_err(StorageError::UnableToExecuteQuery)
5858+}
5959+6060+/// Check if dangerous operations are allowed for a user.
6161+/// Returns false if no configuration exists (safe default).
6262+pub async fn mcp_config_is_dangerous_allowed(
6363+ pool: &StoragePool,
6464+ did: &str,
6565+) -> Result<bool, StorageError> {
6666+ let config = mcp_config_get(pool, did).await?;
6767+ Ok(config.map(|c| c.allow_dangerous).unwrap_or(false))
6868+}
+2
src/storage/mod.rs
···99pub mod event;
1010pub mod identity_profile;
1111pub mod lfg;
1212+pub mod mcp_clients;
1313+pub mod mcp_configuration;
1214pub mod notification;
1315pub mod oauth;
1416pub mod private_event_content;
+2-163
src/storage/oauth.rs
···11-use std::borrow::Cow;
22-33-use chrono::{DateTime, Utc};
44-55-use crate::storage::{StoragePool, errors::StorageError, identity_profile::model::IdentityProfile};
66-use model::OAuthSession;
77-88-pub async fn oauth_session_update(
99- pool: &StoragePool,
1010- session_group: Cow<'_, str>,
1111- access_token: Cow<'_, str>,
1212- refresh_token: Cow<'_, str>,
1313- access_token_expires_at: DateTime<Utc>,
1414-) -> Result<(), StorageError> {
1515- // Validate input parameters
1616- if session_group.trim().is_empty() {
1717- return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol(
1818- "Session group cannot be empty".into(),
1919- )));
2020- }
2121-2222- if access_token.trim().is_empty() {
2323- return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol(
2424- "Access token cannot be empty".into(),
2525- )));
2626- }
2727-2828- if refresh_token.trim().is_empty() {
2929- return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol(
3030- "Refresh token cannot be empty".into(),
3131- )));
3232- }
3333-3434- let mut tx = pool
3535- .begin()
3636- .await
3737- .map_err(StorageError::CannotBeginDatabaseTransaction)?;
3838-3939- sqlx::query("UPDATE oauth_sessions SET access_token = $1, refresh_token = $2, access_token_expires_at = $3 WHERE session_group = $4")
4040- .bind(access_token)
4141- .bind(refresh_token)
4242- .bind(access_token_expires_at)
4343- .bind(session_group)
4444- .execute(tx.as_mut())
4545- .await
4646- .map_err(StorageError::UnableToExecuteQuery)?;
4747-4848- tx.commit()
4949- .await
5050- .map_err(StorageError::CannotCommitDatabaseTransaction)
5151-}
5252-5353-/// Delete an OAuth session by its session group.
5454-pub async fn oauth_session_delete(
5555- pool: &StoragePool,
5656- session_group: &str,
5757-) -> Result<(), StorageError> {
5858- // Validate session_group is not empty
5959- if session_group.trim().is_empty() {
6060- return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol(
6161- "Session group cannot be empty".into(),
6262- )));
6363- }
6464-6565- let mut tx = pool
6666- .begin()
6767- .await
6868- .map_err(StorageError::CannotBeginDatabaseTransaction)?;
6969-7070- sqlx::query("DELETE FROM oauth_sessions WHERE session_group = $1")
7171- .bind(session_group)
7272- .execute(tx.as_mut())
7373- .await
7474- .map_err(StorageError::UnableToExecuteQuery)?;
7575-7676- tx.commit()
7777- .await
7878- .map_err(StorageError::CannotCommitDatabaseTransaction)
7979-}
8080-8181-/// Look up a web session by session group and optionally filter by DID.
8282-pub async fn web_session_lookup(
8383- pool: &StoragePool,
8484- session_group: &str,
8585- did: Option<&str>,
8686-) -> Result<(IdentityProfile, OAuthSession), StorageError> {
8787- // Validate session_group is not empty
8888- if session_group.trim().is_empty() {
8989- return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol(
9090- "Session group cannot be empty".into(),
9191- )));
9292- }
9393-9494- // If did is provided, validate it's not empty
9595- if let Some(did_value) = did
9696- && did_value.trim().is_empty()
9797- {
9898- return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol(
9999- "DID cannot be empty".into(),
100100- )));
101101- }
102102-103103- let mut tx = pool
104104- .begin()
105105- .await
106106- .map_err(StorageError::CannotBeginDatabaseTransaction)?;
107107-108108- let oauth_session = match did {
109109- Some(did_value) => {
110110- sqlx::query_as::<_, OAuthSession>(
111111- "SELECT * FROM oauth_sessions WHERE session_group = $1 AND did = $2 ORDER BY created_at DESC LIMIT 1",
112112- )
113113- .bind(session_group)
114114- .bind(did_value)
115115- .fetch_one(tx.as_mut())
116116- .await
117117- },
118118- None => {
119119- sqlx::query_as::<_, OAuthSession>(
120120- "SELECT * FROM oauth_sessions WHERE session_group = $1 ORDER BY created_at DESC LIMIT 1",
121121- )
122122- .bind(session_group)
123123- .fetch_one(tx.as_mut())
124124- .await
125125- }
126126- }
127127- .map_err(|err| match err {
128128- sqlx::Error::RowNotFound => StorageError::WebSessionNotFound,
129129- other => StorageError::UnableToExecuteQuery(other),
130130- })?;
131131-132132- let did_for_handle = did.unwrap_or(&oauth_session.did);
133133-134134- let handle =
135135- sqlx::query_as::<_, IdentityProfile>("SELECT * FROM identity_profiles WHERE did = $1")
136136- .bind(did_for_handle)
137137- .fetch_one(tx.as_mut())
138138- .await
139139- .map_err(|err| match err {
140140- sqlx::Error::RowNotFound => StorageError::HandleNotFound,
141141- other => StorageError::UnableToExecuteQuery(other),
142142- })?;
143143-144144- tx.commit()
145145- .await
146146- .map_err(StorageError::CannotCommitDatabaseTransaction)?;
147147-148148- Ok((handle, oauth_session))
149149-}
150150-1511pub mod model {
1522 use chrono::{DateTime, Utc};
1533 use serde::Deserialize;
1544 use sqlx::FromRow;
155566+ /// OAuth request state stored during the OAuth flow.
77+ /// Used for PKCE verification and nonce validation.
1568 #[derive(Clone, FromRow, Deserialize)]
1579 pub struct OAuthRequest {
15810 pub oauth_state: String,
···16517 pub dpop_jwk: String,
16618 pub created_at: DateTime<Utc>,
16719 pub expires_at: DateTime<Utc>,
168168- }
169169-170170- #[derive(Clone, FromRow, Deserialize)]
171171- pub struct OAuthSession {
172172- pub session_group: String,
173173- pub access_token: String,
174174- pub did: String,
175175- pub issuer: String,
176176- pub refresh_token: String,
177177- pub secret_jwk_id: String,
178178- pub dpop_jwk: String,
179179- pub created_at: DateTime<Utc>,
180180- pub access_token_expires_at: DateTime<Utc>,
18120 }
18221}
···11+import{l as c,c as m,t as f,b as I,h as z,u as h,d as L}from"./main-B2-Bk5P8.js";const y=9;async function b(){const e=document.getElementById("event-map");if(e&&!(e.dataset.mapInitialized==="true"||e.dataset.mapInitializing==="true")){e.dataset.mapInitializing="true";try{const n=e.dataset.geoLocations;if(!n){e.dataset.mapInitializing="false";return}let s;try{s=JSON.parse(n)}catch(t){console.error("Failed to parse geo locations:",t),e.dataset.mapInitializing="false";return}const a=s.filter(t=>t!=null).map(t=>({latitude:typeof t.latitude=="string"?parseFloat(t.latitude):t.latitude,longitude:typeof t.longitude=="string"?parseFloat(t.longitude):t.longitude,name:t.name})).filter(t=>Number.isFinite(t.latitude)&&Number.isFinite(t.longitude));if(a.length===0){e.dataset.mapInitializing="false";return}const{maplibregl:p,h3:r}=await c();if(e.dataset.mapInitialized==="true"){e.dataset.mapInitializing="false";return}const l=a.reduce((t,i)=>t+i.latitude,0)/a.length,d=a.reduce((t,i)=>t+i.longitude,0)/a.length;if(!Number.isFinite(l)||!Number.isFinite(d)){e.dataset.mapInitializing="false";return}const o=m(p,{container:"event-map",center:f(l,d),zoom:16,scrollZoom:!1});o.on("load",()=>{I(o);const t=a.map(u=>{const g=r.latLngToCell(u.latitude,u.longitude,y);return z(r,g,{fillColor:"#3273dc",fillOpacity:.2,strokeColor:"#3273dc",strokeWidth:2})});h(o,t);const i=L(t);i&&o.fitBounds(i,{padding:20})}),e.dataset.mapInitialized="true"}catch(n){console.error("Failed to initialize event map:",n)}finally{e.dataset.mapInitializing="false"}}}export{b as initEventMap};
···11+import{l as B,c as C,t as b,b as z,h as I,u as F}from"./main-B2-Bk5P8.js";async function T(){const o=document.getElementById("lfg-heatmap");if(o&&!(o.dataset.mapInitialized==="true"||o.dataset.mapInitializing==="true")){o.dataset.mapInitializing="true";try{const f=parseFloat(o.dataset.lat??"40.7128"),v=parseFloat(o.dataset.lon??"-74.006"),m=o.dataset.eventBuckets,g=o.dataset.profileBuckets,{maplibregl:h,h3:L}=await B(),a=C(h,{container:"lfg-heatmap",center:b(f,v),zoom:12});let k=[],y=[];try{m&&(k=JSON.parse(m)),g&&(y=JSON.parse(g))}catch(e){console.warn("Failed to parse heatmap buckets:",e)}const c=new Map;for(const e of k){const t=c.get(e.key)||{events:0,people:0};t.events=e.count,c.set(e.key,t)}for(const e of y){const t=c.get(e.key)||{events:0,people:0};t.people=e.count,c.set(e.key,t)}let u=0;c.forEach(e=>{const t=e.events+e.people;t>u&&(u=t)}),a.on("load",()=>{z(a);const e=[];c.forEach((n,s)=>{try{const r=n.events+n.people,p=.2+Math.min(r/Math.max(u,1),1)*.5,i=[];n.events>0&&i.push(`${n.events} event${n.events!==1?"s":""}`),n.people>0&&i.push(`${n.people} ${n.people!==1?"people":"person"}`);const l=i.join(", "),x=L.cellToLatLng(s);e.push(I(L,s,{fillColor:"#3273dc",fillOpacity:p,strokeColor:"#3273dc",strokeWidth:1,tooltipText:l,centerLat:x[0],centerLng:x[1]}))}catch{console.warn("Invalid H3 cell:",s)}}),F(a,e);let t=null;a.on("mouseenter","hexagons-fill",n=>{var l;const s=(l=n.features)==null?void 0:l[0];if(!(s!=null&&s.properties))return;const r=s.properties,d=r.tooltipText,p=r.centerLat,i=r.centerLng;d&&p!==void 0&&i!==void 0&&(a.getCanvas().style.cursor="pointer",t=new h.Popup({closeButton:!1,closeOnClick:!1}).setLngLat([i,p]).setText(d).addTo(a))}),a.on("mouseleave","hexagons-fill",()=>{a.getCanvas().style.cursor="",t&&(t.remove(),t=null)})}),o.dataset.mapInitialized="true"}catch(f){console.error("Failed to initialize LFG heatmap:",f)}finally{o.dataset.mapInitializing="false"}}}export{T as initLfgHeatmap};
···11-const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["js/event-map-BMN8EESL.js","js/main-CuQd5Sql.js","css/main-DsS_JPFh.css","js/globe-map-v0wYAEFW.js","js/location-heatmap-qsLOB3hq.js"])))=>i.map(i=>d[i]);
22-import{_ as i}from"./main-CuQd5Sql.js";async function o(){if(!document.getElementById("event-map"))return;const{initEventMap:t}=await i(async()=>{const{initEventMap:n}=await import("./event-map-BMN8EESL.js");return{initEventMap:n}},__vite__mapDeps([0,1,2]));await t()}async function e(){if(!document.getElementById("globe-map"))return;const{initGlobeMap:t}=await i(async()=>{const{initGlobeMap:n}=await import("./globe-map-v0wYAEFW.js");return{initGlobeMap:n}},__vite__mapDeps([3,1,2]));await t()}async function c(){if(!document.getElementById("location-heatmap"))return;const{initLocationHeatmap:t}=await i(async()=>{const{initLocationHeatmap:n}=await import("./location-heatmap-qsLOB3hq.js");return{initLocationHeatmap:n}},__vite__mapDeps([4,1,2]));await t()}function p(){o(),e(),c()}export{o as initEventMap,e as initGlobeMap,c as initLocationHeatmap,p as initMaps};
11+const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["js/event-map-BQ9ES8zV.js","js/main-B2-Bk5P8.js","css/main-DsS_JPFh.css","js/globe-map-C_o1blDz.js","js/location-heatmap-XJvZT2hb.js"])))=>i.map(i=>d[i]);
22+import{_ as i}from"./main-B2-Bk5P8.js";async function o(){if(!document.getElementById("event-map"))return;const{initEventMap:t}=await i(async()=>{const{initEventMap:n}=await import("./event-map-BQ9ES8zV.js");return{initEventMap:n}},__vite__mapDeps([0,1,2]));await t()}async function e(){if(!document.getElementById("globe-map"))return;const{initGlobeMap:t}=await i(async()=>{const{initGlobeMap:n}=await import("./globe-map-C_o1blDz.js");return{initGlobeMap:n}},__vite__mapDeps([3,1,2]));await t()}async function c(){if(!document.getElementById("location-heatmap"))return;const{initLocationHeatmap:t}=await i(async()=>{const{initLocationHeatmap:n}=await import("./location-heatmap-XJvZT2hb.js");return{initLocationHeatmap:n}},__vite__mapDeps([4,1,2]));await t()}function p(){o(),e(),c()}export{o as initEventMap,e as initGlobeMap,c as initLocationHeatmap,p as initMaps};
···11const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["js/cropper-DyNc_82c.js","css/cropper-BJgXXBRK.css"])))=>i.map(i=>d[i]);
22-import{_ as a}from"./main-CuQd5Sql.js";let t=null;async function o(){return t||(t=(async()=>{const[n]=await Promise.all([a(()=>import("./cropper-DyNc_82c.js").then(r=>r.c),__vite__mapDeps([0,1])),a(()=>import("./cropper-DyNc_82c.js").then(r=>r.a),__vite__mapDeps([0,1]))]),e=n.default;return window.Cropper=e,e})(),t)}async function s(){const n=document.getElementById("headerCanvas"),e=document.getElementById("thumbnailCanvas");!n&&!e||await o()}export{s as initCropper,o as loadCropper};
22+import{_ as a}from"./main-B2-Bk5P8.js";let t=null;async function o(){return t||(t=(async()=>{const[n]=await Promise.all([a(()=>import("./cropper-DyNc_82c.js").then(r=>r.c),__vite__mapDeps([0,1])),a(()=>import("./cropper-DyNc_82c.js").then(r=>r.a),__vite__mapDeps([0,1]))]),e=n.default;return window.Cropper=e,e})(),t)}async function s(){const n=document.getElementById("headerCanvas"),e=document.getElementById("thumbnailCanvas");!n&&!e||await o()}export{s as initCropper,o as loadCropper};
+1
static/js/location-heatmap-XJvZT2hb.js
···11+import{l as x,c as z,t as I,b as L,e as k,h as B,u as F,d as M}from"./main-B2-Bk5P8.js";async function w(){const t=document.getElementById("location-heatmap");if(t&&!(t.dataset.mapInitialized==="true"||t.dataset.mapInitializing==="true")){t.dataset.mapInitializing="true";try{const s=parseFloat(t.dataset.centerLat??"0"),p=parseFloat(t.dataset.centerLon??"0"),m=t.dataset.centerCell??"",i=t.dataset.geoBuckets;if(!i)return;let a;try{a=JSON.parse(i)}catch(n){console.error("Failed to parse geo buckets:",n);return}const{maplibregl:h,h3:f}=await x(),o=z(h,{container:"location-heatmap",center:I(s,p),zoom:9,interactive:!1});o.on("load",()=>{if(L(o),a&&a.length>0){const n=a.map(e=>e.doc_count??0),g=Math.min(...n),y=Math.max(...n),c=[];a.forEach(e=>{try{const r=e.key,C=e.doc_count??0,d=r===m,u=k(C,g,y);c.push(B(f,r,{fillColor:u,fillOpacity:.5,strokeColor:d?"#1a1a1a":u,strokeWidth:d?3:2}))}catch(r){console.warn("Failed to draw hex:",e.key,r)}}),F(o,c);const l=M(c);l&&o.fitBounds(l,{padding:10})}}),t.dataset.mapInitialized="true"}catch(s){console.error("Failed to initialize location heatmap:",s)}finally{t.dataset.mapInitializing="false"}}}export{w as initLocationHeatmap};