// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements an API to deal with authentication tokens. //! //! Agama web server relies on JSON Web Tokens (JWT) for authentication purposes. //! This module implements a simple API to perform the common operations. //! //! ## The master token //! //! When Agama web server starts, it writes a master token (which is just a regular //! JWT) in `/run/agama/token`. That file is only readable by the root user and //! can be used by any Agama component. //! //! ## The user token //! //! When a user does not have access to the master token it needs to authenticate //! with the server. In that process, it obtains a new token that should be stored //! in user's home directory (`$HOME/.local/agama/token`). //! //! ## A simplistic API //! //! The current API is rather limited and it does not support, for instance, //! keeping tokens for different servers. We might extend this API if needed //! in the future. const USER_TOKEN_PATH: &str = ".local/agama/token"; const AGAMA_TOKEN_FILE: &str = "/run/agama/token"; use std::{ fmt, fs::{self, File}, io::{self, BufRead, BufReader, Write}, os::unix::fs::OpenOptionsExt, path::{Path, PathBuf}, }; use chrono::{Duration, Utc}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use thiserror::Error; use uuid::Uuid; #[derive(Error, Debug)] #[error("Invalid authentication token: {0}")] pub struct AuthTokenError(#[from] jsonwebtoken::errors::Error); /// Represents an authentication token (JWT). #[derive(Clone, Debug, PartialEq)] pub struct AuthToken(pub String); impl AuthToken { /// Creates a new token with the given content. /// /// * `content`: token raw content. pub fn new(content: &str) -> Self { Self(content.to_string()) } /// Generates a new token using the given secret. /// /// * `secret`: secret to encode the token. pub fn generate(secret: &str) -> Result { let claims = TokenClaims::default(); let token = jsonwebtoken::encode( &Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref()), )?; Ok(AuthToken(token)) } /// Finds an usable token for the current user. /// /// It searches for a token in user's home directory and, if it does not exists, /// it tries to read the master token. pub fn find() -> Option { if let Ok(path) = Self::user_token_path() { if let Ok(token) = Self::read(path) { return Some(token); } } Self::read(AGAMA_TOKEN_FILE).ok() } pub fn master() -> Option { Self::read(AGAMA_TOKEN_FILE).ok() } /// Reads the token from the given path. /// /// * `path`: file's path to read the token from. pub fn read>(path: P) -> io::Result { let file = File::open(path)?; let mut reader = BufReader::new(file); let mut buf = String::new(); reader.read_line(&mut buf)?; Ok(AuthToken(buf)) } /// Writes the token to the given path. /// /// It takes care of setting the right permissions (0400). /// /// * `path`: file's path to write the token to. pub fn write>(&self, path: P) -> io::Result<()> { if let Some(parent) = path.as_ref().parent() { std::fs::create_dir_all(parent)?; } let mut file = fs::OpenOptions::new() .create(true) .truncate(true) .write(true) .mode(0o400) .open(path)?; file.write_all(self.0.as_bytes())?; Ok(()) } /// Removes the user token. pub fn remove_user_token() -> io::Result<()> { let path = Self::user_token_path()?; if path.exists() { fs::remove_file(path)?; } Ok(()) } /// Returns the claims from the token. /// /// * `secret`: secret to decode the token. pub fn claims(&self, secret: &str) -> Result { let decoding = DecodingKey::from_secret(secret.as_ref()); let token_data = jsonwebtoken::decode(&self.0, &decoding, &Validation::default())?; Ok(token_data.claims) } /// Returns a reference to the token's content. pub fn as_str(&self) -> &str { self.0.as_str() } /// Writes the token to the user's home directory. pub fn write_user_token(&self) -> io::Result<()> { let path = Self::user_token_path()?; self.write(path) } /// Writes the token to Agama's run directory. /// /// For this function to succeed the run directory should exist and the user needs write /// permissions. pub fn write_master_token(&self) -> io::Result<()> { self.write(AGAMA_TOKEN_FILE) } fn user_token_path() -> io::Result { let Some(path) = home::home_dir() else { return Err(io::Error::other("Cannot find the user's home directory")); }; Ok(path.join(USER_TOKEN_PATH)) } } impl fmt::Display for AuthToken { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } /// Claims that are included in the token. /// /// See https://datatracker.ietf.org/doc/html/rfc7519 for reference. #[derive(Debug, Serialize, Deserialize)] pub struct TokenClaims { pub exp: i64, pub client_id: ClientId, } // FIXME: replace with TokenClaims::new, as it does not exist a "default" token. impl Default for TokenClaims { fn default() -> Self { let mut exp = Utc::now(); if let Some(days) = Duration::try_days(1) { exp += days; } Self { exp: exp.timestamp(), client_id: ClientId::new(), } } } /// Identifies a client. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct ClientId(Uuid); impl Default for ClientId { fn default() -> Self { Self::new() } } impl ClientId { pub fn new() -> Self { ClientId(Uuid::new_v4()) } pub fn new_from_uuid(uuid: Uuid) -> Self { ClientId(uuid) } } impl fmt::Display for ClientId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } #[cfg(test)] mod tests { use tempfile::tempdir; use super::AuthToken; #[test] fn test_generate_token() { let token = AuthToken::generate("nots3cr3t").unwrap(); let decoded = token.claims("nots3cr3t"); assert!(decoded.is_ok()); let wrong = token.claims("wrong"); assert!(wrong.is_err()) } #[test] fn test_write_and_read_token() { // let token = AuthToken::from_path>(path: P) // let token = AuthToken::from_path() let token = AuthToken::generate("nots3cr3t").unwrap(); let tmp_dir = tempdir().unwrap(); let path = tmp_dir.path().join("token"); token.write(&path).unwrap(); let read_token = AuthToken::read(&path).unwrap(); let decoded = read_token.claims("nots3cr3t"); assert!(decoded.is_ok()); } }