use std::collections::VecDeque;
use crossterm::event::{KeyCode, KeyEvent};
use jsonwebtoken::decode_header;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
use serde_json::Value;
use sqlx::PgPool;
use crate::tui::app::AppAction;
const MAX_RECENT: usize = 10;
struct DecodedInfo {
header: Value,
payload: Value,
sub: Option<String>,
exp: Option<i64>,
iat: Option<i64>,
}
enum VerifyStatus {
Untrusted,
Verified { token_type: String, age_human: String },
Invalid(String),
}
struct UserRecord {
id: String,
email: Option<String>,
active_tokens: usize,
}
pub struct AuthInspectorTab {
pub token_input: String,
decoded: Option<DecodedInfo>,
verify_status: VerifyStatus,
user: Option<UserRecord>,
is_revoked: Option<bool>,
error: Option<String>,
recent_tokens: VecDeque<String>,
}
impl Default for AuthInspectorTab {
fn default() -> Self {
Self {
token_input: String::new(),
decoded: None,
verify_status: VerifyStatus::Untrusted,
user: None,
is_revoked: None,
error: None,
recent_tokens: VecDeque::with_capacity(MAX_RECENT),
}
}
}
impl AuthInspectorTab {
pub async fn load(&mut self, _pool: &PgPool) {}
pub fn render(&mut self, frame: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
self.render_input(frame, chunks[0]);
self.render_body(frame, chunks[1]);
self.render_help(frame, chunks[2]);
}
fn render_input(&self, frame: &mut Frame, area: Rect) {
let style = if self.error.is_some() {
Style::default().fg(Color::Red)
} else if self.token_input.is_empty() {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::Yellow)
};
let input_text = if self.token_input.is_empty() {
" Paste or type a JWT, then Enter to decode..."
} else {
&self.token_input
};
let input = Paragraph::new(input_text)
.style(style)
.block(Block::default().borders(Borders::ALL).title(" Token "));
frame.render_widget(input, area);
}
fn render_body(&self, frame: &mut Frame, area: Rect) {
let mut text = Text::default();
if let Some(err) = &self.error {
text.push_line(Line::from(Span::styled(
format!(" Error: {err}"),
Style::default().fg(Color::Red),
)));
text.push_line(Line::from(""));
}
if let Some(info) = &self.decoded {
text.push_line(Line::from(Span::styled(
" Header:",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
text.push_line(Line::from(Span::raw(format!(
" {}",
serde_json::to_string_pretty(&info.header).unwrap_or_default()
))));
text.push_line(Line::from(""));
text.push_line(Line::from(Span::styled(
" Payload:",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
text.push_line(Line::from(Span::raw(format!(
" {}",
serde_json::to_string_pretty(&info.payload).unwrap_or_default()
))));
let now = chrono::Utc::now().timestamp();
if let Some(exp) = info.exp {
let dt = chrono::DateTime::from_timestamp(exp, 0)
.map(|d| d.format("%Y-%m-%dT%H:%M:%S").to_string())
.unwrap_or_default();
let delta = exp - now;
let status = if delta < 0 {
format!(" (expired {} ago)", format_duration(-delta))
} else {
format!(" (expires in {})", format_duration(delta))
};
let color = if delta < 0 { Color::Red } else { Color::Green };
text.push_line(Line::from(""));
text.push_line(Line::from(vec![
Span::styled(" Expiry: ", Style::default().fg(Color::Cyan)),
Span::styled(dt, Style::default().fg(color).add_modifier(Modifier::BOLD)),
Span::styled(status, Style::default().fg(color)),
]));
}
if let Some(iat) = info.iat {
let dt = chrono::DateTime::from_timestamp(iat, 0)
.map(|d| d.format("%Y-%m-%dT%H:%M:%S").to_string())
.unwrap_or_default();
text.push_line(Line::from(vec![
Span::styled(" Issued: ", Style::default().fg(Color::Cyan)),
Span::raw(dt),
]));
}
text.push_line(Line::from(""));
match &self.verify_status {
VerifyStatus::Verified { token_type, age_human } => {
text.push_line(Line::from(vec![
Span::styled(" Signature: ", Style::default().fg(Color::Cyan)),
Span::styled(
"\u{2713} Valid",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!(" (HS256, {token_type}, {age_human})")),
]));
}
VerifyStatus::Invalid(reason) => {
text.push_line(Line::from(vec![
Span::styled(" Signature: ", Style::default().fg(Color::Cyan)),
Span::styled(
"\u{2717} Invalid",
Style::default()
.fg(Color::Red)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!(" \u{2014} {reason}")),
]));
}
VerifyStatus::Untrusted => {
text.push_line(Line::from(vec![
Span::styled(" Signature: ", Style::default().fg(Color::Cyan)),
Span::styled(
"\u{2014} Not verified",
Style::default().fg(Color::DarkGray),
),
Span::raw(" (press v to verify)"),
]));
}
}
if let Some(revoked) = self.is_revoked {
text.push_line(Line::from(vec![
Span::styled(" Blacklist: ", Style::default().fg(Color::Cyan)),
if revoked {
Span::styled(
"\u{2717} Revoked",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
} else {
Span::styled(
"\u{2713} Not revoked",
Style::default().fg(Color::Green),
)
},
]));
}
text.push_line(Line::from(""));
if let Some(u) = &self.user {
text.push_line(Line::from(vec![
Span::styled(
"\u{25b6} Linked user: ",
Style::default().fg(Color::Cyan),
),
Span::raw(format!("#{}", u.id)),
if let Some(email) = &u.email {
Span::raw(format!(" \u{2014} {email}"))
} else {
Span::raw("")
},
]));
text.push_line(Line::from(Span::raw(format!(
" Active tokens: {}",
u.active_tokens
))));
} else if info.sub.is_some() {
text.push_line(Line::from(Span::styled(
" (press u to look up linked user from DB)",
Style::default().fg(Color::DarkGray),
)));
}
}
let body = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL).title(" Decoded "))
.wrap(Wrap { trim: false });
frame.render_widget(body, area);
}
fn render_help(&self, frame: &mut Frame, area: Rect) {
let keys = vec![
("Enter", "decode"),
("v", "verify"),
("u", "user"),
("b", "blacklist"),
("Esc", "clear"),
];
let spans: Vec<Span> = keys
.iter()
.flat_map(|(k, d)| {
vec![
Span::styled(format!(" {k}"), Style::default().fg(Color::Cyan)),
Span::raw(format!(" {d} ")),
]
})
.collect();
frame.render_widget(
Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
area,
);
}
pub fn handle_key(&mut self, key: KeyEvent) -> AppAction {
match key.code {
KeyCode::Char(c) if !matches!(c, 'v' | 'u' | 'b') => {
if c.is_ascii_graphic() || c == ' ' {
self.token_input.push(c);
self.error = None;
}
AppAction::None
}
KeyCode::Backspace => {
self.token_input.pop();
AppAction::None
}
KeyCode::Enter => {
self.decode();
AppAction::None
}
KeyCode::Esc => self.clear(),
KeyCode::Char('v') => {
if self.decoded.is_some() {
AppAction::AuthVerify
} else {
AppAction::None
}
}
KeyCode::Char('u') => {
if self.decoded.is_some() {
AppAction::AuthLookupUser
} else {
AppAction::None
}
}
KeyCode::Char('b') => {
if self.decoded.is_some() {
AppAction::AuthCheckBlacklist
} else {
AppAction::None
}
}
_ => AppAction::None,
}
}
fn decode(&mut self) {
use base64::Engine;
let token = self.token_input.trim();
if token.is_empty() {
return;
}
self.error = None;
self.decoded = None;
self.verify_status = VerifyStatus::Untrusted;
self.user = None;
self.is_revoked = None;
let header = match decode_header(token) {
Ok(h) => h,
Err(e) => {
self.error = Some(format!("Invalid JWT header: {e}"));
return;
}
};
let header_val = serde_json::to_value(&header).unwrap_or(Value::Null);
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
self.error = Some(
"Invalid JWT format: expected 3 dot-separated segments".into(),
);
return;
}
let payload_bytes = match base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(parts[1])
{
Ok(b) => b,
Err(e) => {
self.error = Some(format!("Invalid base64 payload: {e}"));
return;
}
};
let payload: Value = match serde_json::from_slice(&payload_bytes) {
Ok(v) => v,
Err(e) => {
self.error = Some(format!("Invalid JSON payload: {e}"));
return;
}
};
let sub = payload.get("sub").and_then(|v| v.as_str()).map(String::from);
let exp = payload.get("exp").and_then(|v| v.as_i64());
let iat = payload.get("iat").and_then(|v| v.as_i64());
self.decoded = Some(DecodedInfo {
header: header_val,
payload,
sub,
exp,
iat,
});
let display = if token.len() > 50 {
format!("{}...{}", &token[..25], &token[token.len() - 12..])
} else {
token.to_string()
};
self.recent_tokens.push_front(display);
if self.recent_tokens.len() > MAX_RECENT {
self.recent_tokens.pop_back();
}
}
fn clear(&mut self) -> AppAction {
self.token_input.clear();
self.decoded = None;
self.verify_status = VerifyStatus::Untrusted;
self.user = None;
self.is_revoked = None;
self.error = None;
AppAction::None
}
pub async fn verify_signature(&mut self, _pool: &PgPool) {
let token = self.token_input.trim();
if token.is_empty() {
return;
}
let secret = std::env::var("JWT_SECRET").ok();
match secret {
Some(s) if !s.is_empty() => {
match rok_auth::Auth::new(rok_auth::AuthConfig {
secret: s,
..Default::default()
}) {
Ok(auth) => match auth.verify(token) {
Ok(claims) => {
let token_type = if claims.has_role("totp_pending") {
"TOTP pending".into()
} else {
"Access token".into()
};
let age = chrono::Utc::now().timestamp() - claims.iat;
self.verify_status = VerifyStatus::Verified {
token_type,
age_human: format_duration(age),
};
self.error = None;
}
Err(e) => {
let reason = match &e {
rok_auth::AuthError::TokenExpired => "Token expired".into(),
rok_auth::AuthError::InvalidToken => {
"Invalid signature or malformed".into()
}
_ => format!("{e}"),
};
self.verify_status = VerifyStatus::Invalid(reason);
}
},
Err(e) => {
self.error = Some(format!("Auth init error: {e}"));
}
}
}
_ => {
self.error = Some("JWT_SECRET not set \u{2014} cannot verify signature".into());
}
}
}
pub async fn lookup_user(&mut self, pool: &PgPool) {
let sub = self.decoded.as_ref().and_then(|d| d.sub.clone());
let Some(sub) = sub else {
self.error = Some("No 'sub' claim in token payload".into());
return;
};
match sqlx::query_as::<_, (String, Option<String>)>(
"SELECT id::text, email FROM users \
WHERE id::text = $1 OR id = $1::bigint \
LIMIT 1",
)
.bind(&sub)
.fetch_optional(pool)
.await
{
Ok(Some((id, email))) => {
let token_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM personal_access_tokens \
WHERE tokenable_id = $1")
.bind(&id.parse::<i64>().unwrap_or(0))
.fetch_one(pool)
.await
.unwrap_or(0);
self.user = Some(UserRecord {
id,
email,
active_tokens: token_count as usize,
});
self.error = None;
}
Ok(None) => {
self.error = Some(format!("User #{sub} not found in users table"));
}
Err(e) => {
self.error = Some(format!("User lookup failed: {e}"));
}
}
}
pub async fn check_blacklist(&mut self, pool: &PgPool) {
let token = self.token_input.trim();
if token.is_empty() {
return;
}
self.is_revoked = Some(rok_auth::TokenBlacklist::is_revoked(pool, token).await);
}
}
fn format_duration(secs: i64) -> String {
let abs = secs.unsigned_abs();
let days = abs / 86400;
let hours = (abs % 86400) / 3600;
let minutes = (abs % 3600) / 60;
let secs = abs % 60;
let mut parts = Vec::new();
if days > 0 {
parts.push(format!("{days}d"));
}
if hours > 0 {
parts.push(format!("{hours}h"));
}
if minutes > 0 {
parts.push(format!("{minutes}m"));
}
if secs > 0 || parts.is_empty() {
parts.push(format!("{secs}s"));
}
parts.join(" ")
}