use std::sync::Arc;
use axum::Json;
use axum::extract::State;
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
use cheers::providers::apple::{
AppleCallbackForm, AppleRedirectProvider, AppleVerified, FirstLoginName,
};
use cheers::providers::oidc_generic::{OidcFlowStore, VerifiedIdToken};
use cheers_core::{DeviceBinding, DeviceId, TokenMinter};
use cheers_server::{
NewUser, ProviderKey, RefreshStore, RevocationWriter, SessionAuthority, UserStore,
};
use subtle::ConstantTimeEq;
use crate::cookie::{CsrfCookieConfig, read_cookie};
use crate::error::RouteError;
use crate::session::SessionBody;
pub struct AppleAuthState<S, M, R, U, W> {
pub provider: Arc<AppleRedirectProvider<S>>,
pub authority: Arc<SessionAuthority<M, R, U, W>>,
pub http: openidconnect::reqwest::Client,
pub cookie: CsrfCookieConfig,
}
impl<S, M, R, U, W> std::fmt::Debug for AppleAuthState<S, M, R, U, W> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppleAuthState")
.field("cookie", &self.cookie)
.finish_non_exhaustive()
}
}
pub fn router<S, M, R, U, W>(state: Arc<AppleAuthState<S, M, R, U, W>>) -> Router
where
S: OidcFlowStore + Send + Sync + 'static,
M: TokenMinter + Send + Sync + 'static,
R: RefreshStore + Send + Sync + 'static,
U: UserStore + Send + Sync + 'static,
W: RevocationWriter + Send + Sync + 'static,
{
Router::new()
.route("/login/apple", get(login::<S, M, R, U, W>))
.route("/callback/apple", post(callback::<S, M, R, U, W>))
.with_state(state)
}
pub async fn login<S, M, R, U, W>(
State(state): State<Arc<AppleAuthState<S, M, R, U, W>>>,
) -> Result<Response, RouteError>
where
S: OidcFlowStore + Send + Sync + 'static,
M: TokenMinter + Send + Sync + 'static,
R: RefreshStore + Send + Sync + 'static,
U: UserStore + Send + Sync + 'static,
W: RevocationWriter + Send + Sync + 'static,
{
let now = now_unix();
let begin = state.provider.begin(now).await?;
let csrf = begin.csrf_state.secret().to_owned();
let mut headers = HeaderMap::new();
let cookie_value = state.cookie.set_cookie(&csrf);
let cookie_header = HeaderValue::from_str(&cookie_value)
.map_err(|e| RouteError::Config(format!("cookie header value: {e}")))?;
headers.insert(header::SET_COOKIE, cookie_header);
let location = HeaderValue::from_str(begin.authorize_url.as_str())
.map_err(|e| RouteError::Config(format!("location header value: {e}")))?;
headers.insert(header::LOCATION, location);
Ok((StatusCode::FOUND, headers).into_response())
}
pub async fn callback<S, M, R, U, W>(
State(state): State<Arc<AppleAuthState<S, M, R, U, W>>>,
headers_in: HeaderMap,
body: String,
) -> Result<Response, RouteError>
where
S: OidcFlowStore + Send + Sync + 'static,
M: TokenMinter + Send + Sync + 'static,
R: RefreshStore + Send + Sync + 'static,
U: UserStore + Send + Sync + 'static,
W: RevocationWriter + Send + Sync + 'static,
{
let form = AppleCallbackForm::parse(&body)?;
let state_param = form.state.secret().to_owned();
let cookie_value = headers_in
.get(header::COOKIE)
.and_then(|h| h.to_str().ok())
.and_then(|raw| read_cookie(raw, &state.cookie.name).map(str::to_owned))
.ok_or(RouteError::MissingCsrfCookie)?;
if cookie_value
.as_bytes()
.ct_eq(state_param.as_bytes())
.unwrap_u8()
!= 1
{
return Err(RouteError::CsrfStateMismatch);
}
let now = now_unix();
let verified: AppleVerified = state
.provider
.finish_form_post(form, &state.http, now)
.await?;
let session = resolve_user_and_establish(
&state.authority,
verified.id_token,
verified.first_login,
now,
)
.await?;
let body_out = SessionBody::from_new_session(session);
let mut headers_out = HeaderMap::new();
let clear = state.cookie.clear_cookie();
if let Ok(v) = HeaderValue::from_str(&clear) {
headers_out.insert(header::SET_COOKIE, v);
}
Ok((StatusCode::OK, headers_out, Json(body_out)).into_response())
}
async fn resolve_user_and_establish<M, R, U, W>(
authority: &Arc<SessionAuthority<M, R, U, W>>,
verified: VerifiedIdToken,
first_login: FirstLoginName,
now: i64,
) -> Result<cheers_server::NewSession, RouteError>
where
M: TokenMinter + Send + Sync,
R: RefreshStore,
U: UserStore,
W: RevocationWriter,
{
let provider_key = ProviderKey::OidcApple;
let users = authority.users();
let user = match users.find_by_provider(&provider_key, &verified.subject).await? {
Some(u) => u,
None => {
let mut new_user = NewUser::new();
if verified.email_verified == Some(true) {
if let Some(e) = verified.email.as_deref() {
new_user = new_user.with_email(e);
}
}
if let Some(n) = first_login.as_str() {
new_user = new_user.with_name(n);
}
let u = users.create(new_user).await?;
users
.link_provider(&u.id, &provider_key, &verified.subject)
.await?;
u
}
};
let device_id = DeviceId::new(generate_device_id());
let session = authority
.establish(
user.id.clone(),
device_id,
DeviceBinding::OidcApple,
now,
)
.await?;
Ok(session)
}
fn generate_device_id() -> String {
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
URL_SAFE_NO_PAD.encode(bytes)
}
fn now_unix() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or_default()
}