fabro/lib/apps/fabro-server/src/web_auth.rs
Bryan Helmkamp be6dd7df97
Serve CLI auth sessions from SQLite
Points the session listing, revocation, refresh, and logout paths at
`AuthSessionStore`. Listing a user's sessions and revoking one stop scanning
the whole refresh-token keyspace; both are now indexed queries.

Fixes two timestamps that were wrong by construction. `created_at` was fed
from the newest token's `issued_at`, so a session's reported start drifted
forward on every refresh, and `last_seen_at` read a field only ever set at
issue -- so both rendered the same value. They now come from the session row,
where they mean what they say.

Deletes `next_refresh_row`, which had to fabricate an identity of
("https://github.com", "0") and empty profile strings for the no-existing-row
case, because a token was required to carry chain-level fields. Rotation now
takes just the new hash, expiry, and user agent. That also removes the
pre-read it existed to feed, closing the window between that read and the
one `consume_and_rotate` did itself.

Opening the store per request is gone with it: five handlers each had a
500-response arm for "could not open the store", which field access on
AppStores cannot fail.

Drops the replay-revocation cache. Its only effect was reporting `revoked`
rather than `expired` for the third and later presentations in a concurrent
burst, and `fabro-client` (client.rs:508-513) matches both codes in one arm
and treats them identically. Replay detection itself is unaffected: it is
`Reused` into `delete_session`, which lives in the database. The concurrency
test now accepts either code, since losers that arrive after the winner's
revocation find the row already cascaded away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:00:15 -04:00

1777 lines
60 KiB
Rust

use std::sync::Arc;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::{delete, get, post};
use axum::{Extension, Json, Router};
use chrono::{DateTime, Utc};
use cookie::time::Duration;
use cookie::{Cookie, CookieJar, Key, SameSite};
use fabro_redact::DisplaySafeUrl;
use fabro_static::EnvVars;
use fabro_types::settings::ServerAuthMethod;
use fabro_types::{AuthMethod, IdpIdentity};
use fabro_util::dev_token::validate_dev_token_format;
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{debug, error, info, warn};
use crate::auth::{GithubEndpoints, browser_shell};
use crate::error::ApiError;
use crate::jwt_auth::{AuthMode, auth_method_name, dev_token_matches};
use crate::principal_middleware::{
RequestAuth, RequestAuthContext, UserProfile, require_authenticated_user,
};
use crate::server::AppState;
pub const SESSION_COOKIE_NAME: &str = "__fabro_session";
const OAUTH_STATE_COOKIE_NAME: &str = "fabro_oauth_state";
const OAUTH_STATE_TTL_MINUTES: i64 = 30;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SessionCookie {
pub v: u8,
pub login: String,
pub auth_method: AuthMethod,
pub identity: IdpIdentity,
pub name: String,
pub email: String,
pub avatar_url: String,
pub user_url: String,
pub iat: i64,
pub exp: i64,
}
#[derive(Deserialize)]
struct OAuthCallbackParams {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
#[derive(Deserialize)]
struct LoginGithubParams {
return_to: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct OAuthStateCookie {
state: String,
exp: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
return_to: Option<String>,
}
#[derive(Deserialize)]
struct DevTokenLoginRequest {
token: String,
}
#[derive(Serialize)]
struct AuthConfigResponse {
methods: Vec<String>,
}
#[derive(Serialize)]
struct AuthMeResponse {
user: SessionUser,
provider: String,
#[serde(rename = "demoMode")]
demo_mode: bool,
}
#[derive(Serialize)]
struct AuthSessionsResponse {
sessions: Vec<AuthSession>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AuthSession {
id: String,
kind: &'static str,
current: bool,
provider: String,
login: String,
label: String,
user_agent: Option<String>,
created_at: DateTime<Utc>,
last_seen_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
revocable: bool,
}
#[derive(Serialize)]
struct SessionUser {
login: String,
name: String,
email: String,
#[serde(rename = "idpIssuer", skip_serializing_if = "Option::is_none")]
idp_issuer: Option<String>,
#[serde(rename = "idpSubject", skip_serializing_if = "Option::is_none")]
idp_subject: Option<String>,
#[serde(rename = "avatarUrl")]
avatar_url: String,
#[serde(rename = "userUrl")]
user_url: String,
}
#[derive(Deserialize)]
struct GitHubTokenResponse {
access_token: String,
}
#[derive(Deserialize)]
struct GitHubUser {
id: i64,
login: String,
name: Option<String>,
avatar_url: String,
}
#[derive(Deserialize)]
struct GitHubEmail {
email: String,
primary: bool,
verified: bool,
}
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/login/dev-token", post(login_dev_token))
.route("/login/github", get(login_github))
.route("/callback/github", get(callback_github))
.route("/logout", post(logout))
}
pub fn api_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/auth/config", get(auth_config))
.route("/auth/me", get(auth_me))
.route("/auth/sessions", get(list_auth_sessions))
.route("/auth/sessions/{id}", delete(delete_auth_session))
}
pub fn parse_cookie_header(headers: &HeaderMap) -> CookieJar {
let mut jar = CookieJar::new();
if let Some(raw) = headers
.get(header::COOKIE)
.and_then(|value| value.to_str().ok())
{
for part in raw.split(';') {
if let Ok(cookie) = Cookie::parse_encoded(part.trim().to_string()) {
jar.add_original(cookie.into_owned());
}
}
}
jar
}
pub fn read_private_session(headers: &HeaderMap, key: &Key) -> Option<SessionCookie> {
let jar = parse_cookie_header(headers);
let cookie = jar.private(key).get(SESSION_COOKIE_NAME)?;
let session: SessionCookie = serde_json::from_str(cookie.value()).ok()?;
if session.v != 2 || session.exp <= chrono::Utc::now().timestamp() {
return None;
}
Some(session)
}
pub(crate) fn session_cookie_present(headers: &HeaderMap) -> bool {
parse_cookie_header(headers)
.get(SESSION_COOKIE_NAME)
.is_some()
}
pub(crate) fn auth_context_from_session(session: &SessionCookie) -> RequestAuthContext {
RequestAuthContext::authenticated_user(
session.identity.clone(),
session.login.clone(),
session.auth_method,
UserProfile {
name: session.name.clone(),
email: session.email.clone(),
avatar_url: session.avatar_url.clone(),
user_url: session.user_url.clone(),
},
)
}
fn read_private_oauth_state(headers: &HeaderMap, key: &Key) -> Option<OAuthStateCookie> {
let jar = parse_cookie_header(headers);
jar.private(key)
.get(OAUTH_STATE_COOKIE_NAME)
.and_then(|cookie| serde_json::from_str(cookie.value()).ok())
.filter(|state: &OAuthStateCookie| state.exp > chrono::Utc::now().timestamp())
}
fn add_oauth_state_cookie(jar: &mut CookieJar, key: &Key, state: &OAuthStateCookie, secure: bool) {
jar.private_mut(key).add(
Cookie::build((
OAUTH_STATE_COOKIE_NAME,
serde_json::to_string(&state).unwrap_or_default(),
))
.path("/auth")
.http_only(true)
.same_site(SameSite::Lax)
.secure(secure)
.max_age(Duration::minutes(OAUTH_STATE_TTL_MINUTES))
.build(),
);
}
fn remove_oauth_state_cookie(jar: &mut CookieJar, key: &Key, secure: bool) {
jar.private_mut(key).remove(
Cookie::build((OAUTH_STATE_COOKIE_NAME, ""))
.path("/auth")
.http_only(true)
.secure(secure)
.build(),
);
}
fn append_jar_delta(headers: &mut HeaderMap, jar: &CookieJar) {
for cookie in jar.delta() {
if let Ok(value) = HeaderValue::from_str(&cookie.encoded().to_string()) {
headers.append(header::SET_COOKIE, value);
}
}
}
fn json_response(status: StatusCode, body: serde_json::Value) -> Response {
(status, Json(body)).into_response()
}
fn static_error_page(message: &'static str) -> Response {
browser_shell(
StatusCode::BAD_REQUEST,
"Sign-in failed",
&format!(
r#"
<div>
<p class="eyebrow error">Sign-in failed</p>
<h1>We couldn't complete sign-in</h1>
</div>
<p>{message}</p>
<a class="button" href="/login">Back to sign in</a>
"#
),
)
}
fn sanitize_return_to(return_to: Option<String>) -> Option<String> {
match return_to {
Some(path) if matches!(path.as_str(), "/auth/cli/start" | "/auth/cli/resume") => Some(path),
Some(_) => {
warn!("Ignoring unsupported OAuth return_to path");
None
}
None => None,
}
}
fn oauth_error_redirect(path: &str, state: &str, error: &str, error_description: &str) -> String {
const QUERY_VALUE_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC.remove(b'_').remove(b'-');
let error = utf8_percent_encode(error, QUERY_VALUE_ENCODE_SET);
let error_description = utf8_percent_encode(error_description, QUERY_VALUE_ENCODE_SET);
let state = utf8_percent_encode(state, QUERY_VALUE_ENCODE_SET);
format!("{path}?error={error}&error_description={error_description}&state={state}")
}
fn callback_error_redirect(
return_to: Option<&str>,
fallback: &'static str,
state: &str,
error: &'static str,
error_description: &'static str,
) -> Response {
match return_to {
Some(path) => Redirect::to(&oauth_error_redirect(path, state, error, error_description))
.into_response(),
None => Redirect::to(fallback).into_response(),
}
}
fn auth_methods_from_mode(auth_mode: &AuthMode) -> Vec<String> {
let AuthMode::Enabled(config) = auth_mode;
config
.methods
.iter()
.map(|method| auth_method_name(*method).to_string())
.collect()
}
fn auth_method_enabled(auth_mode: &AuthMode, method: ServerAuthMethod) -> bool {
let AuthMode::Enabled(config) = auth_mode;
config.methods.contains(&method)
}
fn dev_token_from_mode(auth_mode: &AuthMode) -> Option<String> {
let AuthMode::Enabled(config) = auth_mode;
config.dev_token.clone()
}
fn session_provider(auth_method: AuthMethod) -> &'static str {
match auth_method {
AuthMethod::DevToken => "dev-token",
AuthMethod::Github => "github",
}
}
fn session_cookie_secure(state: &AppState) -> bool {
state
.canonical_origin()
.is_ok_and(|web_url| web_url.starts_with("https://"))
}
fn redacted_url_for_log(url: &str) -> String {
DisplaySafeUrl::parse(url)
.map_or_else(|_| "<invalid url>".to_string(), |url| url.redacted_string())
}
fn session_timestamp(timestamp: i64) -> Result<DateTime<Utc>, ApiError> {
DateTime::from_timestamp(timestamp, 0).ok_or_else(|| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"Authenticated session timestamp is out of range.",
)
})
}
async fn login_dev_token(
State(state): State<Arc<AppState>>,
Extension(auth_mode): Extension<AuthMode>,
RequestAuth(auth_slot): RequestAuth,
payload: Result<Json<DevTokenLoginRequest>, JsonRejection>,
) -> Response {
let Ok(Json(payload)) = payload else {
auth_slot.replace(RequestAuthContext::invalid());
return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"}));
};
let expected = dev_token_from_mode(&auth_mode);
let Some(expected) = expected else {
auth_slot.replace(RequestAuthContext::invalid());
return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"}));
};
if !validate_dev_token_format(&payload.token) || !dev_token_matches(&payload.token, &expected) {
auth_slot.replace(RequestAuthContext::invalid());
return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"}));
}
let Some(session_key) = state.session_key() else {
return json_response(
StatusCode::CONFLICT,
json!({"error": "SESSION_SECRET is not configured"}),
);
};
let now = chrono::Utc::now();
let session = SessionCookie {
v: 2,
login: "dev".to_string(),
auth_method: AuthMethod::DevToken,
identity: IdpIdentity::new("fabro:dev", "dev").expect("non-empty dev identity"),
name: "Development User".to_string(),
email: "dev@localhost".to_string(),
avatar_url: "/images/logo.svg".to_string(),
user_url: String::new(),
iat: now.timestamp(),
exp: (now + chrono::Duration::days(30)).timestamp(),
};
auth_slot.replace(auth_context_from_session(&session));
let mut jar = CookieJar::new();
jar.private_mut(&session_key).add(
Cookie::build((
SESSION_COOKIE_NAME,
serde_json::to_string(&session).unwrap_or_default(),
))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.secure(session_cookie_secure(state.as_ref()))
.max_age(Duration::days(30))
.build(),
);
let mut response = Json(json!({ "ok": true })).into_response();
append_jar_delta(response.headers_mut(), &jar);
response
}
async fn auth_config(Extension(auth_mode): Extension<AuthMode>) -> Response {
Json(AuthConfigResponse {
methods: auth_methods_from_mode(&auth_mode),
})
.into_response()
}
#[expect(
clippy::disallowed_types,
reason = "GitHub OAuth authorize URL is raw browser redirect transit; logs use DisplaySafeUrl."
)]
async fn login_github(
State(state): State<Arc<AppState>>,
Extension(auth_mode): Extension<AuthMode>,
Extension(github_endpoints): Extension<Arc<GithubEndpoints>>,
Query(params): Query<LoginGithubParams>,
) -> Response {
if !auth_method_enabled(&auth_mode, ServerAuthMethod::Github) {
return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"}));
}
let Some(session_key) = state.session_key() else {
warn!("OAuth login failed: SESSION_SECRET not configured");
return json_response(
StatusCode::CONFLICT,
json!({"error": "SESSION_SECRET is not configured"}),
);
};
let settings = state.server_settings();
let Some(client_id) = settings.server.integrations.github.client_id.as_ref() else {
warn!("OAuth login failed: client_id not configured");
return json_response(
StatusCode::CONFLICT,
json!({"error": "GitHub App client_id is not configured"}),
);
};
let client_id = client_id.clone();
let web_url = match state.canonical_origin() {
Ok(web_url) => web_url,
Err(err) => {
warn!(error = %err, "OAuth login failed: server.web.url is invalid");
return json_response(StatusCode::CONFLICT, json!({"error": err}));
}
};
let state_token = format!("fabro-{}", ulid::Ulid::new());
let redirect_uri = format!("{web_url}/auth/callback/github");
let authorize_url = fabro_http::Url::parse_with_params(
github_endpoints
.oauth_base
.join("login/oauth/authorize")
.expect("GitHub authorize URL should be valid")
.as_str(),
&[
("client_id", client_id.as_str()),
("redirect_uri", redirect_uri.as_str()),
("scope", "read:user user:email"),
("state", state_token.as_str()),
],
)
.expect("GitHub authorize URL should be valid");
let safe_redirect_uri = redacted_url_for_log(&redirect_uri);
debug!(redirect_uri = %safe_redirect_uri, "OAuth login redirecting to GitHub");
let mut jar = CookieJar::new();
add_oauth_state_cookie(
&mut jar,
&session_key,
&OAuthStateCookie {
state: state_token,
exp: (chrono::Utc::now() + chrono::Duration::minutes(OAUTH_STATE_TTL_MINUTES))
.timestamp(),
return_to: sanitize_return_to(params.return_to),
},
session_cookie_secure(state.as_ref()),
);
let mut response = Redirect::to(authorize_url.as_str()).into_response();
append_jar_delta(response.headers_mut(), &jar);
response
}
async fn callback_github(
State(state): State<Arc<AppState>>,
Extension(auth_mode): Extension<AuthMode>,
Extension(github_endpoints): Extension<Arc<GithubEndpoints>>,
RequestAuth(auth_slot): RequestAuth,
Query(params): Query<OAuthCallbackParams>,
headers: HeaderMap,
) -> Response {
auth_slot.replace(RequestAuthContext::invalid());
if !auth_method_enabled(&auth_mode, ServerAuthMethod::Github) {
return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"}));
}
let Some(session_key) = state.session_key() else {
error!("OAuth callback failed: SESSION_SECRET not configured");
return json_response(
StatusCode::CONFLICT,
json!({"error": "SESSION_SECRET is not configured"}),
);
};
let settings = state.server_settings();
let stored_state = read_private_oauth_state(&headers, &session_key);
let Some(stored_state) = stored_state else {
warn!("OAuth callback failed: state cookie missing or invalid");
return static_error_page(
"Your login took too long or was tampered with. Please start again.",
);
};
if stored_state.state.as_str() != params.state.as_deref().unwrap_or_default() {
warn!("OAuth callback failed: state mismatch");
return static_error_page(
"Your login took too long or was tampered with. Please start again.",
);
}
if let Some(error_code) = params.error.as_deref() {
let (error, error_description, fallback) = match error_code {
"unauthorized" => (
"unauthorized",
"Login not permitted",
"/login?error=unauthorized",
),
"access_denied" => (
"access_denied",
"Authorization denied",
"/login?error=access_denied",
),
_ => (
"server_error",
"Could not complete GitHub sign-in",
"/login?error=server_error",
),
};
let mut jar = CookieJar::new();
remove_oauth_state_cookie(
&mut jar,
&session_key,
session_cookie_secure(state.as_ref()),
);
let mut response = callback_error_redirect(
stored_state.return_to.as_deref(),
fallback,
&stored_state.state,
error,
error_description,
);
append_jar_delta(response.headers_mut(), &jar);
return response;
}
let Some(code) = params.code.as_deref() else {
warn!("OAuth callback failed: code missing from successful callback");
return callback_error_redirect(
stored_state.return_to.as_deref(),
"/login?error=server_error",
&stored_state.state,
"server_error",
"Could not complete GitHub sign-in",
);
};
let state_param = params
.state
.as_deref()
.expect("validated oauth callback state should exist");
let Some(client_id) = settings.server.integrations.github.client_id.as_ref() else {
error!("OAuth callback failed: client_id not configured");
return json_response(
StatusCode::CONFLICT,
json!({"error": "GitHub App client_id is not configured"}),
);
};
let client_id = client_id.clone();
let client_secret = match state.vault_secret(EnvVars::GITHUB_APP_CLIENT_SECRET).await {
Ok(value) => value,
Err(err) => {
error!(error = ?err, "OAuth callback failed: secret store unavailable");
return json_response(
StatusCode::INTERNAL_SERVER_ERROR,
json!({"error": "secret store operation failed"}),
);
}
};
let Some(client_secret) = client_secret else {
error!("OAuth callback failed: GITHUB_APP_CLIENT_SECRET not configured");
return json_response(
StatusCode::CONFLICT,
json!({"error": "GITHUB_APP_CLIENT_SECRET is not configured"}),
);
};
let web_url = match state.canonical_origin() {
Ok(web_url) => web_url,
Err(err) => {
error!(error = %err, "OAuth callback failed: server.web.url is invalid");
return json_response(StatusCode::CONFLICT, json!({"error": err}));
}
};
let http = match fabro_http::http_client() {
Ok(http) => http,
Err(err) => {
error!(error = %err, "OAuth callback failed: could not build GitHub HTTP client");
return json_response(
StatusCode::SERVICE_UNAVAILABLE,
json!({"error": format!("Failed to build GitHub HTTP client: {err}")}),
);
}
};
let token = match http
.post(
github_endpoints
.oauth_base
.join("login/oauth/access_token")
.expect("GitHub token URL should be valid"),
)
.header(header::ACCEPT, "application/json")
.form(&[
("client_id", client_id.as_str()),
("client_secret", client_secret.as_str()),
("code", code),
(
"redirect_uri",
format!("{web_url}/auth/callback/github").as_str(),
),
("state", state_param),
])
.send()
.await
{
Ok(response) if response.status().is_success() => {
match response.json::<GitHubTokenResponse>().await {
Ok(token) => token.access_token,
Err(err) => {
error!(error = %err, "OAuth callback failed: could not parse GitHub token response");
return callback_error_redirect(
stored_state.return_to.as_deref(),
"/login?error=server_error",
&stored_state.state,
"server_error",
"Could not complete GitHub sign-in",
);
}
}
}
Ok(response) => {
let status = response.status();
error!(status = %status, "OAuth callback failed: GitHub token exchange returned error");
return callback_error_redirect(
stored_state.return_to.as_deref(),
"/login?error=server_error",
&stored_state.state,
"server_error",
"Could not complete GitHub sign-in",
);
}
Err(err) => {
error!(error = %err, "OAuth callback failed: GitHub token exchange request failed");
return callback_error_redirect(
stored_state.return_to.as_deref(),
"/login?error=server_error",
&stored_state.state,
"server_error",
"Could not complete GitHub sign-in",
);
}
};
let auth_header = format!("Bearer {token}");
let profile = match http
.get(
github_endpoints
.api_base
.join("user")
.expect("GitHub user URL should be valid"),
)
.header(header::AUTHORIZATION, &auth_header)
.header(header::USER_AGENT, "fabro-server")
.send()
.await
{
Ok(response) if response.status().is_success() => match response.json::<GitHubUser>().await
{
Ok(profile) => profile,
Err(err) => {
error!(error = %err, "OAuth callback failed: could not parse GitHub user response");
return callback_error_redirect(
stored_state.return_to.as_deref(),
"/login?error=server_error",
&stored_state.state,
"server_error",
"Could not complete GitHub sign-in",
);
}
},
Ok(response) => {
let status = response.status();
error!(status = %status, "OAuth callback failed: GitHub user lookup returned error");
return callback_error_redirect(
stored_state.return_to.as_deref(),
"/login?error=server_error",
&stored_state.state,
"server_error",
"Could not complete GitHub sign-in",
);
}
Err(err) => {
error!(error = %err, "OAuth callback failed: GitHub user lookup request failed");
return callback_error_redirect(
stored_state.return_to.as_deref(),
"/login?error=server_error",
&stored_state.state,
"server_error",
"Could not complete GitHub sign-in",
);
}
};
let emails = match http
.get(
github_endpoints
.api_base
.join("user/emails")
.expect("GitHub emails URL should be valid"),
)
.header(header::AUTHORIZATION, &auth_header)
.header(header::USER_AGENT, "fabro-server")
.send()
.await
{
Ok(response) if response.status().is_success() => response
.json::<Vec<GitHubEmail>>()
.await
.unwrap_or_default(),
_ => Vec::new(),
};
let allowed_usernames = settings.server.auth.github.allowed_usernames.clone();
if !allowed_usernames.iter().any(|user| user == &profile.login) {
warn!(login = %profile.login, "OAuth callback denied: username not in allowlist");
return callback_error_redirect(
stored_state.return_to.as_deref(),
"/login?error=unauthorized",
&stored_state.state,
"unauthorized",
"Login not permitted",
);
}
let primary_email = emails
.iter()
.find(|email| email.primary && email.verified)
.map(|email| email.email.clone())
.unwrap_or_default();
let now = chrono::Utc::now();
let session = SessionCookie {
v: 2,
login: profile.login.clone(),
auth_method: AuthMethod::Github,
identity: IdpIdentity::new("https://github.com", profile.id.to_string())
.expect("GitHub profile id should produce a valid identity"),
name: profile.name.unwrap_or_else(|| profile.login.clone()),
email: primary_email,
avatar_url: profile.avatar_url,
user_url: format!("https://github.com/{}", profile.login),
iat: now.timestamp(),
exp: (now + chrono::Duration::days(30)).timestamp(),
};
auth_slot.replace(auth_context_from_session(&session));
info!(login = %session.login, "OAuth login succeeded");
let mut jar = CookieJar::new();
jar.private_mut(&session_key).add(
Cookie::build((
SESSION_COOKIE_NAME,
serde_json::to_string(&session).unwrap_or_default(),
))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.secure(session_cookie_secure(state.as_ref()))
.max_age(Duration::days(30))
.build(),
);
remove_oauth_state_cookie(
&mut jar,
&session_key,
session_cookie_secure(state.as_ref()),
);
let redirect_target = stored_state
.return_to
.as_deref()
.unwrap_or("/runs")
.to_string();
let mut response = Redirect::to(&redirect_target).into_response();
append_jar_delta(response.headers_mut(), &jar);
response
}
async fn logout(
State(state): State<Arc<AppState>>,
RequestAuth(auth_slot): RequestAuth,
headers: HeaderMap,
) -> Response {
info!("User logged out");
let mut jar = CookieJar::new();
if let Some(key) = state.session_key() {
if let Some(session) = read_private_session(&headers, &key) {
auth_slot.replace(auth_context_from_session(&session));
} else if session_cookie_present(&headers) {
auth_slot.replace(RequestAuthContext::invalid());
}
jar.private_mut(&key).remove(
Cookie::build((SESSION_COOKIE_NAME, ""))
.path("/")
.http_only(true)
.secure(session_cookie_secure(state.as_ref()))
.build(),
);
}
let mut response = Redirect::to("/login").into_response();
append_jar_delta(response.headers_mut(), &jar);
response
}
async fn auth_me(RequestAuth(auth_slot): RequestAuth, headers: HeaderMap) -> Response {
let authenticated = match require_authenticated_user(&auth_slot) {
Ok(authenticated) => authenticated,
Err(err) => {
warn!(
has_cookie = headers.get(header::COOKIE).is_some(),
"Auth check failed: authenticated subject missing"
);
return err.into_response();
}
};
let demo_mode = parse_cookie_header(&headers)
.get("fabro-demo")
.is_some_and(|cookie| cookie.value() == "1");
Json(AuthMeResponse {
user: SessionUser {
login: authenticated.principal.login.clone(),
name: authenticated.profile.name,
email: authenticated.profile.email,
idp_issuer: Some(authenticated.principal.identity.issuer().to_string()),
idp_subject: Some(authenticated.principal.identity.subject().to_string()),
avatar_url: authenticated.profile.avatar_url,
user_url: authenticated.profile.user_url,
},
provider: session_provider(authenticated.principal.auth_method).to_string(),
demo_mode,
})
.into_response()
}
async fn list_auth_sessions(
State(state): State<Arc<AppState>>,
RequestAuth(auth_slot): RequestAuth,
headers: HeaderMap,
) -> Response {
let authenticated = match require_authenticated_user(&auth_slot) {
Ok(authenticated) => authenticated,
Err(err) => return err.into_response(),
};
let now = Utc::now();
let mut sessions = Vec::new();
if let Some(key) = state.session_key() {
if let Some(session) = read_private_session(&headers, &key) {
let issued_at = match session_timestamp(session.iat) {
Ok(timestamp) => timestamp,
Err(err) => return err.into_response(),
};
let expires_at = match session_timestamp(session.exp) {
Ok(timestamp) => timestamp,
Err(err) => return err.into_response(),
};
sessions.push(AuthSession {
id: "browser:current".to_string(),
kind: "browser",
current: true,
provider: session_provider(session.auth_method).to_string(),
login: session.login,
label: "This browser".to_string(),
user_agent: None,
created_at: issued_at,
last_seen_at: issued_at,
expires_at,
revocable: false,
});
}
}
let cli_sessions = match state
.stores
.auth_sessions
.active_cli_sessions(&authenticated.principal.identity, now)
.await
{
Ok(sessions) => sessions,
Err(err) => {
error!(error = %err, "Failed to load auth sessions");
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to list auth sessions.",
)
.into_response();
}
};
sessions.extend(cli_sessions.into_iter().map(|active| AuthSession {
id: format!("cli:{}", active.session.id),
kind: "cli",
current: false,
provider: "github".to_string(),
login: active.session.login,
label: "Fabro CLI".to_string(),
user_agent: Some(active.session.user_agent),
created_at: active.session.created_at,
last_seen_at: active.session.last_used_at,
expires_at: active.expires_at,
revocable: true,
}));
sessions.sort_by(|left, right| {
right
.current
.cmp(&left.current)
.then_with(|| right.last_seen_at.cmp(&left.last_seen_at))
});
Json(AuthSessionsResponse { sessions }).into_response()
}
async fn delete_auth_session(
State(state): State<Arc<AppState>>,
RequestAuth(auth_slot): RequestAuth,
Path(id): Path<String>,
) -> Response {
let authenticated = match require_authenticated_user(&auth_slot) {
Ok(authenticated) => authenticated,
Err(err) => return err.into_response(),
};
if id == "browser:current" {
return ApiError::bad_request("Browser sessions cannot be revoked by this API version.")
.into_response();
}
let Some(raw_session_id) = id.strip_prefix("cli:") else {
return ApiError::not_found("Auth session not found.").into_response();
};
let Ok(session_id) = uuid::Uuid::parse_str(raw_session_id) else {
return ApiError::bad_request("Malformed CLI auth session id.").into_response();
};
let deleted = match state
.stores
.auth_sessions
.delete_active_session_for_identity(
&authenticated.principal.identity,
session_id,
Utc::now(),
)
.await
{
Ok(deleted) => deleted,
Err(err) => {
error!(error = %err, "Failed to revoke auth session");
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to revoke auth session.",
)
.into_response();
}
};
if deleted == 0 {
return ApiError::not_found("Auth session not found.").into_response();
}
StatusCode::NO_CONTENT.into_response()
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use axum::Extension;
use axum::body::{Body, to_bytes};
use axum::http::{HeaderMap, Request, StatusCode, header};
use axum_extra::extract::cookie::Key;
use fabro_config::{RunLayer, ServerSettingsBuilder};
use fabro_static::EnvVars;
use fabro_types::settings::server::ServerAuthMethod;
use fabro_types::{AuthMethod, IdpIdentity, Principal};
use fabro_vault::SecretType;
use serde_json::json;
use tower::ServiceExt;
use super::{
OAUTH_STATE_TTL_MINUTES, api_routes, read_private_oauth_state, read_private_session, routes,
};
use crate::auth::{self, AuthErrorCode, GithubEndpoints};
use crate::jwt_auth::{AuthMode, ConfiguredAuth};
use crate::principal_middleware::{AuthStatus, RequestAuthContext};
use crate::server;
const DEV_TOKEN: &str =
"fabro_dev_abababababababababababababababababababababababababababababababab";
fn test_cookie_key() -> Key {
auth::derive_cookie_key(b"web-auth-test-key-material-0123456789")
.expect("test key should derive")
}
#[test]
fn redacted_url_for_log_masks_oauth_state_query_values() {
assert_eq!(
super::redacted_url_for_log(
"https://fabro.example.test/auth/callback?state=abc&code=def&keep=1"
),
"https://fabro.example.test/auth/callback?state=****&code=****&keep=1"
);
}
fn dev_token_auth_mode() -> AuthMode {
AuthMode::Enabled(ConfiguredAuth {
methods: vec![ServerAuthMethod::DevToken],
dev_token: Some(DEV_TOKEN.to_string()),
jwt_key: Some(test_jwt_key()),
jwt_issuer: Some("https://fabro.example".to_string()),
})
}
fn github_auth_mode() -> AuthMode {
AuthMode::Enabled(ConfiguredAuth {
methods: vec![ServerAuthMethod::Github],
dev_token: None,
jwt_key: Some(test_jwt_key()),
jwt_issuer: Some("https://fabro.example".to_string()),
})
}
fn test_jwt_key() -> auth::JwtSigningKey {
auth::derive_jwt_key(b"web-auth-test-key-material-0123456789")
.expect("test JWT key should derive")
}
fn default_settings() -> fabro_types::ServerSettings {
ServerSettingsBuilder::from_toml(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
"#,
)
.expect("default test settings should resolve")
}
fn github_settings(web_url: &str) -> fabro_types::ServerSettings {
ServerSettingsBuilder::from_toml(&format!(
r#"
_version = 1
[server.web]
enabled = true
url = "{web_url}"
[server.auth]
methods = ["github"]
[server.auth.github]
allowed_usernames = ["octocat"]
[server.integrations.github]
client_id = "github-client-id"
"#
))
.expect("github settings should resolve")
}
fn test_auth_router_with_settings(
settings: fabro_types::ServerSettings,
auth_mode: AuthMode,
) -> axum::Router {
let state = crate::test_support::test_app_state_with_runtime_settings_and_session_key(
settings,
RunLayer::default(),
Some("web-auth-test-key-material-0123456789"),
);
let translation_state = state.clone();
let principal_state = state.clone();
axum::Router::new()
.nest("/auth", routes())
.nest(
"/api/v1",
api_routes().layer(axum::middleware::from_fn_with_state(
principal_state,
crate::principal_middleware::principal_middleware,
)),
)
.layer(axum::middleware::from_fn_with_state(
translation_state,
crate::auth::auth_translation_middleware,
))
.layer(Extension(Arc::new(GithubEndpoints::production_defaults())))
.layer(Extension(auth_mode))
.with_state(state)
}
fn test_auth_router_with_capture(
settings: fabro_types::ServerSettings,
auth_mode: AuthMode,
) -> (axum::Router, Arc<Mutex<Vec<RequestAuthContext>>>) {
let captured = Arc::new(Mutex::new(Vec::new()));
let state = crate::test_support::test_app_state_with_runtime_settings_and_session_key(
settings,
RunLayer::default(),
Some("web-auth-test-key-material-0123456789"),
);
let app = axum::Router::new()
.nest("/auth", routes())
.layer(axum::middleware::from_fn_with_state(
Arc::clone(&captured),
crate::test_support::capture_auth_context,
))
.layer(Extension(Arc::new(GithubEndpoints::production_defaults())))
.layer(Extension(auth_mode))
.with_state(state);
(app, captured)
}
fn test_auth_router(_key: &Key, auth_mode: AuthMode) -> axum::Router {
test_auth_router_with_settings(default_settings(), auth_mode)
}
macro_rules! response_json {
($response:expr) => {
fabro_test::expect_axum_json($response, StatusCode::OK, concat!(file!(), ":", line!()))
};
}
macro_rules! assert_status {
($response:expr, $expected:expr) => {
fabro_test::assert_axum_status($response, $expected, concat!(file!(), ":", line!()))
};
}
macro_rules! checked_response {
($response:expr, $expected:expr) => {
fabro_test::expect_axum_status($response, $expected, concat!(file!(), ":", line!()))
};
}
#[tokio::test]
async fn login_dev_token_mints_session_with_dev_token_provider() {
let key = test_cookie_key();
let app = test_auth_router(&key, dev_token_auth_mode());
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/auth/login/dev-token")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(json!({ "token": DEV_TOKEN }).to_string()))
.unwrap(),
)
.await
.unwrap();
let response = checked_response!(response, StatusCode::OK).await;
let session_cookie = response
.headers()
.get(header::SET_COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(';').next())
.expect("session cookie should be set")
.to_string();
let mut cookie_headers = axum::http::HeaderMap::new();
cookie_headers.insert(
header::COOKIE,
axum::http::HeaderValue::from_str(&session_cookie).unwrap(),
);
let session = read_private_session(&cookie_headers, &key).expect("session should decode");
assert_eq!(session.auth_method, AuthMethod::DevToken);
assert_eq!(session.v, 2);
assert_eq!(
session.identity,
IdpIdentity::new("fabro:dev", "dev").unwrap()
);
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/auth/me")
.header(header::COOKIE, &session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response).await;
assert_eq!(body["provider"], "dev-token");
assert_eq!(body["user"]["login"], "dev");
assert_eq!(body["user"]["idpIssuer"], "fabro:dev");
assert_eq!(body["user"]["idpSubject"], "dev");
}
#[tokio::test]
async fn auth_me_accepts_cli_jwt_with_empty_profile_urls() {
let app = test_auth_router_with_settings(
github_settings("https://fabro.example"),
github_auth_mode(),
);
let token = auth::issue(
&test_jwt_key(),
"https://fabro.example",
&auth::JwtSubject {
identity: IdpIdentity::new("https://github.com", "12345").unwrap(),
login: "octocat".to_string(),
name: "The Octocat".to_string(),
email: "octocat@example.com".to_string(),
avatar_url: String::new(),
user_url: String::new(),
auth_method: AuthMethod::Github,
},
chrono::Duration::minutes(10),
);
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/auth/me")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response).await;
assert_eq!(body["provider"], "github");
assert_eq!(body["user"]["login"], "octocat");
assert_eq!(body["user"]["avatarUrl"], "");
assert_eq!(body["user"]["userUrl"], "");
}
#[tokio::test]
async fn auth_me_returns_unauthorized_under_demo_mode_without_jwt() {
let app = test_auth_router_with_settings(default_settings(), dev_token_auth_mode());
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/auth/me")
.header(header::COOKIE, "fabro-demo=1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_status!(response, StatusCode::UNAUTHORIZED).await;
}
#[tokio::test]
async fn login_dev_token_rejects_invalid_token() {
let key = test_cookie_key();
let app = test_auth_router(&key, dev_token_auth_mode());
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/auth/login/dev-token")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({ "token": "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" })
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_status!(response, StatusCode::UNAUTHORIZED).await;
}
#[tokio::test]
async fn login_dev_token_stamps_public_auth_context() {
let (app, captured) =
test_auth_router_with_capture(default_settings(), dev_token_auth_mode());
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/auth/login/dev-token")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(json!({ "token": DEV_TOKEN }).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_status!(response, StatusCode::OK).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/auth/login/dev-token")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({ "token": "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" })
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_status!(response, StatusCode::UNAUTHORIZED).await;
let contexts = captured.lock().expect("captured auth contexts").clone();
assert_eq!(contexts[0].auth_status, AuthStatus::Authenticated);
assert!(matches!(contexts[0].principal, Some(Principal::User(_))));
assert_eq!(contexts[1].auth_status, AuthStatus::Invalid);
assert_eq!(
contexts[1].auth_error_code,
Some(AuthErrorCode::Unauthorized)
);
}
#[tokio::test]
async fn auth_config_returns_dev_token_method() {
let key = test_cookie_key();
let app = test_auth_router(&key, dev_token_auth_mode());
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/auth/config")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response).await;
assert_eq!(body, json!({ "methods": ["dev-token"] }));
}
#[tokio::test]
async fn auth_config_returns_real_methods_when_demo_cookie_set() {
let state = crate::test_support::test_app_state_with_runtime_settings_and_session_key(
github_settings("https://fabro.example"),
RunLayer::default(),
Some("web-auth-test-key-material-0123456789"),
);
let app = server::build_router_with_options(
state,
&github_auth_mode(),
server::RouterOptions::default(),
);
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/auth/config")
.header(header::COOKIE, "fabro-demo=1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response).await;
assert_eq!(body, json!({ "methods": ["github"] }));
}
#[tokio::test]
async fn login_github_sets_secure_state_cookie_for_https_web_url() {
let app = test_auth_router_with_settings(
github_settings("https://fabro.example"),
github_auth_mode(),
);
let response = app
.oneshot(
Request::builder()
.uri("/auth/login/github")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let response = checked_response!(response, StatusCode::SEE_OTHER).await;
let set_cookie = response
.headers()
.get(header::SET_COOKIE)
.and_then(|value| value.to_str().ok())
.expect("oauth state cookie should be set");
assert!(
set_cookie.contains("Secure"),
"state cookie should be marked Secure: {set_cookie}"
);
}
#[tokio::test]
async fn login_github_persists_allowed_cli_return_to_in_state_cookie() {
let key = test_cookie_key();
let app = test_auth_router_with_settings(
github_settings("https://fabro.example"),
github_auth_mode(),
);
let now = chrono::Utc::now().timestamp();
let response = app
.oneshot(
Request::builder()
.uri("/auth/login/github?return_to=/auth/cli/resume")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let cookie = response
.headers()
.get(header::SET_COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(';').next())
.expect("oauth state cookie should be set")
.to_string();
let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, cookie.parse().unwrap());
let oauth_state =
read_private_oauth_state(&headers, &key).expect("oauth state should decode");
assert_eq!(oauth_state.return_to.as_deref(), Some("/auth/cli/resume"));
assert!(oauth_state.exp >= now + (29 * 60));
assert!(oauth_state.exp <= now + (OAUTH_STATE_TTL_MINUTES * 60) + 5);
}
#[tokio::test]
async fn login_github_uses_injected_github_endpoints() {
let state = crate::test_support::test_app_state_with_runtime_settings_and_session_key(
github_settings("https://fabro.example"),
RunLayer::default(),
Some("web-auth-test-key-material-0123456789"),
);
let app = crate::server::build_router_with_options(
state,
&github_auth_mode(),
crate::server::RouterOptions {
web_enabled: true,
github_endpoints: Some(Arc::new(GithubEndpoints::with_bases(
"http://127.0.0.1:12345/"
.parse()
.expect("oauth base should parse"),
"http://127.0.0.1:12345/api/"
.parse()
.expect("api base should parse"),
))),
static_asset_root: None,
watch_web: false,
},
);
let response = app
.oneshot(
Request::builder()
.uri("/auth/login/github")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let location = response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok())
.expect("redirect location should be set");
assert!(location.starts_with("http://127.0.0.1:12345/login/oauth/authorize?"));
}
#[tokio::test]
async fn callback_github_rejects_plain_oauth_state_cookie() {
let app = test_auth_router_with_settings(
github_settings("https://fabro.example"),
github_auth_mode(),
);
let response = app
.oneshot(
Request::builder()
.uri("/auth/callback/github?code=test-code&state=fabro-test-state")
.header(header::COOKIE, "fabro_oauth_state=fabro-test-state")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains("Your login took too long or was tampered with. Please start again.")
);
}
#[tokio::test]
async fn callback_github_rejects_expired_oauth_state_cookie_after_35_minutes() {
let key = test_cookie_key();
let app = test_auth_router_with_settings(
github_settings("https://fabro.example"),
github_auth_mode(),
);
let mut jar = cookie::CookieJar::new();
super::add_oauth_state_cookie(
&mut jar,
&key,
&super::OAuthStateCookie {
state: "fabro-test-state".to_string(),
exp: (chrono::Utc::now() - chrono::Duration::minutes(5)).timestamp(),
return_to: Some("/auth/cli/resume".to_string()),
},
true,
);
let cookie = jar
.delta()
.next()
.expect("private oauth cookie should exist")
.encoded()
.to_string();
let response = app
.oneshot(
Request::builder()
.uri("/auth/callback/github?code=test-code&state=fabro-test-state")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains("Your login took too long or was tampered with. Please start again.")
);
}
#[tokio::test]
async fn callback_github_forwards_sanitized_error_to_cli_return_to() {
let key = test_cookie_key();
let app = test_auth_router_with_settings(
github_settings("https://fabro.example"),
github_auth_mode(),
);
let mut jar = cookie::CookieJar::new();
super::add_oauth_state_cookie(
&mut jar,
&key,
&super::OAuthStateCookie {
state: "fabro-test-state".to_string(),
exp: (chrono::Utc::now() + chrono::Duration::minutes(30)).timestamp(),
return_to: Some("/auth/cli/resume".to_string()),
},
true,
);
let cookie = jar
.delta()
.next()
.expect("private oauth cookie should exist")
.encoded()
.to_string();
let response = app
.oneshot(
Request::builder()
.uri("/auth/callback/github?error=access_denied&error_description=%3Cscript%3Eboom%3C%2Fscript%3E&state=fabro-test-state")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response
.headers()
.get(header::LOCATION)
.and_then(|v| v.to_str().ok()),
Some(
"/auth/cli/resume?error=access_denied&error_description=Authorization%20denied&state=fabro-test-state"
)
);
}
#[tokio::test]
async fn callback_github_reads_client_secret_from_vault() {
let github = httpmock::MockServer::start_async().await;
let token = github
.mock_async(|when, then| {
when.method(httpmock::Method::POST)
.path("/login/oauth/access_token")
.body_includes("client_secret=vault-client-secret");
then.status(200)
.header("content-type", "application/json")
.json_body(json!({ "access_token": "gho_test" }));
})
.await;
let user = github
.mock_async(|when, then| {
when.method(httpmock::Method::GET)
.path("/api/user")
.header("authorization", "Bearer gho_test");
then.status(200)
.header("content-type", "application/json")
.json_body(json!({
"id": 12345,
"login": "octocat",
"name": "The Octocat",
"avatar_url": "https://github.example/avatar.png"
}));
})
.await;
let emails = github
.mock_async(|when, then| {
when.method(httpmock::Method::GET).path("/api/user/emails");
then.status(200)
.header("content-type", "application/json")
.json_body(json!([]));
})
.await;
let state = crate::test_support::test_app_state_with_runtime_settings_and_session_key(
github_settings("https://fabro.example"),
RunLayer::default(),
Some("web-auth-test-key-material-0123456789"),
);
state
.stores
.vault
.set(
EnvVars::GITHUB_APP_CLIENT_SECRET,
"vault-client-secret",
SecretType::Token,
None,
)
.await
.unwrap();
let app =
server::build_router_with_options(state, &github_auth_mode(), server::RouterOptions {
web_enabled: true,
github_endpoints: Some(Arc::new(GithubEndpoints::with_bases(
github.url("/").parse().expect("oauth base should parse"),
github.url("/api/").parse().expect("api base should parse"),
))),
..server::RouterOptions::default()
});
let key = test_cookie_key();
let mut jar = cookie::CookieJar::new();
super::add_oauth_state_cookie(
&mut jar,
&key,
&super::OAuthStateCookie {
state: "fabro-test-state".to_string(),
exp: (chrono::Utc::now() + chrono::Duration::minutes(30)).timestamp(),
return_to: None,
},
true,
);
let cookie = jar
.delta()
.next()
.expect("private oauth cookie should exist")
.encoded()
.to_string();
let response = app
.oneshot(
Request::builder()
.uri("/auth/callback/github?code=test-code&state=fabro-test-state")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_status!(response, StatusCode::SEE_OTHER).await;
token.assert_async().await;
user.assert_async().await;
emails.assert_async().await;
}
#[test]
fn read_private_session_rejects_v1_cookies() {
let key = test_cookie_key();
let mut jar = cookie::CookieJar::new();
jar.private_mut(&key).add(cookie::Cookie::new(
super::SESSION_COOKIE_NAME,
json!({
"v": 1,
"login": "dev",
"auth_method": "dev_token",
"name": "Development User",
"email": "dev@localhost",
"avatar_url": "/images/logo.svg",
"user_url": "",
"identity": null,
"iat": chrono::Utc::now().timestamp(),
"exp": chrono::Utc::now().timestamp() + 60,
})
.to_string(),
));
let encoded = jar
.delta()
.next()
.expect("private cookie should exist")
.encoded()
.to_string();
let mut headers = axum::http::HeaderMap::new();
headers.insert(header::COOKIE, encoded.parse().unwrap());
assert!(read_private_session(&headers, &key).is_none());
}
#[test]
fn read_private_session_rejects_invalid_identity_payload() {
let key = test_cookie_key();
let mut jar = cookie::CookieJar::new();
jar.private_mut(&key).add(cookie::Cookie::new(
super::SESSION_COOKIE_NAME,
json!({
"v": 2,
"login": "octocat",
"auth_method": "github",
"name": "The Octocat",
"email": "octocat@example.com",
"avatar_url": "/images/logo.svg",
"user_url": "https://github.com/octocat",
"identity": {
"issuer": "",
"subject": "12345"
},
"iat": chrono::Utc::now().timestamp(),
"exp": chrono::Utc::now().timestamp() + 60,
})
.to_string(),
));
let encoded = jar
.delta()
.next()
.expect("private cookie should exist")
.encoded()
.to_string();
let mut headers = axum::http::HeaderMap::new();
headers.insert(header::COOKIE, encoded.parse().unwrap());
assert!(read_private_session(&headers, &key).is_none());
}
}