1//! Provider-neutral identities and the sessions that follow.2//!3//! Login adapters end at `ExternalIdentity`. Nothing downstream knows which provider4//! authenticated it; it only trusts the opaque dev.site session minted for that identity.56use anyhow::{bail, Result};7use sha2::{Digest, Sha256};89/// How long a dev.site session lasts.10pub const SESSION_LIFETIME_SECS: u64 = 60 * 60 * 24 * 7;1112/// The only value a login adapter is allowed to hand to the application.13///14/// For OIDC, the namespace is the exact `iss` claim and the subject is `sub`; that pair,15/// rather than either value alone, is the stable external identity.1617pub struct ExternalIdentity {18 pub namespace: String,19 pub subject: String,20}2122/// Mint an opaque session token. Only its hash is stored.23pub fn generate_session_token() -> String {24 let mut bytes = [0u8; 32];25 getrandom_fill(&mut bytes);26 data_encoding::BASE64URL_NOPAD.encode(&bytes)27}2829/// Long-lived bearer credential for one named machine. The prefix keeps it30/// recognizable in password managers and logs without weakening its entropy.31pub fn generate_machine_token() -> String {32 format!("dsm_{}", generate_session_token())33}3435/// One-use plaintext bootstrap material. Enrollment consumes it and returns36/// the endpoint-bound machine credential.37pub fn generate_machine_ticket() -> String {38 format!("dmt_{}", generate_session_token())39}4041pub fn generate_machine_credential_id() -> String {42 let mut bytes = [0u8; 16];43 getrandom_fill(&mut bytes);44 format!(45 "machine_{}",46 data_encoding::BASE32_NOPAD47 .encode(&bytes)48 .to_ascii_lowercase()49 )50}5152pub fn hash_token(token: &str) -> String {53 let digest = Sha256::digest(token.as_bytes());54 data_encoding::HEXLOWER.encode(&digest)55}5657fn getrandom_fill(buf: &mut [u8]) {58 use rand::RngCore;59 rand::rngs::OsRng.fill_bytes(buf);60}6162/// Handles are the presentation layer only, but they still end up in URLs and in shell63/// commands, so keep them boring.64pub fn validate_handle(handle: &str) -> Result<String> {65 let handle = handle.trim().trim_start_matches('@');66 if handle.len() < 2 || handle.len() > 32 {67 bail!("handles must be between 2 and 32 characters");68 }69 if !handle70 .chars()71 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')72 {73 bail!("handles may contain only letters, digits, hyphens and underscores");74 }75 if handle.starts_with('-') || handle.starts_with('_') {76 bail!("handles must start with a letter or digit");77 }78 let handle = handle.to_ascii_lowercase();79 // This is deliberately not a profanity filter. These names imply an80 // official dev.site identity and are the small set worth reserving.81 const RESERVED: &[&str] = &["admin", "devsite", "security", "support"];82 if RESERVED.contains(&handle.as_str()) {83 bail!("that handle is reserved");84 }85 Ok(handle)86}878889mod tests {90 use super::*;9192 93 fn session_tokens_are_unpredictable_and_stored_hashed() {94 let a = generate_session_token();95 let b = generate_session_token();96 assert_ne!(a, b);97 assert!(a.len() >= 43);98 assert_ne!(99 hash_token(&a),100 a,101 "the raw token must never be the stored value"102 );103 assert_eq!(hash_token(&a), hash_token(&a));104 assert!(generate_machine_token().starts_with("dsm_"));105 assert!(generate_machine_ticket().starts_with("dmt_"));106 }107108 109 fn accepts_reasonable_handles() {110 assert_eq!(validate_handle("@Alice").unwrap(), "alice");111 assert_eq!(validate_handle("bob_2").unwrap(), "bob_2");112 }113114 115 fn rejects_handles_that_would_be_trouble_in_a_url_or_shell() {116 for bad in [117 "a",118 "",119 "has space",120 "semi;colon",121 "../etc",122 "-leading",123 "slash/es",124 ] {125 assert!(validate_handle(bad).is_err(), "{bad:?} should be rejected");126 }127 for reserved in ["admin", "DevSite", "security", "support"] {128 assert!(129 validate_handle(reserved).is_err(),130 "{reserved:?} should be reserved"131 );132 }133 }134}