Skip to main content

cratefield_module_connections/
lib.rs

1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3
4mod handlers;
5pub mod presets;
6mod seal;
7mod service;
8mod store;
9
10use std::sync::{Arc, OnceLock};
11
12use async_trait::async_trait;
13use sha2::{Digest, Sha256};
14use time::Duration;
15use zeroize::Zeroizing;
16
17use cratefield_core::{
18    Action, AnyError, BoxFuture, Config, ConfigError, DataKind, Disposition, Migrations, Module,
19    ModuleContext, Outcome, PersonalDataSet, Port, Problem, RoutePolicy, SqlMigration, Surface,
20};
21
22use cratefield_oauth_client::{ClientAuth, OAuthError};
23use handlers::Settings;
24
25/// The module's one migration: the `connection` and `connection_state` tables
26/// in the portable SQL subset (ADR 0004). Portable means it is also the set
27/// the Postgres runner applies, so a `postgres` override is only needed if the
28/// SQL ever truly diverges.
29const MIGRATION_INIT: SqlMigration = SqlMigration::new(
30    "0001",
31    "init",
32    include_str!("../migrations/sqlite/0001_init.sql"),
33);
34
35/// The lowercase-hex SHA-256 of a value. The `state` reaches the database
36/// only through this, so a dump, a backup or a query log holds nothing that
37/// can be replayed as one.
38#[must_use]
39pub(crate) fn sha256_hex(value: &str) -> String {
40    hex::encode(Sha256::digest(value.as_bytes()))
41}
42
43/// The origin of `url`: `scheme://host[:port]`, lower-cased, with a default
44/// port dropped. `None` for anything that is not an absolute `http`/`https`
45/// URL a browser would treat as one — which is what refuses `//evil.com`
46/// (no scheme), `javascript:...` (not a web scheme) and
47/// `https://app.example.com@evil.com` (userinfo, so the host is `evil.com`).
48#[must_use]
49pub(crate) fn origin_of(url: &str) -> Option<String> {
50    let (scheme, rest) = url.split_once("://")?;
51    let scheme = scheme.to_ascii_lowercase();
52    if scheme != "http" && scheme != "https" {
53        return None;
54    }
55    let authority = rest
56        .split(['/', '?', '#'])
57        .next()
58        .filter(|authority| !authority.is_empty())?;
59    if authority.contains('@') {
60        // `https://app.example.com@evil.com/` names `evil.com`; refusing the
61        // userinfo form outright is the only reading that cannot be tricked.
62        return None;
63    }
64    let authority = authority.to_ascii_lowercase();
65    let default_port = if scheme == "https" { ":443" } else { ":80" };
66    let host = authority.strip_suffix(default_port).unwrap_or(&authority);
67    Some(format!("{scheme}://{host}"))
68}
69
70/// Percent-encodes one query pair for `application/x-www-form-urlencoded`,
71/// leaving the unreserved set alone. `append_query` uses this to build the
72/// callback's `Location`, and the callback's own `Query` extractor decodes it
73/// back with the same rules, so a `state` or a `connection` id survives the
74/// round trip even when it holds a character a naive concatenation would let
75/// rewrite the URL.
76pub(crate) fn encode_pair(name: &str, value: &str) -> String {
77    use std::fmt::Write as _;
78    fn encode(text: &str) -> String {
79        let mut out = String::with_capacity(text.len());
80        for byte in text.bytes() {
81            match byte {
82                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
83                    out.push(byte as char);
84                }
85                _ => {
86                    let _ = write!(out, "%{byte:02X}");
87                }
88            }
89        }
90        out
91    }
92    format!("{}={}", encode(name), encode(value))
93}
94
95/// A short, printable ASCII token drawn from untrusted text, or `other`.
96///
97/// Both the OAuth `error` code and the callback's `error` query parameter are
98/// provider-controlled and end up in a log line, so anything that is not a
99/// short `[A-Za-z0-9._-]` run — embedded newlines, a huge blob, the refused
100/// token itself — is replaced wholesale rather than truncated.
101#[must_use]
102pub(crate) fn safe_token(text: &str) -> &str {
103    let tokenish = !text.is_empty()
104        && text.len() <= 64
105        && text
106            .bytes()
107            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'));
108    if tokenish { text } else { "other" }
109}
110
111/// `url` with one query pair appended — before any `#fragment`, choosing `?`
112/// or `&` from what is already in the path.
113#[must_use]
114pub(crate) fn append_query(url: &str, name: &str, value: &str) -> String {
115    let pair = encode_pair(name, value);
116    let (head, fragment) = url
117        .split_once('#')
118        .map_or((url, ""), |(head, fragment)| (head, fragment));
119    let separator = if head.contains('?') { '&' } else { '?' };
120    if fragment.is_empty() {
121        format!("{head}{separator}{pair}")
122    } else {
123        format!("{head}{separator}{pair}#{fragment}")
124    }
125}
126
127// ---------------------------------------------------------------------------
128// The provider spec
129
130/// One provider a venture lets people connect: where its endpoints live, how
131/// it wants the client authenticated, and how it behaves on a refresh.
132///
133/// Build one from [`presets`] and adjust, or fill in a provider the presets
134/// do not carry. No secret lives here — the client id and secret are read
135/// from `CONNECTIONS_<KEY>_CLIENT_ID` / `CONNECTIONS_<KEY>_CLIENT_SECRET` at
136/// call time.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct Provider {
139    pub(crate) key: String,
140    pub(crate) authorize_url: String,
141    pub(crate) token_url: String,
142    pub(crate) revoke_url: Option<String>,
143    pub(crate) scopes: Vec<String>,
144    pub(crate) scope_separator: &'static str,
145    pub(crate) client_auth: ClientAuth,
146    pub(crate) authorize_params: Vec<(String, String)>,
147    pub(crate) rotating_refresh: bool,
148    pub(crate) pkce: bool,
149}
150
151impl Provider {
152    /// A provider keyed `key`, with the given endpoints and space-separated
153    /// scopes; `client_secret_post` and no PKCE. Every other knob is set with
154    /// the `with_*` methods.
155    #[must_use]
156    pub fn new(
157        key: impl Into<String>,
158        authorize_url: impl Into<String>,
159        token_url: impl Into<String>,
160    ) -> Self {
161        Self {
162            key: key.into(),
163            authorize_url: authorize_url.into(),
164            token_url: token_url.into(),
165            revoke_url: None,
166            scopes: Vec::new(),
167            scope_separator: " ",
168            client_auth: ClientAuth::ClientSecretPost,
169            authorize_params: Vec::new(),
170            rotating_refresh: false,
171            pkce: false,
172        }
173    }
174
175    /// The provider's key — the `<key>` in the callback path, the
176    /// `CONNECTIONS_<KEY>_*` config prefix and the `provider` on a
177    /// [`Connection`].
178    #[must_use]
179    pub fn key(&self) -> &str {
180        &self.key
181    }
182
183    /// Replaces the scope list, e.g.
184    /// `presets::gitlab().with_scopes(["read_user", "api"])`.
185    #[must_use]
186    pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
187    where
188        I: IntoIterator<Item = S>,
189        S: Into<String>,
190    {
191        self.scopes = scopes.into_iter().map(Into::into).collect();
192        self
193    }
194
195    /// The revocation endpoint (RFC 7009), if the provider documents one.
196    #[must_use]
197    pub fn with_revoke_url(mut self, url: impl Into<String>) -> Self {
198        self.revoke_url = Some(url.into());
199        self
200    }
201
202    /// How the scope list is joined on the wire. Space unless the provider
203    /// says otherwise (Linear uses a comma).
204    #[must_use]
205    pub fn with_scope_separator(mut self, separator: &'static str) -> Self {
206        self.scope_separator = separator;
207        self
208    }
209
210    /// Where the client credentials ride on token calls.
211    #[must_use]
212    pub fn with_client_auth(mut self, auth: ClientAuth) -> Self {
213        self.client_auth = auth;
214        self
215    }
216
217    /// An extra parameter for the authorize URL — a provider's out-of-band
218    /// switch, like Google's `access_type=offline`.
219    #[must_use]
220    pub fn with_authorize_param(
221        mut self,
222        name: impl Into<String>,
223        value: impl Into<String>,
224    ) -> Self {
225        self.authorize_params.push((name.into(), value.into()));
226        self
227    }
228
229    /// Whether the provider issues a **new** refresh token on every refresh,
230    /// which the stored one must be replaced with.
231    #[must_use]
232    pub fn with_rotating_refresh(mut self, rotating: bool) -> Self {
233        self.rotating_refresh = rotating;
234        self
235    }
236
237    /// Whether to send an S256 PKCE challenge.
238    #[must_use]
239    pub fn with_pkce(mut self, pkce: bool) -> Self {
240        self.pkce = pkce;
241        self
242    }
243
244    /// The config-key prefix this provider's secrets live under.
245    pub(crate) fn env_key(&self) -> String {
246        service::env_key(&self.key)
247    }
248
249    /// Whether the provider rotates its refresh token. Read by the docs and
250    /// the tests; the refresh path does the right thing either way, because
251    /// `TokenResponse::rotated_refresh_token` reports what actually happened.
252    #[must_use]
253    pub fn rotates_refresh(&self) -> bool {
254        self.rotating_refresh
255    }
256}
257
258// ---------------------------------------------------------------------------
259// The public types
260
261/// Where a connection stands.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263#[non_exhaustive]
264pub enum ConnectionStatus {
265    /// Tokens are usable; the module will refresh them before they lapse.
266    Active,
267    /// The provider refused the refresh token; a person must authorize again.
268    NeedsReconnect,
269    /// Revoked: both tokens are cleared and nothing more can be issued.
270    Revoked,
271}
272
273impl ConnectionStatus {
274    /// The storage value.
275    #[must_use]
276    pub fn as_str(self) -> &'static str {
277        match self {
278            Self::Active => "active",
279            Self::NeedsReconnect => "needs_reconnect",
280            Self::Revoked => "revoked",
281        }
282    }
283
284    /// Reads a storage value. A value this crate did not write is treated as
285    /// needing a reconnect rather than as active — the safe reading of an
286    /// unknown state is "do not use its tokens".
287    #[must_use]
288    pub(crate) fn parse(raw: &str) -> Self {
289        match raw {
290            "active" => Self::Active,
291            "revoked" => Self::Revoked,
292            _ => Self::NeedsReconnect,
293        }
294    }
295}
296
297/// A connected account, as a venture reads it. **It never carries a token** —
298/// the access token is a separate call ([`ConnectionsApi::access_token`]),
299/// so a token cannot leak by someone serializing a `Connection`.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct Connection {
302    /// The connection's id — stable, and what every other call takes.
303    pub id: String,
304    /// The subject this connection belongs to.
305    pub subject: String,
306    /// The provider key.
307    pub provider: String,
308    /// The provider-side account id, once a venture has learned it.
309    pub external_account_id: Option<String>,
310    /// The name to show a person for this account.
311    pub display_name: Option<String>,
312    /// The scopes the provider actually granted.
313    pub scopes: Vec<String>,
314    /// Where the connection stands.
315    pub status: ConnectionStatus,
316    /// When the access token lapses, RFC 3339 UTC. `None` when the provider
317    /// stated no lifetime.
318    pub access_expires_at: Option<String>,
319    /// When the refresh token lapses, when the provider said.
320    pub refresh_expires_at: Option<String>,
321    /// Why the connection needs a reconnect, when it does.
322    pub last_error: Option<String>,
323    /// When the connection was made.
324    pub created_at: String,
325    /// When it last changed.
326    pub updated_at: String,
327}
328
329impl From<store::ConnectionRow> for Connection {
330    fn from(row: store::ConnectionRow) -> Self {
331        Self {
332            id: row.id,
333            subject: row.subject,
334            provider: row.provider,
335            external_account_id: row.external_account_id,
336            display_name: row.display_name,
337            scopes: store::scopes_of(&row.scopes),
338            status: ConnectionStatus::parse(&row.status),
339            access_expires_at: row.access_expires_at,
340            refresh_expires_at: row.refresh_expires_at,
341            last_error: row.last_error,
342            created_at: row.created_at,
343            updated_at: row.updated_at,
344        }
345    }
346}
347
348/// A live access token for one connection. It is wiped on drop and its
349/// `Debug` prints nothing, so a token cannot reach a log or a panic message
350/// by accident. Its value is only reachable through [`AccessToken::expose`],
351/// which is the one place a caller has to mean it.
352pub struct AccessToken {
353    connection_id: String,
354    token: Zeroizing<String>,
355    expires_at: Option<String>,
356}
357
358impl AccessToken {
359    pub(crate) fn new(
360        connection_id: String,
361        token: Zeroizing<String>,
362        expires_at: Option<String>,
363    ) -> Self {
364        Self {
365            connection_id,
366            token,
367            expires_at,
368        }
369    }
370
371    /// The connection this token belongs to.
372    #[must_use]
373    pub fn connection_id(&self) -> &str {
374        &self.connection_id
375    }
376
377    /// The bearer token itself. Deliberately not `Deref`: taking it should be
378    /// a call a reader can see.
379    #[must_use]
380    pub fn expose(&self) -> &str {
381        &self.token
382    }
383
384    /// Takes the token out, to hand to an
385    /// [`HttpClient`](cratefield_core::HttpClient) header and drop.
386    #[must_use]
387    pub fn into_secret(self) -> Zeroizing<String> {
388        self.token
389    }
390
391    /// When the token lapses, RFC 3339 UTC.
392    #[must_use]
393    pub fn expires_at(&self) -> Option<&str> {
394        self.expires_at.as_deref()
395    }
396}
397
398impl std::fmt::Debug for AccessToken {
399    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400        f.debug_struct("AccessToken")
401            .field("connection_id", &self.connection_id)
402            .field("token", &"<redacted>")
403            .field("expires_at", &self.expires_at)
404            .finish()
405    }
406}
407
408/// The URL a browser opens to start a connection, and the deadline on the
409/// `state` that goes with it.
410#[derive(Debug, Clone, PartialEq, Eq)]
411pub struct AuthorizeUrl {
412    /// The provider authorization URL, with state, PKCE challenge and scopes.
413    pub url: String,
414    /// When the state stops being usable, RFC 3339 UTC.
415    pub expires_at: String,
416    /// The provider key this URL is for.
417    pub provider: String,
418}
419
420/// A failed provider call, reduced to the two facts this module will show or
421/// record: the HTTP status it answered with and the RFC 6749 §5.2 `error`
422/// code. The oauth client's own error carries the provider's
423/// `error_description` and raw body, and a token endpoint can echo the very
424/// token it refused in that prose — so none of it is kept. This type is
425/// therefore safe to log, to store in `last_error`, and to hand to a venture.
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct ProviderFailure {
428    /// The HTTP status the provider answered with; `None` when the call never
429    /// reached it (a transport or decode failure).
430    pub status: Option<u16>,
431    /// The §5.2 `error` code when it was short ASCII token-ish text, else
432    /// `other`; `transport`, `decode`, `config` or `no_revoke_endpoint` for a
433    /// failure that is ours rather than the provider's.
434    pub code: String,
435}
436
437impl ProviderFailure {
438    /// Reduces the oauth client's error to the fields that are safe to keep.
439    #[must_use]
440    pub fn of(error: &OAuthError) -> Self {
441        match error {
442            OAuthError::Provider(inner) => Self {
443                status: Some(inner.status),
444                code: safe_token(&inner.code).to_owned(),
445            },
446            OAuthError::Transport(_) => Self {
447                status: None,
448                code: "transport".to_owned(),
449            },
450            OAuthError::Decode(_) => Self {
451                status: None,
452                code: "decode".to_owned(),
453            },
454            OAuthError::Config(_) => Self {
455                status: None,
456                code: "config".to_owned(),
457            },
458            OAuthError::NoRevokeEndpoint => Self {
459                status: None,
460                code: "no_revoke_endpoint".to_owned(),
461            },
462        }
463    }
464}
465
466impl std::fmt::Display for ProviderFailure {
467    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468        match self.status {
469            Some(status) => write!(f, "the provider answered {status} (error `{}`)", self.code),
470            None => write!(f, "the provider call failed ({})", self.code),
471        }
472    }
473}
474
475impl std::error::Error for ProviderFailure {}
476
477/// What a connection call can fail with.
478#[derive(Debug, thiserror::Error)]
479#[non_exhaustive]
480pub enum ConnectionError {
481    /// The provider key is not one this venture configured.
482    #[error("`{0}` is not a configured provider")]
483    UnknownProvider(String),
484    /// The `return_to` is not on an allowed origin.
485    #[error("`{0}` is not on an allowed origin")]
486    OriginNotAllowed(String),
487    /// The state is unknown, spent or expired.
488    #[error("the connect state is unknown, spent or expired")]
489    BadState,
490    /// No connection carries the id.
491    #[error("no connection with id `{0}`")]
492    NotFound(String),
493    /// The provider requires a fresh authorization.
494    #[error("the connection needs to be reconnected: {0}")]
495    NeedsReconnect(String),
496    /// The connection was revoked.
497    #[error("the connection was revoked")]
498    Revoked,
499    /// The provider's token endpoint failed or answered with a non-token.
500    #[error(transparent)]
501    Provider(ProviderFailure),
502    /// The module is misconfigured, or a sealed value could not be read.
503    #[error("connections is misconfigured: {0}")]
504    Config(String),
505    /// A database failure.
506    #[error(transparent)]
507    Db(#[from] cratefield_core::DbError),
508}
509
510impl ConnectionError {
511    /// The RFC 9457 `type` slug this failure maps to, so a route the venture
512    /// writes can answer the callback's `error=<slug>` convention.
513    #[must_use]
514    pub fn slug(&self) -> &'static str {
515        handlers::problem_for(self).slug
516    }
517
518    /// The problem body a caller should answer with.
519    #[must_use]
520    pub fn problem(&self) -> Problem {
521        handlers::problem_for(self)
522    }
523
524    /// The structured, safe facts of a provider failure, when this is one —
525    /// what a log line records instead of the error's `Display`.
526    #[must_use]
527    pub fn provider_failure(&self) -> Option<&ProviderFailure> {
528        match self {
529            Self::Provider(failure) => Some(failure),
530            _ => None,
531        }
532    }
533}
534
535// ---------------------------------------------------------------------------
536// The module and its builder
537
538/// The connections module (issue #624). Compose it with
539/// [`Connections::builder`], and reach the lifecycle through
540/// [`Connections::api`].
541pub struct Connections {
542    settings: Settings,
543    /// The context of the first `router` build. A module is mounted twice in
544    /// some deployments; the first wins, so the API handle never disagrees
545    /// with the routes about which ports it has.
546    ctx: Arc<OnceLock<Arc<ModuleContext>>>,
547}
548
549impl Default for Connections {
550    fn default() -> Self {
551        Self::builder().build()
552    }
553}
554
555impl Connections {
556    /// A builder with no providers and no allowed origins.
557    #[must_use]
558    pub fn builder() -> ConnectionsBuilder {
559        ConnectionsBuilder::new()
560    }
561
562    /// A handle for calling the lifecycle from anywhere else in the venture —
563    /// a route, a scheduled job, an event handler. It shares the module's
564    /// settings, so it can only reach the providers this venture configured.
565    #[must_use]
566    pub fn api(&self) -> ConnectionsApi {
567        ConnectionsApi {
568            ctx: Arc::clone(&self.ctx),
569            settings: self.settings.clone(),
570        }
571    }
572}
573
574/// Builds a [`Connections`].
575pub struct ConnectionsBuilder {
576    settings: Settings,
577}
578
579impl ConnectionsBuilder {
580    fn new() -> Self {
581        Self {
582            settings: Settings {
583                providers: Vec::new(),
584                allowed_origins: Vec::new(),
585                refresh_lead_secs: service::DEFAULT_REFRESH_LEAD_SECS,
586            },
587        }
588    }
589
590    /// Adds a provider. Two providers with the same key: the later wins, so
591    /// a venture can override a preset's scopes by adding its own after it.
592    #[must_use]
593    pub fn provider(mut self, provider: Provider) -> Self {
594        self.settings
595            .providers
596            .retain(|existing| existing.key != provider.key);
597        self.settings.providers.push(provider);
598        self
599    }
600
601    /// Adds several providers at once.
602    #[must_use]
603    pub fn providers<I>(mut self, providers: I) -> Self
604    where
605        I: IntoIterator<Item = Provider>,
606    {
607        for provider in providers {
608            self = self.provider(provider);
609        }
610        self
611    }
612
613    /// An origin a `return_to` may live on, e.g. `https://app.example.com`. A
614    /// `return_to` off every allowed origin is refused before a state is
615    /// written and again before any redirect, so a stolen `state` cannot be
616    /// turned into an open redirect. Only the origin is kept: a path, query or
617    /// fragment is dropped.
618    ///
619    /// # Panics
620    ///
621    /// Panics when `origin` is not an absolute `http(s)` origin — a bare host,
622    /// a relative path, a non-web scheme. Such a value could never match a
623    /// `return_to`, which always parses to an origin, so accepting it would
624    /// silently refuse every start; failing at the call site names the mistake
625    /// where it was made.
626    #[must_use]
627    pub fn allowed_origin(mut self, origin: impl AsRef<str>) -> Self {
628        let raw = origin.as_ref();
629        let normalized = origin_of(raw).unwrap_or_else(|| {
630            panic!("connections: `{raw}` is not an absolute http(s) origin, e.g. https://app.example.com")
631        });
632        if !self.settings.allowed_origins.contains(&normalized) {
633            self.settings.allowed_origins.push(normalized);
634        }
635        self
636    }
637
638    /// How long before an access token lapses to refresh it. A negative lead
639    /// is clamped to zero, which makes a refresh due only at expiry.
640    #[must_use]
641    pub fn refresh_lead(mut self, lead: Duration) -> Self {
642        self.settings.refresh_lead_secs = lead.whole_seconds().max(0);
643        self
644    }
645
646    /// Finishes the module.
647    #[must_use]
648    pub fn build(self) -> Connections {
649        Connections {
650            settings: self.settings,
651            ctx: Arc::new(OnceLock::new()),
652        }
653    }
654}
655
656/// The venture-facing lifecycle handle. Every method names its subject or
657/// connection explicitly — there is no "current caller" here.
658#[derive(Clone)]
659pub struct ConnectionsApi {
660    ctx: Arc<OnceLock<Arc<ModuleContext>>>,
661    settings: Settings,
662}
663
664impl ConnectionsApi {
665    fn ctx(&self) -> Result<Arc<ModuleContext>, ConnectionError> {
666        self.ctx.get().cloned().ok_or_else(|| {
667            ConnectionError::Config(
668                "the connections module has not been mounted yet; build the harness first"
669                    .to_owned(),
670            )
671        })
672    }
673
674    /// Starts a connection for `subject`, returning the URL to send the
675    /// browser to. Refuses a `return_to` off the venture's allowed origins.
676    /// # Errors
677    /// See [`ConnectionError`].
678    pub async fn start(
679        &self,
680        subject: &str,
681        provider: &str,
682        return_to: &str,
683    ) -> Result<AuthorizeUrl, ConnectionError> {
684        service::start(
685            self.ctx()?.as_ref(),
686            &self.settings,
687            subject,
688            provider,
689            return_to,
690        )
691        .await
692    }
693
694    /// Completes a connection from the callback's `state` and `code`. Exactly
695    /// one caller per state succeeds.
696    /// # Errors
697    /// See [`ConnectionError`].
698    pub async fn complete(&self, state: &str, code: &str) -> Result<Connection, ConnectionError> {
699        service::complete(self.ctx()?.as_ref(), &self.settings, state, code).await
700    }
701
702    /// A subject's connections.
703    /// # Errors
704    /// See [`ConnectionError`].
705    pub async fn list(&self, subject: &str) -> Result<Vec<Connection>, ConnectionError> {
706        service::list(self.ctx()?.as_ref(), subject).await
707    }
708
709    /// One connection by id.
710    /// # Errors
711    /// See [`ConnectionError`].
712    pub async fn get(&self, connection_id: &str) -> Result<Connection, ConnectionError> {
713        service::get(self.ctx()?.as_ref(), connection_id).await
714    }
715
716    /// A live access token for `connection_id`, refreshed first when due.
717    /// # Errors
718    /// See [`ConnectionError`].
719    pub async fn access_token(&self, connection_id: &str) -> Result<AccessToken, ConnectionError> {
720        service::access_token(self.ctx()?.as_ref(), &self.settings, connection_id).await
721    }
722
723    /// Revokes a connection: the provider is asked to drop the tokens, the
724    /// row is marked revoked and its ciphertexts are cleared.
725    /// # Errors
726    /// See [`ConnectionError`].
727    pub async fn revoke(&self, connection_id: &str) -> Result<(), ConnectionError> {
728        service::revoke(self.ctx()?.as_ref(), &self.settings, connection_id).await
729    }
730
731    /// Marks a connection as needing a new authorization.
732    /// # Errors
733    /// See [`ConnectionError`].
734    pub async fn mark_needs_reconnect(
735        &self,
736        connection_id: &str,
737        reason: &str,
738    ) -> Result<(), ConnectionError> {
739        service::mark_needs_reconnect(self.ctx()?.as_ref(), connection_id, reason).await
740    }
741
742    /// Records the provider-side account id and display name for a
743    /// connection.
744    /// # Errors
745    /// See [`ConnectionError`].
746    pub async fn set_account(
747        &self,
748        connection_id: &str,
749        external_account_id: Option<&str>,
750        display_name: Option<&str>,
751    ) -> Result<(), ConnectionError> {
752        service::set_account(
753            self.ctx()?.as_ref(),
754            connection_id,
755            external_account_id,
756            display_name,
757        )
758        .await
759    }
760}
761
762// ---------------------------------------------------------------------------
763// The module
764
765#[async_trait]
766impl Module for Connections {
767    fn name(&self) -> &'static str {
768        "connections"
769    }
770
771    fn version(&self) -> &'static str {
772        env!("CARGO_PKG_VERSION")
773    }
774
775    /// The connections live in a database, and both the connect and the
776    /// refresh legs talk to the provider: without `HttpClient` there is no
777    /// connection to make and without `Db` none to keep.
778    fn requires(&self) -> &'static [Port] {
779        &[Port::Db, Port::HttpClient]
780    }
781
782    /// `Clock` decides "now" for expiry and the refresh lead; `IdGen` mints
783    /// connection ids; `Defer` is what events are emitted through. Each has a
784    /// working default, so none is required.
785    fn optional(&self) -> &'static [Port] {
786        &[Port::Clock, Port::IdGen, Port::Defer]
787    }
788
789    fn tables(&self) -> &'static [&'static str] {
790        &["connection", "connection_state"]
791    }
792
793    /// Both tables are a subject's. `connection` is the account they
794    /// connected — the provider, the provider-side account id and the name to
795    /// show them — and the two token columns are held but never exported: a
796    /// token is a bearer credential, so a copy in an export file is a copy of
797    /// the credential (ADR 0015). `connection_state` is an attempt in flight,
798    /// which names the subject too; its `state` is present only as a hash and
799    /// its PKCE verifier only as ciphertext, both redacted.
800    fn personal_data(&self) -> &'static [PersonalDataSet] {
801        static SETS: OnceLock<Vec<PersonalDataSet>> = OnceLock::new();
802        SETS.get_or_init(|| {
803            vec![
804                PersonalDataSet {
805                    table: "connection",
806                    subject: "subject",
807                    kind: DataKind::Identifier,
808                    disposition: Disposition::Erase,
809                    description: "A third-party account you connected: the provider, the \
810                        account id and name it gave us, the scopes, and the live status. The \
811                        access and refresh tokens are held only as ciphertext and are never \
812                        copied into an export or a response.",
813                    redacted: &["access_token_sealed", "refresh_token_sealed"],
814                    subject_via: None,
815                },
816                PersonalDataSet {
817                    table: "connection_state",
818                    subject: "subject",
819                    kind: DataKind::Identifier,
820                    disposition: Disposition::Erase,
821                    description: "A connection attempt still in flight: where the browser was \
822                        sent and when it lapses. The anti-forgery state is held only as a \
823                        SHA-256 hash and the PKCE verifier only as ciphertext. Once spent, or \
824                        ten minutes old, the row is deleted.",
825                    redacted: &["state_hash", "verifier_sealed"],
826                    subject_via: None,
827                },
828            ]
829        })
830        .as_slice()
831    }
832
833    /// The four transitions a venture may want to react to. Payloads name the
834    /// connection id, the subject and the provider — never a token.
835    fn emits(&self) -> &'static [&'static str] {
836        &[
837            handlers::EVENT_CONNECTED,
838            handlers::EVENT_REFRESHED,
839            handlers::EVENT_REVOKED,
840            handlers::EVENT_NEEDS_RECONNECT,
841        ]
842    }
843
844    fn migrations(&self) -> Migrations {
845        const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
846        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
847        Migrations::sqlite(&MIGRATIONS)
848    }
849
850    /// `CONNECTIONS_TOKEN_KEY` (32 bytes of base64) is required, and so is a
851    /// client id and secret for every provider the venture composed — a
852    /// provider with no secret cannot exchange a code, so it is a build
853    /// error rather than a callback that fails in production.
854    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
855        handlers::validate(&self.settings, cfg).into_result()
856    }
857
858    /// The composition a build must refuse: no providers to connect, no
859    /// origins to return to, or a provider key that cannot be a path segment.
860    fn self_check(&self) -> Vec<String> {
861        handlers::self_check(&self.settings)
862    }
863
864    fn router(&self, ctx: ModuleContext) -> axum::Router {
865        let ctx = Arc::new(ctx);
866        // A module can be mounted twice (in-process and as a sidecar); the
867        // first context wins, so the API handle and the routes always agree
868        // about which ports they hold.
869        let _ = self.ctx.set(Arc::clone(&ctx));
870        handlers::router(ctx, self.settings.clone())
871    }
872
873    /// The one route: the provider's redirect target.
874    ///
875    /// It is a public write, and declared `Open` on purpose. A provider's
876    /// redirect is exactly what it is — the browser carries a `state` this
877    /// module minted and a `code` for a request this module started, and the
878    /// route authenticates itself by spending that single-use `state`. There
879    /// is nothing for a CAPTCHA to gate and no cookie to check: the state is
880    /// the whole proof, and a spent or unknown one answers a problem.
881    fn surface(&self) -> Surface {
882        Surface::new().action(
883            Action::get("callback", "/callback/{provider}")
884                .policy(RoutePolicy::Open)
885                .outcome(Outcome::Redirect),
886        )
887    }
888
889    /// The scheduled pass: purge spent and expired states, then refresh the
890    /// connections due within the lead, one `try_spend(1)` per unit so the
891    /// invocation's budget is respected (ADR 0023).
892    fn scheduled<'a>(
893        &'a self,
894        ctx: &'a ModuleContext,
895        cron: &'a str,
896    ) -> BoxFuture<'a, Result<(), AnyError>> {
897        Box::pin(async move {
898            if let Err(error) = service::maintain(ctx, &self.settings).await {
899                let failure = error.provider_failure();
900                tracing::warn!(
901                    cron,
902                    slug = error.slug(),
903                    status = ?failure.and_then(|failure| failure.status),
904                    code = failure.map_or("-", |failure| failure.code.as_str()),
905                    "the scheduled connections pass did not complete"
906                );
907            }
908            Ok(())
909        })
910    }
911}
912
913#[cfg(test)]
914mod tests {
915    use super::*;
916
917    /// A pair is appended before a fragment, so a `return_to` that carries one
918    /// (`.../settings#tab`) keeps it.
919    #[test]
920    fn a_query_pair_goes_before_the_fragment() {
921        assert_eq!(
922            append_query("https://app.example.com/settings#tab", "connection", "c-1"),
923            "https://app.example.com/settings?connection=c-1#tab"
924        );
925        assert_eq!(
926            append_query("https://app.example.com/settings?a=1#tab", "b", "2"),
927            "https://app.example.com/settings?a=1&b=2#tab"
928        );
929        assert_eq!(
930            append_query("https://app.example.com/s", "b", "2"),
931            "https://app.example.com/s?b=2"
932        );
933    }
934
935    /// Only a short ASCII token survives: anything a provider could put in an
936    /// `error` field — a newline, a huge blob, the refused token — is `other`.
937    #[test]
938    fn only_a_short_token_survives_sanitising() {
939        assert_eq!(safe_token("access_denied"), "access_denied");
940        assert_eq!(safe_token("invalid_grant"), "invalid_grant");
941        assert_eq!(safe_token("line\nbreak"), "other");
942        assert_eq!(safe_token(""), "other");
943        assert_eq!(safe_token(&"x".repeat(65)), "other");
944    }
945
946    /// A value that is not an origin is refused where it is written, rather
947    /// than stored so that every `start` is then silently refused.
948    #[test]
949    #[should_panic(expected = "is not an absolute http(s) origin")]
950    fn a_bad_allowed_origin_panics() {
951        let _ = Connections::builder().allowed_origin("app.example.com");
952    }
953}