mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-23 00:41:13 +00:00
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>
1777 lines
60 KiB
Rust
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());
|
|
}
|
|
}
|