use std::fmt;
use std::fs::File;
use std::io::Read;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use reqwest::Client as Http;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use url::Url;
use crate::{Error, KeySet, TokenResponse, VerifiedPass, read_token};
const DISCOVERY: &str = ".well-known/openid-configuration";
const SCOPE: &str = "openid";
pub struct Secret(String);
impl Secret {
pub fn new(text: String) -> Result<Self, Error> {
if text.is_empty() || text.chars().any(char::is_control) {
return Err(Error::Invalid("client secret is empty or not one line"));
}
Ok(Self(text))
}
}
impl fmt::Debug for Secret {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("Secret(<redacted>)")
}
}
#[derive(Debug, Clone)]
pub struct Started {
pub url: String,
pub state: String,
pub verifier: String,
pub browser: String,
}
#[derive(Debug, Clone)]
pub struct SignedIn {
pub token: String,
pub holder: String,
pub expires_at: u64,
pub refresh: Option<String>,
}
#[derive(Debug, Clone)]
struct Endpoints {
authorize: Url,
token: Url,
keys: Url,
}
#[derive(Deserialize)]
struct Discovery {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
jwks_uri: String,
}
#[derive(Debug)]
pub struct SignIn {
http: Http,
issuer: String,
audience: String,
client_id: String,
secret: Secret,
redirect: Url,
endpoints: Endpoints,
}
impl SignIn {
pub async fn connect(
http: Http,
issuer: &Url,
audience: &str,
client_id: &str,
secret: Secret,
redirect: Url,
) -> Result<Self, Error> {
if audience.is_empty() || client_id.is_empty() || client_id.chars().any(char::is_control) {
return Err(Error::Invalid("audience or client id is missing"));
}
if !matches!(redirect.scheme(), "http" | "https") || redirect.host_str().is_none() {
return Err(Error::Invalid("redirect is not an HTTP address"));
}
let base = crate::Client::new(http.clone(), issuer.clone())?;
let response = http
.get(base.endpoint(DISCOVERY)?)
.send()
.await
.map_err(|error| Error::Transport(Box::new(error)))?;
if !response.status().is_success() {
return Err(Error::CannotAsk("discovery returned a refusal status"));
}
let discovery: Discovery = response
.json()
.await
.map_err(|error| Error::Transport(Box::new(error)))?;
if discovery.issuer.trim_end_matches('/') != issuer.as_str().trim_end_matches('/') {
return Err(Error::WrongIssuer);
}
let endpoints = Endpoints {
authorize: http_address(&discovery.authorization_endpoint)?,
token: http_address(&discovery.token_endpoint)?,
keys: http_address(&discovery.jwks_uri)?,
};
Ok(Self {
http,
issuer: discovery.issuer,
audience: audience.to_owned(),
client_id: client_id.to_owned(),
secret,
redirect,
endpoints,
})
}
pub fn start(&self) -> Result<Started, Error> {
let state = random()?;
let verifier = random()?;
let browser = random()?;
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let mut url = self.endpoints.authorize.clone();
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &self.client_id)
.append_pair("redirect_uri", self.redirect.as_str())
.append_pair("scope", SCOPE)
.append_pair("state", &state)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256");
Ok(Started {
url: url.into(),
state,
verifier,
browser,
})
}
pub async fn finish(
&self,
code: &str,
state: &str,
browser: &str,
started: &Started,
now: u64,
) -> Result<SignedIn, Error> {
if !same(state, &started.state) || !same(browser, &started.browser) {
return Err(Error::TokenRefused {
name: "sign_in_state_refused".to_owned(),
reason: "the sign-in's state is not this browser's".to_owned(),
});
}
if code.is_empty() {
return Err(Error::Invalid("authorization code is missing"));
}
let answer = self
.token(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", self.redirect.as_str()),
("code_verifier", &started.verifier),
])
.await?;
self.verified(answer, now).await
}
pub async fn refresh(&self, refresh: &str, now: u64) -> Result<SignedIn, Error> {
if refresh.is_empty() {
return Err(Error::Invalid("refresh token is missing"));
}
let answer = self
.token(&[("grant_type", "refresh_token"), ("refresh_token", refresh)])
.await?;
self.verified(answer, now).await
}
async fn token(&self, form: &[(&str, &str)]) -> Result<TokenResponse, Error> {
let mut fields = vec![
("client_id", self.client_id.as_str()),
("client_secret", self.secret.0.as_str()),
];
fields.extend_from_slice(form);
let response = self
.http
.post(self.endpoints.token.clone())
.form(&fields)
.send()
.await
.map_err(|error| Error::Transport(Box::new(error)))?;
read_token(response, &fields).await
}
async fn verified(&self, answer: TokenResponse, now: u64) -> Result<SignedIn, Error> {
let keys = self.keys().await?;
let pass = VerifiedPass::verify(
&answer.access_token,
&keys,
&self.issuer,
&self.audience,
now,
)?;
let claims = pass.claims();
Ok(SignedIn {
holder: claims.holder.id.clone(),
expires_at: claims.exp,
token: answer.access_token,
refresh: answer.refresh_token,
})
}
async fn keys(&self) -> Result<KeySet, Error> {
let response = self
.http
.get(self.endpoints.keys.clone())
.send()
.await
.map_err(|error| Error::Transport(Box::new(error)))?;
let text = response
.error_for_status()
.map_err(|error| Error::Transport(Box::new(error)))?
.text()
.await
.map_err(|error| Error::Transport(Box::new(error)))?;
KeySet::from_json(&text)
}
}
pub fn session_cookie(name: &str, id: &str, max_age: u64) -> Result<String, Error> {
cookie(name, id, "Strict", max_age)
}
pub fn state_cookie(name: &str, browser: &str, max_age: u64) -> Result<String, Error> {
cookie(name, browser, "Lax", max_age)
}
fn cookie(name: &str, value: &str, same_site: &str, max_age: u64) -> Result<String, Error> {
let token = |text: &str| {
!text.is_empty()
&& text
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
};
if !token(name) || !token(value) {
return Err(Error::Invalid("cookie name or value is not a token"));
}
Ok(format!(
"{name}={value}; HttpOnly; Secure; SameSite={same_site}; Path=/; Max-Age={max_age}"
))
}
fn http_address(text: &str) -> Result<Url, Error> {
let url = Url::parse(text)?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return Err(Error::Invalid(
"discovery names an address that is not HTTP",
));
}
Ok(url)
}
fn random() -> Result<String, Error> {
let mut bytes = [0_u8; 32];
let read = File::open("/dev/urandom").and_then(|mut source| source.read_exact(&mut bytes));
if read.is_err() {
return Err(Error::Invalid("the random source cannot be read"));
}
Ok(URL_SAFE_NO_PAD.encode(bytes))
}
fn same(left: &str, right: &str) -> bool {
left.len() == right.len()
&& left
.bytes()
.zip(right.bytes())
.fold(0_u8, |differs, (a, b)| differs | (a ^ b))
== 0
}