use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use serde::Deserialize;
use cratefield_core::{
Config, ConfigError, ModuleConfig, ModuleContext, Problem, ProblemDef, Scope,
};
use crate::service;
use crate::{ConnectionError, Provider};
pub(crate) const EVENT_CONNECTED: &str = "connections.connected";
pub(crate) const EVENT_REFRESHED: &str = "connections.refreshed";
pub(crate) const EVENT_REVOKED: &str = "connections.revoked";
pub(crate) const EVENT_NEEDS_RECONNECT: &str = "connections.needs_reconnect";
pub(crate) const UNKNOWN_PROVIDER: ProblemDef = ProblemDef {
slug: "connections-unknown-provider",
status: StatusCode::NOT_FOUND,
title: "Unknown provider",
description: "The callback names a provider key the venture has not configured, so there \
is no client to exchange the code with.",
};
pub(crate) const BAD_STATE: ProblemDef = ProblemDef {
slug: "connections-bad-state",
status: StatusCode::BAD_REQUEST,
title: "That connection link is no longer valid",
description: "The connect state was never issued, has already been used, or has expired. \
Start the connection again.",
};
pub(crate) const OAUTH_DENIED: ProblemDef = ProblemDef {
slug: "connections-oauth-denied",
status: StatusCode::BAD_REQUEST,
title: "The provider did not grant access",
description: "The provider sent the browser back with an error instead of a code, so no \
connection was stored.",
};
pub(crate) const RECONNECT_REQUIRED: ProblemDef = ProblemDef {
slug: "connections-reconnect-required",
status: StatusCode::CONFLICT,
title: "This connection needs to be reconnected",
description: "The provider rejected the stored refresh token, so the person must authorize \
the connection again before an access token can be issued.",
};
pub(crate) const NOT_FOUND: ProblemDef = ProblemDef {
slug: "connections-not-found",
status: StatusCode::NOT_FOUND,
title: "No such connection",
description: "No connection the venture holds has that id.",
};
pub(crate) const REVOKED: ProblemDef = ProblemDef {
slug: "connections-revoked",
status: StatusCode::CONFLICT,
title: "This connection was revoked",
description: "The connection was revoked and its tokens were cleared, so there is nothing \
left to use or refresh.",
};
pub(crate) const UPSTREAM: ProblemDef = ProblemDef {
slug: "connections-upstream",
status: StatusCode::BAD_GATEWAY,
title: "The provider could not complete the connection",
description: "Talking to the provider's token endpoint failed, or it answered with a body \
that is not a token response.",
};
#[derive(Clone, Default)]
pub(crate) struct Settings {
pub providers: Vec<Provider>,
pub allowed_origins: Vec<String>,
pub refresh_lead_secs: i64,
}
impl Settings {
pub(crate) fn provider(&self, key: &str) -> Option<&Provider> {
self.providers.iter().find(|provider| provider.key == key)
}
pub(crate) fn allows(&self, return_to: &str) -> bool {
crate::origin_of(return_to).is_some_and(|origin| self.allowed_origins.contains(&origin))
}
}
pub(crate) fn problem_for(error: &ConnectionError) -> Problem {
let def = match error {
ConnectionError::UnknownProvider(_) => &UNKNOWN_PROVIDER,
ConnectionError::OriginNotAllowed(_) | ConnectionError::BadState => &BAD_STATE,
ConnectionError::NotFound(_) => &NOT_FOUND,
ConnectionError::NeedsReconnect(_) => &RECONNECT_REQUIRED,
ConnectionError::Revoked => &REVOKED,
ConnectionError::Provider(_) | ConnectionError::Config(_) => &UPSTREAM,
ConnectionError::Db(_) => return Problem::internal(),
};
match error {
ConnectionError::UnknownProvider(_)
| ConnectionError::OriginNotAllowed(_)
| ConnectionError::NotFound(_)
| ConnectionError::NeedsReconnect(_) => Problem::new(def).with_detail(error.to_string()),
_ => Problem::new(def),
}
}
pub(crate) fn event_scope(ctx: &ModuleContext) -> Option<Scope> {
let defer = ctx.ports.defer.clone()?;
let request_id = ctx
.ports
.id_gen
.as_ref()
.map(|id_gen| id_gen.ulid())
.unwrap_or_default();
Some(Scope {
request_id,
defer,
span: tracing::info_span!("connections.event"),
})
}
pub(crate) fn emit(ctx: &ModuleContext, name: &str, payload: serde_json::Value) {
if let Some(scope) = event_scope(ctx) {
ctx.events.emit_in(&scope, name, payload);
} else {
tracing::debug!(event = name, "no Defer port is mounted; event not emitted");
}
}
pub(crate) struct ModuleState {
pub ctx: Arc<ModuleContext>,
pub settings: Settings,
}
pub(crate) fn router(ctx: Arc<ModuleContext>, settings: Settings) -> axum::Router {
let state = Arc::new(ModuleState { ctx, settings });
axum::Router::new()
.route("/callback/{provider}", get(callback))
.with_state(state)
}
#[derive(Debug, Deserialize)]
pub(crate) struct CallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
async fn callback(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Path(provider): Path<String>,
Query(query): Query<CallbackQuery>,
) -> Response {
if state.settings.provider(&provider).is_none() {
return problem(
&UNKNOWN_PROVIDER,
&scope,
format!("`{provider}` is not a configured provider"),
);
}
if let Some(error) = query.error.as_deref() {
tracing::warn!(
provider = %provider,
error = crate::safe_token(error),
"the provider refused the authorization"
);
return problem(
&OAUTH_DENIED,
&scope,
"the provider sent the browser back with an error".to_owned(),
);
}
let (Some(code), Some(raw_state)) = (query.code.as_deref(), query.state.as_deref()) else {
return problem(
&BAD_STATE,
&scope,
"the callback carried no code or state".to_owned(),
);
};
let ctx = state.ctx.as_ref();
let row = match service::spend_state(ctx, raw_state).await {
Ok(row) => row,
Err(error) => {
return problem_for(&error)
.instance(&scope.request_id)
.into_response();
}
};
if !state.settings.allows(&row.return_to) {
tracing::error!(
"refusing to redirect to a return_to whose origin is not an allowed origin"
);
return problem(
&BAD_STATE,
&scope,
"the return URL is not on an allowed origin".to_owned(),
);
}
let outcome = service::exchange(ctx, &state.settings, &row, code).await;
let (name, value) = match &outcome {
Ok(connection) => ("connection", connection.id.clone()),
Err(error) => ("error", error.slug().to_owned()),
};
redirect(&row.return_to, name, &value)
}
fn redirect(return_to: &str, name: &str, value: &str) -> Response {
let location = crate::append_query(return_to, name, value);
(StatusCode::SEE_OTHER, [(header::LOCATION, location)]).into_response()
}
fn problem(def: &ProblemDef, scope: &Scope, detail: String) -> Response {
Problem::new(def)
.with_detail(detail)
.instance(&scope.request_id)
.into_response()
}
#[must_use]
pub(crate) fn validate(settings: &Settings, cfg: &dyn Config) -> ConfigError {
let module = ModuleConfig::new("connections", cfg);
let mut errors = ConfigError::new();
match cfg.get(&module.key("TOKEN_KEY")) {
Some(raw) => {
if crate::seal::SealKey::from_config(&raw, 1).is_err() {
errors.push(format!(
"connections: {} must be {} bytes of base64",
module.key("TOKEN_KEY"),
crate::seal::KEY_LEN
));
}
}
None => errors.push(format!(
"connections: {} is required ({} random bytes, base64)",
module.key("TOKEN_KEY"),
crate::seal::KEY_LEN
)),
}
for provider in &settings.providers {
for kind in ["CLIENT_ID", "CLIENT_SECRET"] {
let key = module.key(&format!("{}_{kind}", provider.env_key()));
match cfg.get(&key) {
Some(value) if !value.trim().is_empty() => {}
Some(_) => errors.push(format!("connections: {key} must not be empty")),
None => errors.push(format!(
"connections: {key} is required (a Worker secret, never wrangler.toml)"
)),
}
}
}
if let Some(base) = cfg.get(&module.key("API_BASE"))
&& !base.trim().is_empty()
&& !base.starts_with("https://")
&& !base.starts_with("http://localhost")
&& !base.starts_with("http://127.0.0.1")
{
errors.push(format!(
"connections: {} must be https (or a localhost URL for wrangler dev), got {base:?}",
module.key("API_BASE")
));
}
errors
}
#[must_use]
pub(crate) fn self_check(settings: &Settings) -> Vec<String> {
let mut problems = Vec::new();
if settings.providers.is_empty() {
problems.push(
"connections: no providers are configured, so no connection can ever be started — \
add `.provider(..)`"
.to_owned(),
);
}
if settings.allowed_origins.is_empty() {
problems.push(
"connections: no allowed origins are configured, so every `return_to` would be \
refused and no callback could redirect — add `.allowed_origin(..)`"
.to_owned(),
);
}
for provider in &settings.providers {
if provider.key.is_empty()
|| !provider
.key
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
problems.push(format!(
"connections: provider key {:?} must be a non-empty kebab-case token",
provider.key
));
}
}
problems
}