use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use authx_core::crypto::sha256_hex;
use authx_core::models::{CreateOidcClient, CreateOidcFederationProvider};
use authx_storage::{
memory::MemoryStore,
ports::{DeviceCodeRepository, OidcClientRepository, OidcFederationProviderRepository},
};
#[derive(Subcommand)]
pub enum OidcCommand {
#[command(subcommand)]
Client(ClientCommand),
#[command(subcommand)]
Federation(FederationCommand),
#[command(subcommand)]
Device(DeviceCommand),
}
#[derive(Subcommand)]
pub enum ClientCommand {
List(ClientListArgs),
Create(ClientCreateArgs),
}
#[derive(Args)]
pub struct ClientListArgs {
#[arg(long, default_value_t = 50)]
limit: u32,
#[arg(long, default_value_t = 0)]
offset: u32,
}
#[derive(Args)]
pub struct ClientCreateArgs {
pub name: String,
#[arg(long)]
pub redirect_uris: String,
#[arg(long, default_value = "openid profile email")]
pub scopes: String,
#[arg(long)]
pub client_secret: Option<String>,
}
#[derive(Subcommand)]
pub enum FederationCommand {
List(FederationListArgs),
Create(FederationCreateArgs),
}
#[derive(Args)]
pub struct FederationListArgs;
#[derive(Args)]
pub struct FederationCreateArgs {
pub name: String,
pub issuer: String,
pub client_id: String,
pub client_secret: String,
#[arg(long, default_value = "openid profile email")]
pub scopes: String,
#[arg(long, value_name = "HEX_KEY")]
pub enc_key_hex: String,
}
#[derive(Subcommand)]
pub enum DeviceCommand {
List(DeviceListArgs),
}
#[derive(Args)]
pub struct DeviceListArgs {
pub client_id: String,
#[arg(long, default_value_t = 50)]
limit: u32,
#[arg(long, default_value_t = 0)]
offset: u32,
}
pub async fn run(cmd: OidcCommand) -> Result<()> {
match cmd {
OidcCommand::Client(sub) => match sub {
ClientCommand::List(args) => list_clients(args).await,
ClientCommand::Create(args) => create_client(args).await,
},
OidcCommand::Federation(sub) => match sub {
FederationCommand::List(args) => list_federation(args).await,
FederationCommand::Create(args) => create_federation(args).await,
},
OidcCommand::Device(sub) => match sub {
DeviceCommand::List(args) => list_device_codes(args).await,
},
}
}
async fn list_clients(args: ClientListArgs) -> Result<()> {
let store = MemoryStore::new();
let clients = OidcClientRepository::list(&store, args.offset, args.limit)
.await
.context("list clients")?;
if clients.is_empty() {
tracing::info!("No OIDC clients found.");
return Ok(());
}
tracing::info!("{:<38} {:<32} Name", "ID", "Client ID");
tracing::info!("{}", "-".repeat(90));
for c in &clients {
tracing::info!("{:<38} {:<32} {}", c.id, c.client_id, c.name);
}
tracing::info!("{} client(s) shown.", clients.len());
Ok(())
}
async fn create_client(args: ClientCreateArgs) -> Result<()> {
let store = MemoryStore::new();
let redirect_uris: Vec<String> = args
.redirect_uris
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
if redirect_uris.is_empty() {
anyhow::bail!("at least one redirect URI is required");
}
let secret_hash = args
.client_secret
.as_ref()
.map(|s| sha256_hex(s.as_bytes()))
.unwrap_or_default();
let client = OidcClientRepository::create(
&store,
CreateOidcClient {
name: args.name.clone(),
redirect_uris,
grant_types: vec!["authorization_code".into(), "refresh_token".into()],
response_types: vec!["code".into()],
allowed_scopes: args.scopes.clone(),
secret_hash,
},
)
.await
.context("create client")?;
tracing::info!("OIDC client created:");
tracing::info!(" ID: {}", client.id);
tracing::info!(" Client ID: {}", client.client_id);
tracing::info!(" Name: {}", client.name);
tracing::info!(" Redirects: {}", client.redirect_uris.join(", "));
tracing::info!(" Scopes: {}", client.allowed_scopes);
if args.client_secret.is_some() {
tracing::info!(" Type: confidential (secret hashed)");
} else {
tracing::info!(" Type: public (no secret)");
}
Ok(())
}
async fn list_federation(_args: FederationListArgs) -> Result<()> {
let store = MemoryStore::new();
let providers = OidcFederationProviderRepository::list_enabled(&store)
.await
.context("list providers")?;
if providers.is_empty() {
tracing::info!("No federation providers found.");
return Ok(());
}
tracing::info!("{:<38} {:<18} {:<40} Scopes", "ID", "Name", "Issuer");
tracing::info!("{}", "-".repeat(110));
for p in &providers {
tracing::info!("{:<38} {:<18} {:<40} {}", p.id, p.name, p.issuer, p.scopes);
}
tracing::info!("{} provider(s) shown.", providers.len());
Ok(())
}
async fn create_federation(args: FederationCreateArgs) -> Result<()> {
let store = MemoryStore::new();
let key_bytes = hex::decode(args.enc_key_hex.trim()).context("decode enc_key_hex")?;
if key_bytes.len() != 32 {
anyhow::bail!("enc_key_hex must decode to 32 bytes (AES-256 key)");
}
let mut key = [0u8; 32];
key.copy_from_slice(&key_bytes);
let secret_enc = authx_core::crypto::encrypt(&key, args.client_secret.as_bytes())
.context("encrypt client_secret")?;
let provider = OidcFederationProviderRepository::create(
&store,
CreateOidcFederationProvider {
name: args.name.clone(),
issuer: args.issuer.clone(),
client_id: args.client_id.clone(),
secret_enc,
scopes: args.scopes.clone(),
org_id: None,
claim_mapping: vec![],
},
)
.await
.context("create federation provider")?;
tracing::info!("Federation provider created:");
tracing::info!(" ID: {}", provider.id);
tracing::info!(" Name: {}", provider.name);
tracing::info!(" Issuer: {}", provider.issuer);
tracing::info!(" Scopes: {}", provider.scopes);
Ok(())
}
async fn list_device_codes(args: DeviceListArgs) -> Result<()> {
let store = MemoryStore::new();
let codes =
DeviceCodeRepository::list_by_client(&store, &args.client_id, args.offset, args.limit)
.await
.context("list device codes")?;
if codes.is_empty() {
tracing::info!("No device codes found for client '{}'.", args.client_id);
return Ok(());
}
tracing::info!(
"{:<38} {:<12} {:<12} {:<12} Expires At",
"ID",
"User Code",
"Authorized",
"Denied"
);
tracing::info!("{}", "-".repeat(100));
for dc in &codes {
tracing::info!(
"{:<38} {:<12} {:<12} {:<12} {}",
dc.id,
dc.user_code,
dc.authorized,
dc.denied,
dc.expires_at
);
}
tracing::info!("{} code(s) shown.", codes.len());
Ok(())
}