Enforce allowed_usernames at arc-api level via JWT sub claim

The API now extracts the GitHub username from the JWT sub claim
(last path segment of the profile URL) and checks it against
allowed_usernames from arc.toml. Fails closed: empty allowed list
or missing sub claim returns 403.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-03 16:33:04 -05:00
parent 07a765f5a2
commit fca63cb2e7
2 changed files with 140 additions and 20 deletions

View file

@ -10,18 +10,23 @@ use tracing::warn;
/// JWT claims for service-to-service authentication.
#[derive(Debug, Deserialize)]
struct Claims {
#[allow(dead_code)]
iss: String,
#[allow(dead_code)]
iat: u64,
#[allow(dead_code)]
exp: u64,
sub: Option<String>,
}
/// Authentication mode resolved at startup.
#[derive(Clone)]
pub enum AuthMode {
/// JWT verification is enabled with the given decoding key.
Jwt(Arc<DecodingKey>),
/// JWT verification is enabled with the given decoding key and allowed users.
Jwt {
key: Arc<DecodingKey>,
allowed_usernames: Vec<String>,
},
/// Authentication is explicitly disabled (insecure, for development only).
Disabled,
}
@ -40,7 +45,10 @@ fn decode_pem_env(name: &str, value: &str) -> String {
///
/// Call this once at startup before serving requests. Panics if the
/// configuration is invalid (JWT strategy but no public key).
pub fn resolve_auth_mode(api_config: &crate::server_config::ApiConfig) -> AuthMode {
pub fn resolve_auth_mode(
api_config: &crate::server_config::ApiConfig,
allowed_usernames: Vec<String>,
) -> AuthMode {
use crate::server_config::ApiAuthenticationStrategy;
match api_config.authentication_strategy {
@ -60,7 +68,10 @@ pub fn resolve_auth_mode(api_config: &crate::server_config::ApiConfig) -> AuthMo
let pem = decode_pem_env("ARC_JWT_PUBLIC_KEY", &raw);
let key = DecodingKey::from_ed_pem(pem.as_bytes())
.expect("ARC_JWT_PUBLIC_KEY contains an invalid Ed25519 PEM public key");
AuthMode::Jwt(Arc::new(key))
AuthMode::Jwt {
key: Arc::new(key),
allowed_usernames,
}
}
}
}
@ -81,9 +92,12 @@ impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedService {
.get::<AuthMode>()
.expect("AuthMode extension must be added to the router");
let key = match auth_mode {
let (key, allowed_usernames) = match auth_mode {
AuthMode::Disabled => return Ok(AuthenticatedService),
AuthMode::Jwt(key) => key,
AuthMode::Jwt {
key,
allowed_usernames,
} => (key, allowed_usernames),
};
let header = parts
@ -103,8 +117,21 @@ impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedService {
let token_data = jsonwebtoken::decode::<Claims>(token, key, &validation)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
if token_data.claims.iss != "arc-web" {
return Err(StatusCode::UNAUTHORIZED);
// Fail closed: if no usernames are allowed, reject all requests
if allowed_usernames.is_empty() {
return Err(StatusCode::FORBIDDEN);
}
// Extract GitHub username from sub claim URL (last path segment)
let username = token_data
.claims
.sub
.as_deref()
.and_then(|s| s.rsplit('/').next())
.ok_or(StatusCode::FORBIDDEN)?;
if !allowed_usernames.iter().any(|u| u == username) {
return Err(StatusCode::FORBIDDEN);
}
Ok(AuthenticatedService)
@ -157,24 +184,39 @@ mod tests {
(encoding, decoding)
}
fn sign_token(key: &jsonwebtoken::EncodingKey, iss: &str, exp_secs: u64) -> String {
fn sign_token(
key: &jsonwebtoken::EncodingKey,
iss: &str,
exp_secs: u64,
sub: Option<&str>,
) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let claims = serde_json::json!({
let mut claims = serde_json::json!({
"iss": iss,
"iat": now,
"exp": now + exp_secs,
});
if let Some(sub) = sub {
claims["sub"] = serde_json::Value::String(sub.to_string());
}
let header = jsonwebtoken::Header::new(Algorithm::EdDSA);
jsonwebtoken::encode(&header, &claims, key).expect("failed to sign token")
}
fn jwt_mode(decoding: DecodingKey, allowed_usernames: Vec<&str>) -> AuthMode {
AuthMode::Jwt {
key: Arc::new(decoding),
allowed_usernames: allowed_usernames.into_iter().map(String::from).collect(),
}
}
#[tokio::test]
async fn rejects_missing_auth_header() {
let (_, decoding) = generate_test_keypair();
let app = test_router(AuthMode::Jwt(Arc::new(decoding)));
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
@ -185,7 +227,7 @@ mod tests {
#[tokio::test]
async fn rejects_invalid_token() {
let (_, decoding) = generate_test_keypair();
let app = test_router(AuthMode::Jwt(Arc::new(decoding)));
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
let req = Request::builder()
.uri("/test")
@ -198,11 +240,16 @@ mod tests {
}
#[tokio::test]
async fn accepts_valid_token() {
async fn accepts_valid_token_with_matching_username() {
let (encoding, decoding) = generate_test_keypair();
let app = test_router(AuthMode::Jwt(Arc::new(decoding)));
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
let token = sign_token(&encoding, "arc-web", 60);
let token = sign_token(
&encoding,
"arc-web",
60,
Some("https://github.com/brynary"),
);
let req = Request::builder()
.uri("/test")
@ -214,12 +261,78 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn rejects_token_with_non_matching_username() {
let (encoding, decoding) = generate_test_keypair();
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
let token = sign_token(
&encoding,
"arc-web",
60,
Some("https://github.com/someone-else"),
);
let req = Request::builder()
.uri("/test")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn rejects_token_with_missing_sub() {
let (encoding, decoding) = generate_test_keypair();
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
let token = sign_token(&encoding, "arc-web", 60, None);
let req = Request::builder()
.uri("/test")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn rejects_when_allowed_usernames_empty() {
let (encoding, decoding) = generate_test_keypair();
let app = test_router(jwt_mode(decoding, vec![]));
let token = sign_token(
&encoding,
"arc-web",
60,
Some("https://github.com/brynary"),
);
let req = Request::builder()
.uri("/test")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn rejects_wrong_issuer() {
let (encoding, decoding) = generate_test_keypair();
let app = test_router(AuthMode::Jwt(Arc::new(decoding)));
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
let token = sign_token(&encoding, "wrong-issuer", 60);
let token = sign_token(
&encoding,
"wrong-issuer",
60,
Some("https://github.com/brynary"),
);
let req = Request::builder()
.uri("/test")
@ -234,7 +347,7 @@ mod tests {
#[tokio::test]
async fn rejects_expired_token() {
let (encoding, decoding) = generate_test_keypair();
let app = test_router(AuthMode::Jwt(Arc::new(decoding)));
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -244,6 +357,7 @@ mod tests {
"iss": "arc-web",
"iat": now - 200,
"exp": now - 100,
"sub": "https://github.com/brynary",
});
let header = jsonwebtoken::Header::new(Algorithm::EdDSA);
let token = jsonwebtoken::encode(&header, &claims, &encoding).unwrap();
@ -266,7 +380,10 @@ mod tests {
authentication_strategy: ApiAuthenticationStrategy::InsecureDisabled,
..ApiConfig::default()
};
assert!(matches!(resolve_auth_mode(&config), AuthMode::Disabled));
assert!(matches!(
resolve_auth_mode(&config, vec![]),
AuthMode::Disabled
));
}
#[tokio::test]

View file

@ -122,7 +122,10 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
let auth_mode = if args.demo {
crate::jwt_auth::AuthMode::Disabled
} else {
crate::jwt_auth::resolve_auth_mode(&server_config.api)
crate::jwt_auth::resolve_auth_mode(
&server_config.api,
server_config.web.auth.allowed_usernames.clone(),
)
};
let state = create_app_state_with_options(db, factory, dry_run_mode, args.demo);