use crate::{
output::{CliError, EXIT_AUTH, EXIT_USAGE, Output},
state::{Home, Snapshot, State, StoredSignIn, slot},
};
use silicon_remind_client::{
Client, Error as ClientError, Refusal, Secret,
accounts::{self, SignIn, Tokens},
};
use uuid::Uuid;
pub const REFRESH_MARGIN: i64 = 60;
pub const SILICON_SIGN_IN: &str =
"silicon-accounts login --app remind -q | remind login --slt-stdin";
const FINAL_401: &[&str] = &[
"account_deleted",
"token_kind_mismatch",
"token_wrong_audience",
"legacy_token_rejected",
"proof_as_bearer",
"proof_not_accepted",
"proof_invalid",
"test_key_invalid",
];
pub fn now() -> i64 {
accounts::now()
}
pub struct Ctx {
pub out: Output,
home: Result<Home, String>,
pub url: String,
pub accounts_url: String,
pub app_id: String,
pub test: Option<Uuid>,
pub explicit_test: Option<Uuid>,
pub idempotency_key: Option<String>,
pub snapshot: Snapshot,
}
pub struct CtxInputs<'a> {
pub json: bool,
pub url: Option<&'a str>,
pub accounts_url: Option<&'a str>,
pub test: Option<Uuid>,
pub production: bool,
pub idempotency_key: Option<String>,
}
impl Ctx {
pub fn new(inputs: CtxInputs<'_>) -> Self {
let home = Home::resolve().map_err(|error| format!("{error:#}"));
let snapshot = home.as_ref().map_or_else(
|_| Snapshot {
state: State::default(),
exists: false,
legacy_sign_ins: false,
legacy: false,
unreadable: None,
},
Home::read,
);
let url = inputs
.url
.map(str::to_owned)
.unwrap_or_else(|| snapshot.state.url.clone())
.trim()
.trim_end_matches('/')
.to_owned();
let accounts_url = inputs
.accounts_url
.map(str::to_owned)
.or_else(|| snapshot.state.accounts_url.clone())
.unwrap_or_else(|| accounts::DEFAULT_ACCOUNTS_URL.to_owned())
.trim()
.trim_end_matches('/')
.to_owned();
let app_id = std::env::var("REMIND_APP_ID")
.ok()
.map(|id| id.trim().to_owned())
.filter(|id| !id.is_empty())
.unwrap_or_else(|| accounts::APP_ID.to_owned());
let test = if inputs.production {
None
} else {
inputs
.test
.or_else(|| snapshot.state.selected_tests.get(&url).copied())
};
Self {
out: Output { json: inputs.json },
home,
url,
accounts_url,
app_id,
test,
explicit_test: if inputs.production { None } else { inputs.test },
idempotency_key: inputs.idempotency_key,
snapshot,
}
}
pub fn home(&self) -> anyhow::Result<&Home> {
self.home.as_ref().map_err(|reason| {
CliError::new(
"home_unavailable",
EXIT_USAGE,
format!("Remind has nowhere to keep its state: {reason}."),
"Set SILICON_HOME (or HOME) to an existing directory.",
)
.into()
})
}
pub fn sign_in_slot(&self) -> String {
slot(&self.url, self.explicit_test)
}
pub fn client(&self) -> anyhow::Result<Client> {
let client =
Client::new(&self.url).map_err(|error| bad_origin("--url / REMIND_URL", &error))?;
match self.test {
Some(id) => Ok(client.with_test_environment(self.test_key(id)?)?),
None => Ok(client),
}
}
pub fn production_client(&self) -> anyhow::Result<Client> {
Client::new(&self.url).map_err(|error| bad_origin("--url / REMIND_URL", &error))
}
pub fn test_key(&self, id: Uuid) -> anyhow::Result<Secret> {
self.snapshot
.state
.test_keys
.get(&slot(&self.url, Some(id)))
.cloned()
.ok_or_else(|| {
CliError::usage(
format!("No key is saved here for test environment {id} on {}.", self.url),
format!(
"Fetch it with `remind env key {id}` (signed in, without --test), or save a shared one with `remind env import {id} --key-stdin`."
),
)
.into()
})
}
pub fn sign_in_at(&self, accounts_url: &str, app_id: &str) -> anyhow::Result<SignIn> {
SignIn::new(accounts_url, app_id)
.map_err(|error| bad_origin("--accounts-url / ACCOUNTS_URL", &error))
}
pub fn effective(&self, state: &State) -> Option<(String, StoredSignIn, bool)> {
self.effective_for(state, self.test)
}
pub fn effective_for(
&self,
state: &State,
test: Option<Uuid>,
) -> Option<(String, StoredSignIn, bool)> {
let own = slot(&self.url, test);
if let Some(stored) = state.sign_ins.get(&own) {
return Some((own, stored.clone(), false));
}
if test.is_some() {
let production = slot(&self.url, None);
if let Some(stored) = state.sign_ins.get(&production) {
return Some((production, stored.clone(), true));
}
}
None
}
pub fn mutation(&self) -> anyhow::Result<silicon_remind_client::Mutation> {
Ok(match &self.idempotency_key {
Some(key) => silicon_remind_client::Mutation::with_key(key)?,
None => silicon_remind_client::Mutation::new(),
})
}
pub fn not_signed_in(&self) -> anyhow::Error {
let place = match self.test {
Some(id) => format!("{} (test environment {id})", self.url),
None => self.url.clone(),
};
let message = if self.snapshot.legacy_sign_ins {
format!(
"Not signed in to Remind at {place}: the sign-in saved by an earlier Remind no longer works."
)
} else {
format!("Not signed in to Remind at {place}.")
};
CliError::new(
"not_signed_in",
EXIT_AUTH,
message,
format!("Sign in: `remind login` (Carbons) or `{SILICON_SIGN_IN}` (Silicons)."),
)
.into()
}
}
fn bad_origin(source: &str, error: &ClientError) -> anyhow::Error {
CliError::usage(
format!("{} (from {source}).", error.message().trim_end_matches('.')),
"Use an https origin such as https://api.remind.teamofsilicons.com; plain http works only for localhost, 127.0.0.1 or ::1.",
)
.into()
}
pub fn stored_from(tokens: Tokens, accounts_url: &str, app_id: &str, method: &str) -> StoredSignIn {
StoredSignIn {
accounts_url: accounts_url.trim_end_matches('/').to_owned(),
app_id: app_id.to_owned(),
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_at,
refresh_expires_at: tokens.refresh_expires_at,
scope: tokens.scope,
account: tokens.account,
method: method.to_owned(),
signed_in_at: now(),
refreshed_at: None,
}
}
fn merged(current: &StoredSignIn, tokens: Tokens) -> anyhow::Result<StoredSignIn> {
if tokens.account.uuid != current.account.uuid {
anyhow::bail!(
"Silicon Accounts refreshed the sign-in of {} as another account ({}); sign in again.",
current.account.id,
tokens.account.id
);
}
let mut next = current.clone();
next.access_token = tokens.access_token;
next.refresh_token = tokens.refresh_token;
next.expires_at = tokens.expires_at;
if tokens.refresh_expires_at.is_some() {
next.refresh_expires_at = tokens.refresh_expires_at;
}
if !tokens.scope.is_empty() {
next.scope = tokens.scope;
}
next.account = tokens.account;
next.refreshed_at = Some(now());
Ok(next)
}
pub async fn refresh_single_flight(
ctx: &Ctx,
slot: &str,
seen: &StoredSignIn,
force: bool,
) -> anyhow::Result<StoredSignIn> {
let home = ctx.home()?.clone();
let locked = tokio::task::spawn_blocking(move || home.lock()).await??;
let mut snapshot = locked.read();
let Some(current) = snapshot.state.sign_ins.get(slot).cloned() else {
return Err(ctx.not_signed_in());
};
if current.account.uuid != seen.account.uuid {
return Err(CliError::new(
"sign_in_changed",
EXIT_AUTH,
format!(
"The saved sign-in changed while this command ran: it is now {}.",
current.account.id
),
"Run the command again.",
)
.into());
}
let now = now();
let refreshed_elsewhere = current.access_token != seen.access_token;
if (refreshed_elsewhere || !force) && current.fresh(now, REFRESH_MARGIN) {
return Ok(current);
}
let sign_in = ctx.sign_in_at(¤t.accounts_url, ¤t.app_id)?;
match sign_in.refresh(¤t.refresh_token).await {
Ok(tokens) => {
let updated = merged(¤t, tokens)?;
snapshot
.state
.sign_ins
.insert(slot.to_owned(), updated.clone());
for notice in locked.write(&snapshot)? {
ctx.out.warn(¬ice);
}
Ok(updated)
}
Err(
error @ ClientError::SignInRefused {
refusal: Refusal::SignInEnded,
..
},
) => {
snapshot.state.sign_ins.remove(slot);
locked.write(&snapshot)?;
Err(CliError::new(
"sign_in_ended",
EXIT_AUTH,
format!(
"The sign-in of {} has ended, so it was removed from this machine: {}",
current.account.id,
error.message()
),
format!(
"Sign in again: `remind login` (Carbons) or `{SILICON_SIGN_IN}` (Silicons)."
),
)
.into())
}
Err(error) => Err(error.into()),
}
}
pub struct Authed<'a> {
ctx: &'a Ctx,
slot: String,
sign_in: StoredSignIn,
base: Client,
}
impl<'a> Authed<'a> {
pub async fn new(ctx: &'a Ctx) -> anyhow::Result<Self> {
Self::scoped(ctx, ctx.test).await
}
pub async fn production(ctx: &'a Ctx) -> anyhow::Result<Self> {
Self::scoped(ctx, None).await
}
async fn scoped(ctx: &'a Ctx, test: Option<Uuid>) -> anyhow::Result<Self> {
ctx.home()?;
let base = if test.is_some() {
ctx.client()?
} else {
ctx.production_client()?
};
let Some((slot, sign_in, _)) = ctx.effective_for(&ctx.snapshot.state, test) else {
return Err(ctx.not_signed_in());
};
let mut authed = Self {
ctx,
slot,
sign_in,
base,
};
if !authed.sign_in.fresh(now(), REFRESH_MARGIN) {
authed.sign_in =
refresh_single_flight(ctx, &authed.slot, &authed.sign_in, false).await?;
}
Ok(authed)
}
pub fn sign_in(&self) -> &StoredSignIn {
&self.sign_in
}
fn client(&self) -> anyhow::Result<Client> {
Ok(self.base.with_session(self.sign_in.access_token.clone())?)
}
pub async fn call<T>(
&mut self,
f: impl AsyncFn(&Client) -> silicon_remind_client::Result<T>,
) -> anyhow::Result<T> {
let client = self.client()?;
match f(&client).await {
Err(ClientError::Api {
status: 401, code, ..
}) if !FINAL_401.contains(&code.as_str()) => {
self.sign_in =
refresh_single_flight(self.ctx, &self.slot, &self.sign_in, true).await?;
let client = self.client()?;
Ok(f(&client).await?)
}
other => Ok(other?),
}
}
}