feat: initialize k-tv-frontend with Next.js and Tailwind CSS

- Added package.json with dependencies and scripts for development, build, and linting.
- Created postcss.config.mjs for Tailwind CSS integration.
- Added SVG assets for UI components including file, globe, next, vercel, and window icons.
- Configured TypeScript with tsconfig.json for strict type checking and module resolution.
This commit is contained in:
2026-03-11 19:13:21 +01:00
commit 01108aa23e
130 changed files with 29949 additions and 0 deletions

View File

@@ -0,0 +1,278 @@
//! JWT Authentication Infrastructure
//!
//! Provides JWT token creation and validation using HS256 (secret-based).
//! For OIDC/JWKS validation, see the `oidc` module.
use domain::User;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// Minimum secret length for production (256 bits = 32 bytes)
const MIN_SECRET_LENGTH: usize = 32;
/// JWT configuration
#[derive(Debug, Clone)]
pub struct JwtConfig {
/// Secret key for HS256 signing/verification
pub secret: String,
/// Expected issuer (for validation)
pub issuer: Option<String>,
/// Expected audience (for validation)
pub audience: Option<String>,
/// Token expiry in hours (default: 24)
pub expiry_hours: u64,
}
impl JwtConfig {
/// Create a new JWT config with validation
///
/// In production mode, this will reject weak secrets.
pub fn new(
secret: String,
issuer: Option<String>,
audience: Option<String>,
expiry_hours: Option<u64>,
is_production: bool,
) -> Result<Self, JwtError> {
// Validate secret strength in production
if is_production && secret.len() < MIN_SECRET_LENGTH {
return Err(JwtError::WeakSecret {
min_length: MIN_SECRET_LENGTH,
actual_length: secret.len(),
});
}
Ok(Self {
secret,
issuer,
audience,
expiry_hours: expiry_hours.unwrap_or(24),
})
}
/// Create config without validation (for testing)
pub fn new_unchecked(secret: String) -> Self {
Self {
secret,
issuer: None,
audience: None,
expiry_hours: 24,
}
}
}
/// JWT claims structure
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JwtClaims {
/// Subject - the user's unique identifier (user ID as string)
pub sub: String,
/// User's email address
pub email: String,
/// Expiry timestamp (seconds since UNIX epoch)
pub exp: usize,
/// Issued at timestamp (seconds since UNIX epoch)
pub iat: usize,
/// Issuer
#[serde(skip_serializing_if = "Option::is_none")]
pub iss: Option<String>,
/// Audience
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
}
/// JWT-related errors
#[derive(Debug, thiserror::Error)]
pub enum JwtError {
#[error("JWT secret is too weak: minimum {min_length} bytes required, got {actual_length}")]
WeakSecret {
min_length: usize,
actual_length: usize,
},
#[error("Token creation failed: {0}")]
CreationFailed(#[from] jsonwebtoken::errors::Error),
#[error("Token validation failed: {0}")]
ValidationFailed(String),
#[error("Token expired")]
Expired,
#[error("Invalid token format")]
InvalidFormat,
#[error("Missing configuration")]
MissingConfig,
}
/// JWT token validator and generator
#[derive(Clone)]
pub struct JwtValidator {
config: JwtConfig,
encoding_key: EncodingKey,
decoding_key: DecodingKey,
validation: Validation,
}
impl JwtValidator {
/// Create a new JWT validator with the given configuration
pub fn new(config: JwtConfig) -> Self {
let encoding_key = EncodingKey::from_secret(config.secret.as_bytes());
let decoding_key = DecodingKey::from_secret(config.secret.as_bytes());
let mut validation = Validation::new(Algorithm::HS256);
// Configure issuer validation if set
if let Some(ref issuer) = config.issuer {
validation.set_issuer(&[issuer]);
}
// Configure audience validation if set
if let Some(ref audience) = config.audience {
validation.set_audience(&[audience]);
}
Self {
config,
encoding_key,
decoding_key,
validation,
}
}
/// Create a JWT token for the given user
pub fn create_token(&self, user: &User) -> Result<String, JwtError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as usize;
let expiry = now + (self.config.expiry_hours as usize * 3600);
let claims = JwtClaims {
sub: user.id.to_string(),
email: user.email.as_ref().to_string(),
exp: expiry,
iat: now,
iss: self.config.issuer.clone(),
aud: self.config.audience.clone(),
};
let header = Header::new(Algorithm::HS256);
encode(&header, &claims, &self.encoding_key).map_err(JwtError::CreationFailed)
}
/// Validate a JWT token and return the claims
pub fn validate_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
let token_data = decode::<JwtClaims>(token, &self.decoding_key, &self.validation).map_err(
|e| match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
jsonwebtoken::errors::ErrorKind::InvalidToken => JwtError::InvalidFormat,
_ => JwtError::ValidationFailed(e.to_string()),
},
)?;
Ok(token_data.claims)
}
/// Get the user ID (subject) from a token without full validation
/// Useful for logging/debugging, but should not be trusted for auth
pub fn decode_unverified(&self, token: &str) -> Result<JwtClaims, JwtError> {
let mut validation = Validation::new(Algorithm::HS256);
validation.insecure_disable_signature_validation();
validation.validate_exp = false;
let token_data = decode::<JwtClaims>(token, &self.decoding_key, &validation)
.map_err(|_| JwtError::InvalidFormat)?;
Ok(token_data.claims)
}
}
impl std::fmt::Debug for JwtValidator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JwtValidator")
.field("issuer", &self.config.issuer)
.field("audience", &self.config.audience)
.field("expiry_hours", &self.config.expiry_hours)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::Email;
fn create_test_user() -> User {
let email = Email::try_from("test@example.com").unwrap();
User::new("test-subject", email)
}
#[test]
fn test_create_and_validate_token() {
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
let validator = JwtValidator::new(config);
let user = create_test_user();
let token = validator.create_token(&user).expect("Should create token");
let claims = validator
.validate_token(&token)
.expect("Should validate token");
assert_eq!(claims.sub, user.id.to_string());
assert_eq!(claims.email, "test@example.com");
}
#[test]
fn test_weak_secret_rejected_in_production() {
let result = JwtConfig::new(
"short".to_string(), // Too short
None,
None,
None,
true, // Production mode
);
assert!(matches!(result, Err(JwtError::WeakSecret { .. })));
}
#[test]
fn test_weak_secret_allowed_in_development() {
let result = JwtConfig::new(
"short".to_string(), // Too short but OK in dev
None,
None,
None,
false, // Development mode
);
assert!(result.is_ok());
}
#[test]
fn test_invalid_token_rejected() {
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
let validator = JwtValidator::new(config);
let result = validator.validate_token("invalid.token.here");
assert!(result.is_err());
}
#[test]
fn test_wrong_secret_rejected() {
let config1 = JwtConfig::new_unchecked("secret-one-that-is-long-enough".to_string());
let config2 = JwtConfig::new_unchecked("secret-two-that-is-long-enough".to_string());
let validator1 = JwtValidator::new(config1);
let validator2 = JwtValidator::new(config2);
let user = create_test_user();
let token = validator1.create_token(&user).unwrap();
// Token from validator1 should fail on validator2
let result = validator2.validate_token(&token);
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,19 @@
//! Authentication infrastructure
//!
//! This module contains the concrete implementation of authentication mechanisms.
/// Hash a password using the password-auth crate
pub fn hash_password(password: &str) -> String {
password_auth::generate_hash(password)
}
/// Verify a password against a stored hash
pub fn verify_password(password: &str, hash: &str) -> bool {
password_auth::verify_password(password, hash).is_ok()
}
#[cfg(feature = "auth-oidc")]
pub mod oidc;
#[cfg(feature = "auth-jwt")]
pub mod jwt;

View File

@@ -0,0 +1,212 @@
use anyhow::anyhow;
use domain::{
AuthorizationCode, AuthorizationUrlData, ClientId, ClientSecret, CsrfToken, IssuerUrl,
OidcNonce, PkceVerifier, RedirectUrl, ResourceId,
};
use openidconnect::{
AccessTokenHash, Client, EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet,
OAuth2TokenResponse, PkceCodeChallenge, Scope, StandardErrorResponse, TokenResponse,
UserInfoClaims,
core::{
CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreErrorResponseType,
CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata,
CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse,
CoreTokenResponse,
},
reqwest,
};
use serde::{Deserialize, Serialize};
pub type OidcClient = Client<
EmptyAdditionalClaims,
CoreAuthDisplay,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJsonWebKey,
CoreAuthPrompt,
StandardErrorResponse<CoreErrorResponseType>,
CoreTokenResponse,
CoreTokenIntrospectionResponse,
CoreRevocableToken,
CoreRevocationErrorResponse,
EndpointSet, // HasAuthUrl (Required and guaranteed by discovery)
EndpointNotSet, // HasDeviceAuthUrl
EndpointNotSet, // HasIntrospectionUrl
EndpointNotSet, // HasRevocationUrl
EndpointMaybeSet, // HasTokenUrl (Discovered, might be missing)
EndpointMaybeSet, // HasUserInfoUrl (Discovered, might be missing)
>;
/// Serializable OIDC state stored in an encrypted cookie during the auth code flow
#[derive(Debug, Serialize, Deserialize)]
pub struct OidcState {
pub csrf_token: CsrfToken,
pub nonce: OidcNonce,
pub pkce_verifier: PkceVerifier,
}
#[derive(Clone)]
pub struct OidcService {
client: OidcClient,
http_client: reqwest::Client,
resource_id: Option<ResourceId>,
}
#[derive(Debug)]
pub struct OidcUser {
pub subject: String,
pub email: String,
}
impl OidcService {
/// Create a new OIDC service with validated configuration newtypes
pub async fn new(
issuer: IssuerUrl,
client_id: ClientId,
client_secret: Option<ClientSecret>,
redirect_url: RedirectUrl,
resource_id: Option<ResourceId>,
) -> anyhow::Result<Self> {
tracing::debug!("🔵 OIDC Setup: Client ID = '{}'", client_id);
tracing::debug!("🔵 OIDC Setup: Redirect = '{}'", redirect_url);
tracing::debug!(
"🔵 OIDC Setup: Secret = {:?}",
if client_secret.is_some() { "SET" } else { "NONE" }
);
let http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let provider_metadata = CoreProviderMetadata::discover_async(
openidconnect::IssuerUrl::new(issuer.as_ref().to_string())?,
&http_client,
)
.await?;
let oidc_client_id = openidconnect::ClientId::new(client_id.as_ref().to_string());
let oidc_client_secret = client_secret
.as_ref()
.filter(|s| !s.is_empty())
.map(|s| openidconnect::ClientSecret::new(s.as_ref().to_string()));
let oidc_redirect_url =
openidconnect::RedirectUrl::new(redirect_url.as_ref().to_string())?;
let client = CoreClient::from_provider_metadata(
provider_metadata,
oidc_client_id,
oidc_client_secret,
)
.set_redirect_uri(oidc_redirect_url);
Ok(Self {
client,
http_client,
resource_id,
})
}
/// Get the authorization URL and associated state for OIDC login.
///
/// Returns `(AuthorizationUrlData, OidcState)` where `OidcState` should be
/// serialized and stored in an encrypted cookie for the duration of the flow.
pub fn get_authorization_url(&self) -> (AuthorizationUrlData, OidcState) {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_token, nonce) = self
.client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
openidconnect::CsrfToken::new_random,
openidconnect::Nonce::new_random,
)
.add_scope(Scope::new("profile".to_string()))
.add_scope(Scope::new("email".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
let oidc_state = OidcState {
csrf_token: CsrfToken::new(csrf_token.secret().to_string()),
nonce: OidcNonce::new(nonce.secret().to_string()),
pkce_verifier: PkceVerifier::new(pkce_verifier.secret().to_string()),
};
let auth_data = AuthorizationUrlData {
url: auth_url.into(),
csrf_token: oidc_state.csrf_token.clone(),
nonce: oidc_state.nonce.clone(),
pkce_verifier: oidc_state.pkce_verifier.clone(),
};
(auth_data, oidc_state)
}
/// Resolve the OIDC callback with type-safe parameters
pub async fn resolve_callback(
&self,
code: AuthorizationCode,
nonce: OidcNonce,
pkce_verifier: PkceVerifier,
) -> anyhow::Result<OidcUser> {
let oidc_pkce_verifier =
openidconnect::PkceCodeVerifier::new(pkce_verifier.as_ref().to_string());
let oidc_nonce = openidconnect::Nonce::new(nonce.as_ref().to_string());
let token_response = self
.client
.exchange_code(openidconnect::AuthorizationCode::new(
code.as_ref().to_string(),
))?
.set_pkce_verifier(oidc_pkce_verifier)
.request_async(&self.http_client)
.await?;
let id_token = token_response
.id_token()
.ok_or_else(|| anyhow!("Server did not return an ID token"))?;
let mut id_token_verifier = self.client.id_token_verifier().clone();
if let Some(resource_id) = &self.resource_id {
let trusted_resource_id = resource_id.as_ref().to_string();
id_token_verifier = id_token_verifier
.set_other_audience_verifier_fn(move |aud| aud.as_str() == trusted_resource_id);
}
let claims = id_token.claims(&id_token_verifier, &oidc_nonce)?;
if let Some(expected_access_token_hash) = claims.access_token_hash() {
let actual_access_token_hash = AccessTokenHash::from_token(
token_response.access_token(),
id_token.signing_alg()?,
id_token.signing_key(&id_token_verifier)?,
)?;
if actual_access_token_hash != *expected_access_token_hash {
return Err(anyhow!("Invalid access token"));
}
}
let email = if let Some(email) = claims.email() {
Some(email.as_str().to_string())
} else {
tracing::debug!("🔵 Email missing in ID Token, fetching UserInfo...");
let user_info: UserInfoClaims<EmptyAdditionalClaims, CoreGenderClaim> = self
.client
.user_info(token_response.access_token().clone(), None)?
.request_async(&self.http_client)
.await?;
user_info.email().map(|e| e.as_str().to_string())
};
let email =
email.ok_or_else(|| anyhow!("User has no verified email address in ZITADEL"))?;
Ok(OidcUser {
subject: claims.subject().to_string(),
email,
})
}
}