use time::{Duration, OffsetDateTime, UtcOffset};
use zeroize::Zeroizing;
use cratefield_core::{
Clock, Database, HttpClient, IdGen, ModuleConfig, ModuleContext, SystemClock,
};
use cratefield_oauth_client::{
OAuthClient, Pkce, ProviderConfig, RandomError, SealError, authorize_url, random_state,
};
use crate::handlers::{self, Settings};
use crate::seal;
use crate::store::{self, NewConnection, NewState, StateRow};
use crate::{AccessToken, AuthorizeUrl, Connection, ConnectionError, Provider, ProviderFailure};
pub(crate) const STATE_TTL_SECS: i64 = 600;
pub(crate) const DEFAULT_REFRESH_LEAD_SECS: i64 = 60;
pub(crate) fn now_of(ctx: &ModuleContext) -> OffsetDateTime {
ctx.ports
.clock
.as_ref()
.map_or_else(|| SystemClock.now(), |clock| clock.now())
}
pub(crate) fn stamp(at: OffsetDateTime) -> String {
at.to_offset(UtcOffset::UTC)
.replace_nanosecond(0)
.ok()
.and_then(|at| {
at.format(&time::format_description::well_known::Rfc3339)
.ok()
})
.unwrap_or_default()
}
pub(crate) fn parse_stamp(raw: &str) -> Option<OffsetDateTime> {
OffsetDateTime::parse(raw, &time::format_description::well_known::Rfc3339).ok()
}
fn iso_in(at: OffsetDateTime, seconds: i64) -> String {
stamp(at.saturating_add(Duration::seconds(seconds)))
}
fn new_id(ctx: &ModuleContext) -> String {
ctx.ports
.id_gen
.as_ref()
.map_or_else(|| cratefield_core::UlidIdGen.ulid(), |id_gen| id_gen.ulid())
}
fn db(ctx: &ModuleContext) -> Result<&dyn Database, ConnectionError> {
ctx.ports
.db
.as_deref()
.ok_or_else(|| ConnectionError::Config("the Database port is not mounted".to_owned()))
}
fn http(ctx: &ModuleContext) -> Result<&dyn HttpClient, ConnectionError> {
ctx.ports
.http
.as_deref()
.ok_or_else(|| ConnectionError::Config("the HttpClient port is not mounted".to_owned()))
}
pub(crate) fn env_key(key: &str) -> String {
key.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_uppercase()
} else {
'_'
}
})
.collect()
}
fn seal_key(ctx: &ModuleContext) -> Result<seal::SealKey, ConnectionError> {
let cfg = ModuleConfig::new("connections", &*ctx.config);
let encoded = cfg
.get_opt("TOKEN_KEY")
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
ConnectionError::Config("CONNECTIONS_TOKEN_KEY is not configured".to_owned())
})?;
let id = cfg
.get_opt("TOKEN_KEY_ID")
.and_then(|value| value.trim().parse().ok())
.unwrap_or(1);
seal::SealKey::from_config(&encoded, id).map_err(ConnectionError::Config)
}
fn redirect_uri(ctx: &ModuleContext, key: &str) -> String {
let cfg = ModuleConfig::new("connections", &*ctx.config);
let base = cfg
.get_opt("API_BASE")
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| format!("https://api.{}", ctx.venture.domain));
format!(
"{}/v1/connections/callback/{}",
base.trim_end_matches('/'),
key
)
}
pub(crate) fn provider_config(
ctx: &ModuleContext,
provider: &Provider,
) -> Result<ProviderConfig, ConnectionError> {
let cfg = ModuleConfig::new("connections", &*ctx.config);
let prefix = env_key(&provider.key);
let client_id = cfg
.get_opt(&format!("{prefix}_CLIENT_ID"))
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
ConnectionError::Config(format!("CONNECTIONS_{prefix}_CLIENT_ID is not configured"))
})?;
let client_secret = cfg
.get_opt(&format!("{prefix}_CLIENT_SECRET"))
.filter(|value| !value.trim().is_empty());
Ok(ProviderConfig {
authorize_url: provider.authorize_url.clone(),
token_url: provider.token_url.clone(),
revoke_url: provider.revoke_url.clone(),
client_id,
client_secret,
scopes: provider.scopes.clone(),
scope_separator: provider.scope_separator,
client_auth: provider.client_auth,
})
}
fn authorize_url_for(
config: &ProviderConfig,
provider: &Provider,
redirect_uri: &str,
state: &str,
pkce: Option<&Pkce>,
) -> String {
let mut url = authorize_url(config, redirect_uri, state, pkce);
for (name, value) in &provider.authorize_params {
url = crate::append_query(&url, name, value);
}
url
}
pub(crate) async fn start(
ctx: &ModuleContext,
settings: &Settings,
subject: &str,
provider_key: &str,
return_to: &str,
) -> Result<AuthorizeUrl, ConnectionError> {
let provider = settings
.provider(provider_key)
.ok_or_else(|| ConnectionError::UnknownProvider(provider_key.to_owned()))?;
if !settings.allows(return_to) {
return Err(ConnectionError::OriginNotAllowed(return_to.to_owned()));
}
let db = db(ctx)?;
let key = seal_key(ctx)?;
let state = random_state().map_err(random_error)?;
let state_hash = crate::sha256_hex(&state);
let pkce = if provider.pkce {
Some(Pkce::new().map_err(random_error)?)
} else {
None
};
let verifier_sealed = pkce
.as_ref()
.map(|pkce| {
seal::seal(
&key,
&seal::context(store::STATES, &state_hash, "verifier_sealed"),
pkce.verifier(),
)
.map_err(|error| seal_error(&error))
})
.transpose()?;
let now = now_of(ctx);
let expires_at = iso_in(now, STATE_TTL_SECS);
store::insert_state(
db,
&NewState {
state_hash: &state_hash,
subject,
provider: provider_key,
return_to,
verifier_sealed: verifier_sealed.as_deref(),
now: &stamp(now),
expires_at: &expires_at,
},
)
.await?;
let config = provider_config(ctx, provider)?;
let url = authorize_url_for(
&config,
provider,
&redirect_uri(ctx, provider_key),
&state,
pkce.as_ref(),
);
Ok(AuthorizeUrl {
url,
expires_at,
provider: provider_key.to_owned(),
})
}
pub(crate) async fn spend_state(
ctx: &ModuleContext,
state: &str,
) -> Result<StateRow, ConnectionError> {
let db = db(ctx)?;
let state_hash = crate::sha256_hex(state);
let now = stamp(now_of(ctx));
let row = store::find_state(db, &state_hash)
.await?
.ok_or(ConnectionError::BadState)?;
if store::spend_state(db, &state_hash, &now).await? != 1 {
return Err(ConnectionError::BadState);
}
Ok(row)
}
pub(crate) async fn exchange(
ctx: &ModuleContext,
settings: &Settings,
row: &StateRow,
code: &str,
) -> Result<Connection, ConnectionError> {
let provider = settings
.provider(&row.provider)
.ok_or_else(|| ConnectionError::UnknownProvider(row.provider.clone()))?;
let db = db(ctx)?;
let key = seal_key(ctx)?;
let verifier = row
.verifier_sealed
.as_deref()
.map(|sealed| {
seal::open(
&key,
&seal::context(store::STATES, &row.state_hash, "verifier_sealed"),
sealed,
)
.map_err(|error| seal_error(&error))
})
.transpose()?;
let config = provider_config(ctx, provider)?;
let client = OAuthClient::new(http(ctx)?, &config);
let tokens = client
.exchange_code(
code,
&redirect_uri(ctx, &row.provider),
verifier.as_deref().map(String::as_str),
)
.await
.map_err(|error| ConnectionError::Provider(ProviderFailure::of(&error)))?;
let id = new_id(ctx);
let now = now_of(ctx);
let access_sealed = seal::seal(
&key,
&seal::context(store::CONNECTIONS, &id, "access_token_sealed"),
&tokens.access_token,
)
.map_err(|error| seal_error(&error))?;
let refresh_sealed = tokens
.refresh_token
.as_deref()
.map(|token| {
seal::seal(
&key,
&seal::context(store::CONNECTIONS, &id, "refresh_token_sealed"),
token,
)
.map_err(|error| seal_error(&error))
})
.transpose()?;
let access_expires = (tokens.expires_in > 0).then(|| iso_in(now, tokens.expires_in));
let refresh_expires = tokens
.refresh_token_expires_in
.filter(|seconds| *seconds > 0)
.map(|seconds| iso_in(now, seconds));
store::insert_connection(
db,
&NewConnection {
id: &id,
subject: &row.subject,
provider: &row.provider,
scopes: &tokens.scope,
access_token_sealed: Some(&access_sealed),
refresh_token_sealed: refresh_sealed.as_deref(),
access_expires_at: access_expires.as_deref(),
refresh_expires_at: refresh_expires.as_deref(),
now: &stamp(now),
},
)
.await?;
let connection = store::load_connection(db, &id)
.await?
.ok_or(ConnectionError::BadState)?
.into();
handlers::emit(
ctx,
handlers::EVENT_CONNECTED,
serde_json::json!({
"connection_id": id,
"subject": row.subject,
"provider": row.provider,
}),
);
Ok(connection)
}
pub(crate) async fn complete(
ctx: &ModuleContext,
settings: &Settings,
state: &str,
code: &str,
) -> Result<Connection, ConnectionError> {
let row = spend_state(ctx, state).await?;
exchange(ctx, settings, &row, code).await
}
pub(crate) async fn list(
ctx: &ModuleContext,
subject: &str,
) -> Result<Vec<Connection>, ConnectionError> {
Ok(store::list_connections(db(ctx)?, subject)
.await?
.into_iter()
.map(Connection::from)
.collect())
}
pub(crate) async fn get(ctx: &ModuleContext, id: &str) -> Result<Connection, ConnectionError> {
store::load_connection(db(ctx)?, id)
.await?
.map(Connection::from)
.ok_or_else(|| ConnectionError::NotFound(id.to_owned()))
}
pub(crate) async fn access_token(
ctx: &ModuleContext,
settings: &Settings,
id: &str,
) -> Result<AccessToken, ConnectionError> {
let db = db(ctx)?;
let row = store::load_connection(db, id)
.await?
.ok_or_else(|| ConnectionError::NotFound(id.to_owned()))?;
match crate::ConnectionStatus::parse(&row.status) {
crate::ConnectionStatus::Revoked => return Err(ConnectionError::Revoked),
crate::ConnectionStatus::NeedsReconnect => {
return Err(ConnectionError::NeedsReconnect(
row.last_error
.clone()
.unwrap_or_else(|| "the provider requires a new authorization".to_owned()),
));
}
crate::ConnectionStatus::Active => {}
}
if is_due(&row, settings, now_of(ctx)) || row.access_token_sealed.is_none() {
return refresh(ctx, settings, &row).await;
}
let key = seal_key(ctx)?;
let sealed = row
.access_token_sealed
.as_deref()
.ok_or_else(|| ConnectionError::Config("the connection has no access token".to_owned()))?;
let token = seal::open(
&key,
&seal::context(store::CONNECTIONS, &row.id, "access_token_sealed"),
sealed,
)
.map_err(|error| seal_error(&error))?;
Ok(AccessToken::new(row.id, token, row.access_expires_at))
}
fn is_due(row: &store::ConnectionRow, settings: &Settings, now: OffsetDateTime) -> bool {
row.access_expires_at
.as_deref()
.and_then(parse_stamp)
.is_some_and(|expiry| expiry - now <= Duration::seconds(settings.refresh_lead_secs))
}
async fn refresh(
ctx: &ModuleContext,
settings: &Settings,
row: &store::ConnectionRow,
) -> Result<AccessToken, ConnectionError> {
let db = db(ctx)?;
let key = seal_key(ctx)?;
let Some(previous) = row.refresh_token_sealed.clone() else {
let reason = "the provider issued no refresh token, so the connection cannot be renewed";
mark_needs_reconnect_row(ctx, row, reason).await?;
return Err(ConnectionError::NeedsReconnect(reason.to_owned()));
};
let refresh_token = seal::open(
&key,
&seal::context(store::CONNECTIONS, &row.id, "refresh_token_sealed"),
&previous,
)
.map_err(|error| seal_error(&error))?;
let provider = settings
.provider(&row.provider)
.ok_or_else(|| ConnectionError::UnknownProvider(row.provider.clone()))?;
let config = provider_config(ctx, provider)?;
let client = OAuthClient::new(http(ctx)?, &config);
let tokens = match client.refresh(&refresh_token).await {
Ok(tokens) => tokens,
Err(error) if error.is_invalid_grant() => {
let reason = "the provider rejected the refresh token";
mark_needs_reconnect_row(ctx, row, reason).await?;
return Err(ConnectionError::NeedsReconnect(reason.to_owned()));
}
Err(error) => return Err(ConnectionError::Provider(ProviderFailure::of(&error))),
};
let now = now_of(ctx);
let access_sealed = seal::seal(
&key,
&seal::context(store::CONNECTIONS, &row.id, "access_token_sealed"),
&tokens.access_token,
)
.map_err(|error| seal_error(&error))?;
let next_refresh = tokens.rotated_refresh_token(&refresh_token);
let refresh_sealed = seal::seal(
&key,
&seal::context(store::CONNECTIONS, &row.id, "refresh_token_sealed"),
next_refresh,
)
.map_err(|error| seal_error(&error))?;
let access_expires = (tokens.expires_in > 0)
.then(|| iso_in(now, tokens.expires_in))
.or_else(|| row.access_expires_at.clone());
let refresh_expires = tokens
.refresh_token_expires_in
.filter(|seconds| *seconds > 0)
.map(|seconds| iso_in(now, seconds))
.or_else(|| row.refresh_expires_at.clone());
let written = store::store_refreshed(
db,
&row.id,
&previous,
&access_sealed,
&refresh_sealed,
access_expires.as_deref(),
refresh_expires.as_deref(),
&stamp(now),
)
.await?;
if written == 0 {
return adopt_winner(&key, db, &row.id).await;
}
handlers::emit(
ctx,
handlers::EVENT_REFRESHED,
serde_json::json!({
"connection_id": row.id,
"subject": row.subject,
"provider": row.provider,
}),
);
Ok(AccessToken::new(
row.id.clone(),
Zeroizing::new(tokens.access_token),
access_expires,
))
}
async fn adopt_winner(
key: &seal::SealKey,
db: &dyn Database,
id: &str,
) -> Result<AccessToken, ConnectionError> {
let winner = store::load_connection(db, id)
.await?
.ok_or_else(|| ConnectionError::NotFound(id.to_owned()))?;
match crate::ConnectionStatus::parse(&winner.status) {
crate::ConnectionStatus::Revoked => return Err(ConnectionError::Revoked),
crate::ConnectionStatus::NeedsReconnect => {
return Err(ConnectionError::NeedsReconnect(
winner.last_error.unwrap_or_default(),
));
}
crate::ConnectionStatus::Active => {}
}
let sealed = winner
.access_token_sealed
.as_deref()
.ok_or_else(|| ConnectionError::Config("the connection has no access token".to_owned()))?;
let token = seal::open(
key,
&seal::context(store::CONNECTIONS, &winner.id, "access_token_sealed"),
sealed,
)
.map_err(|error| seal_error(&error))?;
Ok(AccessToken::new(winner.id, token, winner.access_expires_at))
}
pub(crate) async fn revoke(
ctx: &ModuleContext,
settings: &Settings,
id: &str,
) -> Result<(), ConnectionError> {
let db = db(ctx)?;
let row = store::load_connection(db, id)
.await?
.ok_or_else(|| ConnectionError::NotFound(id.to_owned()))?;
if crate::ConnectionStatus::parse(&row.status) == crate::ConnectionStatus::Revoked {
return Ok(());
}
if let (Some(provider), Ok(key)) = (settings.provider(&row.provider), seal_key(ctx))
&& let Ok(config) = provider_config(ctx, provider)
&& let Ok(http) = http(ctx)
{
let client = OAuthClient::new(http, &config);
for (sealed, hint) in [
(row.access_token_sealed.as_deref(), "access_token"),
(row.refresh_token_sealed.as_deref(), "refresh_token"),
] {
let Some(sealed) = sealed else { continue };
let column = format!("{hint}_sealed");
let Ok(token) = seal::open(
&key,
&seal::context(store::CONNECTIONS, &row.id, &column),
sealed,
) else {
continue;
};
if let Err(error) = client.revoke(&token, hint).await {
let failure = ProviderFailure::of(&error);
tracing::warn!(
provider = %row.provider,
slug = handlers::UPSTREAM.slug,
status = ?failure.status,
code = %failure.code,
"the provider did not confirm the revocation"
);
}
}
}
store::set_revoked(db, &row.id, &stamp(now_of(ctx))).await?;
handlers::emit(
ctx,
handlers::EVENT_REVOKED,
serde_json::json!({
"connection_id": row.id,
"subject": row.subject,
"provider": row.provider,
}),
);
Ok(())
}
pub(crate) async fn mark_needs_reconnect(
ctx: &ModuleContext,
id: &str,
reason: &str,
) -> Result<(), ConnectionError> {
let db = db(ctx)?;
let row = store::load_connection(db, id)
.await?
.ok_or_else(|| ConnectionError::NotFound(id.to_owned()))?;
mark_needs_reconnect_row(ctx, &row, reason).await
}
async fn mark_needs_reconnect_row(
ctx: &ModuleContext,
row: &store::ConnectionRow,
reason: &str,
) -> Result<(), ConnectionError> {
store::set_status(
db(ctx)?,
&row.id,
crate::ConnectionStatus::NeedsReconnect.as_str(),
Some(reason),
&stamp(now_of(ctx)),
)
.await?;
handlers::emit(
ctx,
handlers::EVENT_NEEDS_RECONNECT,
serde_json::json!({
"connection_id": row.id,
"subject": row.subject,
"provider": row.provider,
}),
);
Ok(())
}
pub(crate) async fn set_account(
ctx: &ModuleContext,
id: &str,
external_account_id: Option<&str>,
display_name: Option<&str>,
) -> Result<(), ConnectionError> {
let db = db(ctx)?;
let row = store::load_connection(db, id)
.await?
.ok_or_else(|| ConnectionError::NotFound(id.to_owned()))?;
store::set_account(
db,
&row.id,
external_account_id,
display_name,
&stamp(now_of(ctx)),
)
.await?;
Ok(())
}
pub(crate) async fn maintain(
ctx: &ModuleContext,
settings: &Settings,
) -> Result<(), ConnectionError> {
let db = db(ctx)?;
let now = now_of(ctx);
if ctx.scheduled.try_spend(1) {
let purged = store::purge_states(db, &stamp(now)).await?;
if purged > 0 {
tracing::info!(purged, "purged spent and expired connection states");
}
}
let not_after = iso_in(now, settings.refresh_lead_secs);
let due = store::due_connections(db, ¬_after).await?;
for row in due {
if !ctx.scheduled.try_spend(1) {
break;
}
if let Err(error) = refresh(ctx, settings, &row).await {
let failure = error.provider_failure();
tracing::warn!(
connection_id = %row.id,
provider = %row.provider,
slug = error.slug(),
status = ?failure.and_then(|failure| failure.status),
code = failure.map_or("-", |failure| failure.code.as_str()),
"a scheduled refresh did not complete"
);
}
}
Ok(())
}
fn seal_error(error: &SealError) -> ConnectionError {
ConnectionError::Config(format!("a sealed value could not be read: {error}"))
}
fn random_error(error: RandomError) -> ConnectionError {
ConnectionError::Config(format!("no randomness is available: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_stamp_is_always_utc() {
let epoch = OffsetDateTime::from_unix_timestamp(0).expect("the epoch is in range");
let shifted = epoch.to_offset(UtcOffset::from_hms(5, 30, 0).expect("a valid offset"));
assert_eq!(stamp(epoch), "1970-01-01T00:00:00Z");
assert_eq!(
stamp(shifted),
stamp(epoch),
"the clock's own offset must not reach the stored value"
);
}
#[test]
fn an_extreme_offset_saturates_instead_of_panicking() {
let now =
OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("the epoch is in range");
let far = iso_in(now, i64::MAX);
assert_eq!(
parse_stamp(&far).map(OffsetDateTime::year),
Some(9999),
"a saturated expiry must still be a timestamp this module can read back"
);
assert!(
far.ends_with('Z'),
"a saturated expiry is still UTC: {far:?}"
);
}
}