use std::collections::HashSet;
use std::sync::Arc;
use autumn_web::reexports::axum::body::Body;
use autumn_web::reexports::axum::extract::RawQuery;
use autumn_web::reexports::axum::response::Response;
use autumn_web::reexports::http::{HeaderMap, StatusCode, header};
use autumn_web::{AppState, AutumnResult, State, get, public, routes};
use serde::Deserialize;
use subtle::ConstantTimeEq as _;
use crate::config::ShopifyConfig;
use crate::error::{Error, Result};
use crate::plugin::{InstallContext, Keeper, Shopify, ShopifyRuntime};
use crate::tokens::ShopToken;
use crate::types::ShopDomain;
pub const STATE_COOKIE: &str = "__Host-shopify_oauth_state";
pub const AUTH_PATH: &str = "/shopify/auth";
pub const CALLBACK_PATH: &str = "/shopify/auth/callback";
pub const STATE_COOKIE_MAX_AGE_SECS: u32 = 600;
pub const NONCE_BYTES: usize = 16;
const COOKIE_ATTRIBUTES: &str = "Path=/; HttpOnly; Secure; SameSite=Lax";
pub fn authorize_url(config: &ShopifyConfig, shop: &ShopDomain, nonce: &str) -> Result<String> {
let redirect_uri = config.redirect_uri().ok_or(Error::OAuthDisabled)?;
let api_key = config
.api_key
.as_deref()
.ok_or_else(|| Error::Config("OAuth needs [shopify] api_key".to_owned()))?;
let query = url::form_urlencoded::Serializer::new(String::new())
.append_pair("client_id", api_key)
.append_pair("scope", &config.scopes.join(","))
.append_pair("redirect_uri", &redirect_uri)
.append_pair("state", nonce)
.finish();
Ok(format!("https://{shop}/admin/oauth/authorize?{query}"))
}
#[must_use]
pub fn missing_scopes(requested: &[String], granted: &[&str]) -> Vec<String> {
let granted: HashSet<&str> = granted.iter().copied().collect();
requested
.iter()
.filter(|scope| {
let covered_by_write = scope
.strip_prefix("read_")
.is_some_and(|rest| granted.contains(format!("write_{rest}").as_str()));
!granted.contains(scope.as_str()) && !covered_by_write
})
.cloned()
.collect()
}
pub(crate) fn parse_query(raw: Option<&str>) -> Result<Vec<(String, String)>> {
let mut seen = HashSet::new();
let mut pairs = Vec::new();
for (key, value) in url::form_urlencoded::parse(raw.unwrap_or_default().as_bytes()) {
if !seen.insert(key.clone()) {
return Err(Error::InvalidRequest(format!(
"query parameter {key:?} appears two times"
)));
}
pairs.push((key.into_owned(), value.into_owned()));
}
Ok(pairs)
}
pub(crate) fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
let mut values = headers
.get_all(header::COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(';'))
.filter_map(|pair| pair.trim().split_once('='))
.filter(|(key, _)| *key == name)
.map(|(_, value)| value);
let first = values.next()?;
values.next().is_none().then_some(first)
}
pub(crate) fn state_cookie(nonce: &str, shop: &ShopDomain) -> String {
format!(
"{STATE_COOKIE}={nonce}.{shop}; Max-Age={STATE_COOKIE_MAX_AGE_SECS}; {COOKIE_ATTRIBUTES}"
)
}
pub(crate) fn clear_state_cookie() -> String {
format!("{STATE_COOKIE}=; Max-Age=0; {COOKIE_ATTRIBUTES}")
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
scope: String,
expires_in: Option<i64>,
refresh_token: Option<String>,
refresh_token_expires_in: Option<i64>,
}
pub(crate) async fn exchange_code(
keeper: &Keeper,
shop: &ShopDomain,
code: &str,
now: i64,
) -> Result<ShopToken> {
let response = post_token_form(
keeper,
shop,
&[
("client_id", keeper.api_key()?),
("client_secret", keeper.secret()),
("code", code),
("expiring", "1"),
],
)
.await?;
if !response.status().is_success() {
return Err(crate::client::api_error(response).await);
}
let mut token = read_token(response, now).await?;
token.installed_at = Some(now);
Ok(token)
}
pub(crate) async fn refresh_token(
keeper: &Keeper,
shop: &ShopDomain,
refresh: &str,
now: i64,
) -> Result<ShopToken> {
let response = post_token_form(
keeper,
shop,
&[
("client_id", keeper.api_key()?),
("client_secret", keeper.secret()),
("grant_type", "refresh_token"),
("refresh_token", refresh),
],
)
.await?;
let status = response.status();
if status.is_success() {
return read_token(response, now).await;
}
if status == StatusCode::UNAUTHORIZED {
return Err(Error::Reauthorize(shop.to_string()));
}
Err(crate::client::api_error(response).await)
}
async fn post_token_form(
keeper: &Keeper,
shop: &ShopDomain,
form: &[(&str, &str)],
) -> Result<reqwest::Response> {
keeper
.http()
.post(format!(
"{}/admin/oauth/access_token",
keeper.admin_origin(shop)
))
.header(header::ACCEPT, "application/json")
.form(form)
.send()
.await
.map_err(Error::Http)
}
async fn read_token(response: reqwest::Response, now: i64) -> Result<ShopToken> {
let bytes = response.bytes().await.map_err(Error::Http)?;
let answer: TokenResponse = serde_json::from_slice(&bytes)
.map_err(|_| Error::UnexpectedResponse("the token answer is not valid".to_owned()))?;
let mut token = ShopToken::non_expiring(answer.access_token, answer.scope);
token.expires_at = answer.expires_in.map(|secs| now.saturating_add(secs));
token.refresh_token = answer.refresh_token;
token.refresh_token_expires_at = answer
.refresh_token_expires_in
.map(|secs| now.saturating_add(secs));
Ok(token)
}
#[get("/auth")]
#[public]
async fn install_route(
State(state): State<AppState>,
RawQuery(query): RawQuery,
) -> AutumnResult<Response> {
install(&state, query.as_deref()).map_err(|err| err.to_autumn_error())
}
fn install(state: &AppState, query: Option<&str>) -> Result<Response> {
let runtime = oauth_runtime(state)?;
let params = parse_query(query)?;
if param(¶ms, "hmac").is_some() {
crate::verify::verify_query(runtime.secrets(), ¶ms)?;
}
let shop = ShopDomain::parse(param(¶ms, "shop").unwrap_or_default())?;
let mut nonce = [0u8; NONCE_BYTES];
state.entropy().fill_bytes(&mut nonce);
let nonce = hex::encode(nonce);
let location = authorize_url(runtime.config(), &shop, &nonce)?;
tracing::info!(shop = %shop, "shopify install started");
redirect(&location, &state_cookie(&nonce, &shop))
}
#[get("/auth/callback")]
#[public]
async fn callback_route(
State(state): State<AppState>,
headers: HeaderMap,
RawQuery(query): RawQuery,
) -> AutumnResult<Response> {
callback(&state, &headers, query.as_deref())
.await
.map_err(|err| {
tracing::warn!(error = %err, "shopify install callback failed");
err.to_autumn_error()
})
}
async fn callback(state: &AppState, headers: &HeaderMap, query: Option<&str>) -> Result<Response> {
let runtime = oauth_runtime(state)?;
let params = parse_query(query)?;
crate::verify::verify_query(runtime.secrets(), ¶ms)?;
let shop = ShopDomain::parse(param(¶ms, "shop").unwrap_or_default())?;
check_state(headers, param(¶ms, "state").unwrap_or_default(), &shop)?;
let code = param(¶ms, "code")
.filter(|code| !code.is_empty())
.ok_or_else(|| Error::InvalidRequest("missing code parameter".to_owned()))?;
let now = state.clock().now().timestamp();
let token = exchange_code(runtime.keeper(), &shop, code, now).await?;
let missing = missing_scopes(&runtime.config().scopes, &token.scopes());
if !missing.is_empty() {
return Err(Error::ScopeMismatch { missing });
}
let scope = token.scope.clone();
runtime.keeper().store_install(&shop, token).await?;
tracing::info!(shop = %shop, "shopify install complete");
if let Some(handler) = runtime.on_install() {
let ctx = InstallContext {
shop: shop.clone(),
scope,
state: state.clone(),
shopify: Shopify::from_runtime(Arc::clone(&runtime), state.clone()),
};
handler(ctx).await.map_err(|err| {
tracing::error!(shop = %shop, error = %err, "shopify install handler failed");
Error::Store(format!("the install handler failed: {err}"))
})?;
}
redirect(
&after_install_location(&runtime.config().after_install_path, &shop),
&clear_state_cookie(),
)
}
fn check_state(headers: &HeaderMap, sent: &str, shop: &ShopDomain) -> Result<()> {
let cookie = cookie_value(headers, STATE_COOKIE).unwrap_or_default();
let (kept_nonce, kept_shop) = cookie.split_once('.').unwrap_or_default();
let nonce_ok = !sent.is_empty() && bool::from(sent.as_bytes().ct_eq(kept_nonce.as_bytes()));
if nonce_ok && kept_shop == shop.as_str() {
Ok(())
} else {
Err(Error::StateMismatch)
}
}
pub(crate) fn after_install_location(path: &str, shop: &ShopDomain) -> String {
let pair = url::form_urlencoded::Serializer::new(String::new())
.append_pair("shop", shop.as_str())
.finish();
let separator = if path.contains('?') { '&' } else { '?' };
format!("{path}{separator}{pair}")
}
fn oauth_runtime(state: &AppState) -> Result<Arc<ShopifyRuntime>> {
let runtime = state
.extension::<ShopifyRuntime>()
.ok_or(Error::MissingRuntime)?;
if runtime.config().oauth_enabled() {
Ok(runtime)
} else {
Err(Error::OAuthDisabled)
}
}
fn param<'a>(params: &'a [(String, String)], key: &str) -> Option<&'a str> {
params
.iter()
.find(|(k, _)| k == key)
.map(|(_, value)| value.as_str())
}
fn redirect(location: &str, cookie: &str) -> Result<Response> {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, location)
.header(header::SET_COOKIE, cookie)
.header(header::CACHE_CONTROL, "no-store")
.body(Body::empty())
.map_err(|err| Error::InvalidRequest(format!("the redirect does not build: {err}")))
}
pub(crate) fn oauth_routes() -> Vec<autumn_web::Route> {
routes![install_route, callback_route]
}
#[cfg(test)]
mod tests;