Something went wrong. Try again.
An OIDC provider for AT Protocol OAuth. Rust port of apenwarr/atlogin. forked from quilling.dev/ratlogin
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237use super::{AppState, response::*};use std::sync::Arc;
use axum::body::Body;use axum::extract::State;use axum::http::header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, PRAGMA, WWW_AUTHENTICATE};use axum::http::{HeaderMap, StatusCode};use axum::response::{IntoResponse, Response};use jiff::Timestamp;use serde_json::json;use url::{Url, form_urlencoded};
use crate::clients::ClientStoreError;use crate::database::DatabaseError;use crate::flows::{AccessToken, TOKEN_LIFETIME};use crate::keys::KeyError;use crate::oidc::{IdTokenClaims, Scope, TokenError, TokenRequest};
pub(super) fn token_error(error: &TokenError) -> Response { tracing::warn!(%error, "token request rejected"); let status = StatusCode::from_u16(error.status()).unwrap_or(StatusCode::BAD_REQUEST); let body = json!({ "error": error.code(), "error_description": error.to_string(), }); let mut headers = vec![ (CACHE_CONTROL, "no-store".to_owned()), (PRAGMA, "no-cache".to_owned()), (CONTENT_TYPE, "application/json".to_owned()), ]; if status == StatusCode::UNAUTHORIZED { headers.push((WWW_AUTHENTICATE, "Basic realm=\"ratlogin\"".to_owned())); } respond(status, headers, Body::from(body.to_string()))}
pub(super) enum TokenFailure { Protocol(TokenError), Internal(HttpError),}
impl From<TokenError> for TokenFailure { fn from(error: TokenError) -> Self { Self::Protocol(error) }}
impl From<DatabaseError> for TokenFailure { fn from(error: DatabaseError) -> Self { Self::Internal(error.into()) }}
impl From<ClientStoreError> for TokenFailure { fn from(error: ClientStoreError) -> Self { Self::Internal(error.into()) }}
impl From<KeyError> for TokenFailure { fn from(error: KeyError) -> Self { tracing::error!(%error, "cannot sign the ID token"); Self::Internal(HttpError( StatusCode::INTERNAL_SERVER_ERROR, "signing failure".to_owned(), )) }}
impl IntoResponse for TokenFailure { fn into_response(self) -> Response { match self { Self::Protocol(error) => token_error(&error), Self::Internal(error) => error.plain(), } }}
pub(super) async fn token<L>( State(state): State<Arc<AppState<L>>>, headers: HeaderMap, body: String,) -> Response { match redeem(&state, &headers, &body) { Ok(response) => response, Err(error) => error.into_response(), }}
pub(super) fn redeem<L>( state: &AppState<L>, headers: &HeaderMap, body: &str,) -> Result<Response, TokenFailure> { let form: Vec<(String, String)> = form_urlencoded::parse(body.as_bytes()) .into_owned() .collect(); let authorization = headers.get(AUTHORIZATION).and_then(|v| v.to_str().ok()); let request = TokenRequest::parse(authorization, &form)?; let client = state .clients .get(&request.client_id)? .filter(|client| client.authenticate(&request.client_secret)) .ok_or(TokenError::InvalidClient)?; if request.grant_type != "authorization_code" { return Err(TokenError::UnsupportedGrant.into()); }
let now = Timestamp::now(); let code = state .flows .consume_code(&request.code, now)? .ok_or(TokenError::InvalidCode)?; if &code.client_id != client.id() { return Err(TokenError::ClientMismatch.into()); } if Url::parse(&request.redirect_uri).ok().as_ref() != Some(&code.redirect_uri) { return Err(TokenError::RedirectMismatch.into()); } if let Some(challenge) = &code.challenge { let verifier = request.code_verifier.as_deref().unwrap_or_default(); if !challenge.verify(verifier) { return Err(TokenError::InvalidVerifier.into()); } } else if request.code_verifier.is_some() { return Err(TokenError::UnexpectedVerifier.into()); }
let claims = IdTokenClaims::new( state.config.issuer(), client.id(), &code.identity, code.nonce, code.auth_time, now, TOKEN_LIFETIME, ) .with_email(code.email.clone()); let id_token = state.keys.sign(&claims, client.signing_algorithm())?; tracing::debug!( client = client.id().as_str(), did = code.identity.did.as_str(), "tokens issued" ); let scope = code .scopes .iter() .map(|scope| scope.as_str()) .collect::<Vec<_>>() .join(" "); let access_token = state.flows.issue_token(AccessToken { identity: code.identity, expires_at: now + TOKEN_LIFETIME, email: code.email, scopes: code.scopes, })?; let body = json!({ "access_token": access_token, "token_type": "Bearer", "expires_in": TOKEN_LIFETIME.as_secs(), "id_token": id_token, "scope": scope, }); Ok(( [(CACHE_CONTROL, "no-store"), (PRAGMA, "no-cache")], axum::Json(body), ) .into_response())}
pub(super) fn bearer_challenge(error: Option<&str>, message: &str) -> Response { let challenge = match error { Some(error) => format!("Bearer realm=\"ratlogin\", error=\"{error}\""), None => "Bearer realm=\"ratlogin\"".to_owned(), }; respond( StatusCode::UNAUTHORIZED, vec![ (CONTENT_TYPE, "text/plain; charset=utf-8".to_owned()), (CACHE_CONTROL, "no-store".to_owned()), (WWW_AUTHENTICATE, challenge), ], Body::from(message.to_owned()), )}
pub(super) async fn userinfo<L>( State(state): State<Arc<AppState<L>>>, headers: HeaderMap,) -> Response { let bearer = headers .get(AUTHORIZATION) .and_then(|value| value.to_str().ok()) .and_then(|value| value.split_once(' ')) .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer")) .map(|(_, token)| token.trim()); let Some(bearer) = bearer else { return bearer_challenge(None, "invalid authorization header"); }; let token = match state.flows.token(bearer, Timestamp::now()) { Ok(Some(token)) => token, Ok(None) => { return bearer_challenge(Some("invalid_token"), "invalid or expired token"); } Err(error) => return HttpError::from(error).plain(), }; let mut body = json!({ "sub": token.identity.did.as_str() }); if token.scopes.contains(&Scope::Profile) { body["name"] = json!(token.identity.handle.as_str()); body["preferred_username"] = json!(token.identity.handle.as_str()); } if token.scopes.contains(&Scope::Groups) { body["groups"] = json!([token.identity.did.as_str()]); } if let Some(email) = token.email { body["email"] = json!(email.address); body["email_verified"] = json!(email.verified); } ([(CACHE_CONTROL, "no-store")], axum::Json(body)).into_response()}
#[cfg(test)]mod tests { use super::*;
#[tokio::test] async fn a_signing_failure_is_a_plain_500_that_does_not_describe_the_key() { let failure = TokenFailure::from(KeyError::Client("the key is unusable".to_owned())); let response = failure.into_response(); assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); let body = axum::body::to_bytes(response.into_body(), 1024) .await .expect("body"); assert_eq!(body, "signing failure"); }}