diff options
Diffstat (limited to '')
44 files changed, 2243 insertions, 477 deletions
@@ -14,12 +14,27 @@ dependencies = [ ] [[package]] +name = "aho-corasick" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" +dependencies = [ + "memchr", +] + +[[package]] name = "anyhow" version = "1.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6" [[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] name = "async-attributes" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -272,6 +287,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] +name = "chunked_transfer" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fff857943da45f546682664a79488be82e69e43c1a7a2307679ab9afb3a66d2e" + +[[package]] name = "clap" version = "4.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -479,6 +500,28 @@ dependencies = [ ] [[package]] +name = "encoding_rs" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_logger" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] name = "errno" version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -527,6 +570,12 @@ dependencies = [ ] [[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -585,7 +634,7 @@ checksum = "a604f7a68fbf8103337523b1fadc8ade7361ee3f112f7c680ad179651616aed5" dependencies = [ "futures-core", "lock_api", - "parking_lot", + "parking_lot 0.11.2", ] [[package]] @@ -683,6 +732,25 @@ dependencies = [ ] [[package]] +name = "h2" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f9f29bc9dda355256b2916cf526ab02ce0aeaaaf2bad60d65ef3f12f11dd0f4" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -761,6 +829,83 @@ dependencies = [ ] [[package]] +name = "http" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "0.14.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "034711faac9d2166cb1baf1a2fb0b60b1f277f8492fd72176c17f3515e1abd3c" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] name = "iam" version = "0.1.0" dependencies = [ @@ -768,6 +913,7 @@ dependencies = [ "async-std", "clap", "colored", + "env_logger", "home", "log", "rand", @@ -777,7 +923,11 @@ dependencies = [ "strum", "strum_macros", "thiserror", + "tiny_http", + "tokio", "toml", + "url", + "urlencoding", "uuid", ] @@ -821,6 +971,12 @@ dependencies = [ ] [[package]] +name = "ipnet" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11b0d96e660696543b251e58030cf9787df56da39dab19ad60eae7353040917e" + +[[package]] name = "is-terminal" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -930,12 +1086,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" [[package]] +name = "mime" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" + +[[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] +name = "mio" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d732bc30207a6423068df043e3d02e0735b155ad7ce1a6f76fe2baa5b158de" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.42.0", +] + +[[package]] name = "native-tls" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -973,6 +1147,16 @@ dependencies = [ ] [[package]] +name = "num_cpus" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6058e64324c71e02bc2b150e4f3bc8286db6c83092132ffa3f6b1eab0f9def5" +dependencies = [ + "hermit-abi 0.1.19", + "libc", +] + +[[package]] name = "once_cell" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1043,7 +1227,17 @@ checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" dependencies = [ "instant", "lock_api", - "parking_lot_core", + "parking_lot_core 0.8.5", +] + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.5", ] [[package]] @@ -1061,6 +1255,19 @@ dependencies = [ ] [[package]] +name = "parking_lot_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ff9f3fef3968a3ec5945535ed654cb38ff72d7495a25619e2247fb15a2ed9ba" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys 0.42.0", +] + +[[package]] name = "paste" version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1223,6 +1430,23 @@ dependencies = [ ] [[package]] +name = "regex" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" + +[[package]] name = "remove_dir_all" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1232,6 +1456,43 @@ dependencies = [ ] [[package]] +name = "reqwest" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68cc60575865c7831548863cc02356512e3f1dc2f3f82cb837d7fc4cc8f3c97c" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] name = "rustc_version" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1286,15 +1547,18 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" name = "secd" version = "0.1.0" dependencies = [ + "anyhow", "async-std", "async-trait", "base64", + "clap", "derive_more", "email_address", "lazy_static", "log", "openssl", "rand", + "reqwest", "serde", "serde_json", "sqlx", @@ -1302,6 +1566,7 @@ dependencies = [ "strum_macros", "thiserror", "time", + "url", "uuid", ] @@ -1366,6 +1631,18 @@ dependencies = [ ] [[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] name = "sha1" version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1668,6 +1945,18 @@ dependencies = [ ] [[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] name = "tinyvec" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1683,6 +1972,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] +name = "tokio" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab6d665857cc6ca78d6e80303a02cea7a7851e85dfbd77cbdc09bd129f1ef46" +dependencies = [ + "autocfg", + "bytes", + "libc", + "memchr", + "mio", + "num_cpus", + "parking_lot 0.12.1", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.42.0", +] + +[[package]] +name = "tokio-macros" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d266c00fde287f55d3f1c3e96c500c362a2b8c695076ec180f27918820bc6df8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb2e075f03b3d66d8d8785356224ba688d2906a371015e225beeb65ca92c740" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] name = "toml" version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1692,6 +2036,38 @@ dependencies = [ ] [[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +dependencies = [ + "cfg-if", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" + +[[package]] name = "typenum" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1742,6 +2118,12 @@ dependencies = [ ] [[package]] +name = "urlencoding" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" + +[[package]] name = "uuid" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1780,6 +2162,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" [[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2011,3 +2403,12 @@ name = "windows_x86_64_msvc" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] diff --git a/crates/iam/Cargo.toml b/crates/iam/Cargo.toml index fd9006d..ba642c3 100644 --- a/crates/iam/Cargo.toml +++ b/crates/iam/Cargo.toml @@ -8,6 +8,7 @@ anyhow = "1.0" async-std = { version = "1.12.0", features = [ "attributes" ] } clap = { version = "4.0.29", features = ["derive"] } colored = "2.0.0" +env_logger = "0.9" home = "0.5.4" log = "0.4" rand = "0.8" @@ -16,6 +17,10 @@ serde = "1" serde_json = { version = "1.0", features = ["raw_value"] } strum = "0.24.1" strum_macros = "0.24" +tiny_http = "0.12" +tokio = { version = "1.23.0", features = ["full"] } toml = "0.5.9" thiserror = "1.0" +url = "2.3.1" +urlencoding = "2.1.2" uuid = { version = "1.2", features = ["v4", "serde"]}
\ No newline at end of file diff --git a/crates/iam/src/api.rs b/crates/iam/src/api.rs index 5b72d93..8b46d08 100644 --- a/crates/iam/src/api.rs +++ b/crates/iam/src/api.rs @@ -1,22 +1,26 @@ use crate::ISSUE_TRACKER_LOC; use clap::{Parser, Subcommand, ValueEnum}; use colored::*; +use secd::{IdentityId, OauthProviderName}; use serde::{Deserialize, Serialize}; use thiserror; +use url::Url; use uuid::Uuid; #[derive(Debug, thiserror::Error)] pub enum CliError { + #[error("{} {}", "Failed to initialize an iam store.".red(), format!("An invariant was likely broken and should be reported as a bug here: {}", ISSUE_TRACKER_LOC))] + AdminInitializationError, + #[error("{} {}", "Failed to recieve incoming request.".red(), .0.white())] + DevOauthServer(String), + #[error("{} {} {}", "An unknown error occurred.".red(), format!("An invariant was likely broken and should be reported as a bug here: {}", ISSUE_TRACKER_LOC), .0.yellow())] + InternalError(String), + #[error("{} {}", "The provided validation id and code is invalid or has expired.".red(), "You may recieve at most one session with a valid code, after which a new validation is required.")] + InvalidCode, #[error("{}", "iam failed to read a valid configuration profile. Initialize an iam store with `iam admin init`".red())] InvalidProfile, #[error("{} {}", "Failed to initialize secd: ".red(), .0.yellow())] SecdInitializationFailure(String), - #[error("{} {}", "Fail to initialize an iam store.".red(), format!("An invariant was likely broken and should be reported as a bug here: {}", ISSUE_TRACKER_LOC))] - AdminInitializationError, - #[error("{} {}", "The provided validation id and code is invalid or has expired.".red(), "You may recieve at most one session with a valid code, after which a new validation is required.")] - InvalidCode, - #[error("{} {}", "An unknown error occurred.".red(), format!("An invariant was likely broken and should be reported as a bug here: {}", ISSUE_TRACKER_LOC))] - Unknown, } #[derive(Parser)] @@ -178,11 +182,11 @@ pub enum AdminObject { public_key: Option<String>, }, /// A selected Oauth2.0 provider capable of authenticating identities - OauthProvider { - provider: OauthProvider, + Oauth2Provider { + provider: OauthProviderName, client_id: String, secret: String, - redirect_uri: String, + redirect_url: Url, }, /// A selected provider capable of sending SMS SmsProvider { @@ -320,7 +324,17 @@ pub enum CreateObject { } #[derive(Subcommand)] -pub enum DevObject {} +pub enum DevObject { + #[command( + about = "Create a temporary server to easily receive oauth validation during development.", + long_about = "Oauth2\n\nCreate a temporary server to easily receive oauth validation during development." + )] + Oauth2Server { + /// The port on which the server should listen. You must specify this exact port with your oauth provider. Defaults to 1337 + #[arg(long, short)] + port: Option<u16>, + }, +} #[derive(Subcommand)] pub enum ValidationMethod { @@ -335,9 +349,11 @@ pub enum ValidationMethod { Kerberos, /// An oauth2 provider to authenticate (and authorize) an identity Oauth2 { - provider: OauthProvider, + provider: OauthProviderName, /// An optional scope to use for authorization scope: Option<String>, + /// An optional existing identity to link to this validation request + identity: Option<IdentityId>, }, /// A phone which an identity may authenticate via SMS or voice Phone { @@ -349,28 +365,6 @@ pub enum ValidationMethod { Saml, } -#[derive(Clone, ValueEnum)] -pub enum OauthProvider { - Amazon, - Apple, - Dropbox, - Facebook, - Github, - Gitlab, - Google, - Instagram, - LinkedIn, - Microsoft, - Paypal, - Reddit, - Spotify, - Strava, - Stripe, - Twitch, - Twitter, - WeChat, -} - #[derive(Subcommand)] pub enum GetObject { ApiKey { @@ -507,3 +501,14 @@ pub struct ConfigProfile { pub email_template_login: Option<String>, pub email_template_signup: Option<String>, } + +#[derive(Serialize, Deserialize)] +pub struct Validation { + pub validation_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub oauth_auth_url: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option<String>, +} + +pub type ValidationSecretCode = String; diff --git a/crates/iam/src/command.rs b/crates/iam/src/command.rs index e9e0f23..980c4d0 100644 --- a/crates/iam/src/command.rs +++ b/crates/iam/src/command.rs @@ -1,6 +1,6 @@ use crate::{ - api, - util::{self, get_config_profile, Result}, + api::{self, CliError, Validation, ValidationSecretCode}, + util::{self, error_detail, get_config_profile, Result}, CONFIG_LOGIN_TEMPLATE, CONFIG_SIGNUP_TEMPLATE, }; use async_std::fs; @@ -9,10 +9,13 @@ use rand::distributions::{Alphanumeric, DistString}; use secd::{AuthEmail, AuthStore}; use std::{ fs::File, - io::{self, stdin, stdout, Write}, - str::FromStr, + io::{self, stdin, stdout, Read, Write}, + net::TcpListener, + str::{self, FromStr}, }; use strum::VariantNames; +use tiny_http::Server; +use uuid::Uuid; const DEFAULT_LOGIN_EMAIL: &str = "<!doctype html><html><body><p>You requested a login link for %secd_email_address%. Please click the following link<br/><br/>http://localhost:5500/myapp/iam/exchange/%secd_link%<br/><br/>or use code: %secd_code%</p></body></html>"; const DEFAULT_SIGNUP_EMAIL: &str = "<!doctype html><html><body><h1>Welcome to SecD IAM</h1></h1><p>If you did not request this sign up, you can safely ignore this email. Otherwise, please click the following link to validate your account<br/><br/>http://localhost:5500/myapp/iam/exchange/%secd_link%<br/><br/>or use code: %secd_code%</p></body></html>"; @@ -162,3 +165,44 @@ pub async fn admin_init(is_interactive: bool) -> Result<()> { } Ok(()) } + +pub fn dev_oauth2_listen(port: Option<u16>) -> Result<ValidationSecretCode> { + let server = Server::http(&format!("localhost:{}", port.unwrap_or(1337))).map_err(|_| { + CliError::InternalError(error_detail( + "53abd03d-c426-4bba-969d-f1dbed9af75b", + "Failure while creating a server to listen to oauth responese", + )) + })?; + + let parser = |s: &str| -> Option<ValidationSecretCode> { + let maybe_code = s.split("code=").collect::<Vec<&str>>(); + if maybe_code.len() != 2 { + None + } else { + let maybe_code = maybe_code + .last() + .map(|s| s.to_string()) + .map(|c| { + c.split("&") + .collect::<Vec<&str>>() + .first() + .map(|s| s.to_string()) + }) + .flatten(); + + maybe_code.map(|s| s.to_string()) + } + }; + + let mut s_code = String::new(); + for req in server.incoming_requests() { + match parser(req.url()) { + Some(secret_code) => { + s_code = secret_code; + break; + } + None => continue, + } + } + Ok(urlencoding::decode(&s_code)?.to_string()) +} diff --git a/crates/iam/src/main.rs b/crates/iam/src/main.rs index c187380..85b3e37 100644 --- a/crates/iam/src/main.rs +++ b/crates/iam/src/main.rs @@ -2,10 +2,17 @@ mod api; mod command; mod util; -use api::{AdminAction, Args, CliError, Command, CreateObject, GetObject, LinkObject, ListObject}; +use anyhow::bail; +use api::{ + AdminAction, AdminObject, Args, CliError, Command, CreateObject, DevObject, GetObject, + LinkObject, ListObject, Validation, +}; use clap::Parser; +use command::dev_oauth2_listen; +use env_logger::Env; use secd::{Secd, SecdError}; -use util::Result; +use util::{error_detail, Result}; +use uuid::Uuid; use crate::api::ValidationMethod; @@ -15,8 +22,9 @@ const CONFIG_LOGIN_TEMPLATE: &str = "default_login.html"; const CONFIG_SIGNUP_TEMPLATE: &str = "default_signup.html"; const ISSUE_TRACKER_LOC: &str = "https://www.github.com/secdiam/iam"; -#[async_std::main] +#[tokio::main] async fn main() { + env_logger::init_from_env(Env::default().default_filter_or("debug")); match exec().await { Ok(Some(s)) => println!("{}", s), Err(e) => { @@ -30,10 +38,15 @@ async fn main() { async fn exec() -> Result<Option<String>> { let args = Args::parse(); Ok(match args.command { - Command::Init { interactive } => admin(AdminAction::Init { interactive }) - .await - .map_err(|_| CliError::AdminInitializationError)?, - Command::Admin { action } => admin(action).await?, + Command::Init { interactive } + | Command::Admin { + action: AdminAction::Init { interactive }, + } => { + command::admin_init(interactive) + .await + .map_err(|_| CliError::AdminInitializationError)?; + None + } rest @ _ => { let cfg = util::read_config(args.profile).map_err(|_| CliError::InvalidProfile)?; @@ -48,8 +61,14 @@ async fn exec() -> Result<Option<String>> { .map_err(|e| CliError::SecdInitializationFailure(e.to_string()))?; match rest { + Command::Admin { action } => admin(&secd, action).await?, Command::Create { object } => create(&secd, object).await?, + Command::Dev { object } => dev(object).await?, Command::Get { object } => get(&secd, object).await?, + Command::Init { .. } => bail!(CliError::InternalError(error_detail( + "4a696b66-6231-4a2f-811c-4448a41473d2", + "Code path should be unreachable", + ))), Command::Link { object, unlink } => link(&secd, object, unlink).await?, Command::Ls { object, @@ -60,28 +79,30 @@ async fn exec() -> Result<Option<String>> { Command::Repl => { unimplemented!() } - _ => None, } } }) } -async fn admin(cmd: AdminAction) -> Result<Option<String>> { +async fn admin(secd: &Secd, cmd: AdminAction) -> Result<Option<String>> { Ok(match cmd { AdminAction::Backend { action } => { println!("do backend stuff!"); None } - AdminAction::Create { object } => { - println!("do create!"); - None - } - AdminAction::Init { interactive } => { - command::admin_init(interactive) - .await - .map_err(|_| CliError::AdminInitializationError)?; - None - } + AdminAction::Create { object } => match object { + AdminObject::Oauth2Provider { + provider, + client_id, + secret, + redirect_url, + } => { + secd.create_oauth_provider(&provider, client_id, secret, redirect_url) + .await?; + None + } + rest @ _ => unimplemented!(), + }, AdminAction::Seal => { println!("do seal"); None @@ -90,6 +111,9 @@ async fn admin(cmd: AdminAction) -> Result<Option<String>> { println!("do unseal: {}", secret_key); None } + AdminAction::Init { .. } => { + panic!("Invariant violation: this path should be impossible") + } }) } async fn create(secd: &Secd, cmd: CreateObject) -> Result<Option<String>> { @@ -130,19 +154,57 @@ async fn create(secd: &Secd, cmd: CreateObject) -> Result<Option<String>> { .await .map_err(|e| match e { SecdError::InvalidCode => CliError::InvalidCode, - _ => CliError::Unknown, + _ => CliError::InternalError(error_detail( + "17e5c226-5d7d-44a2-b3b5-be3ee958c252", + "An unknown error while exchanging a session", + )), })?; serde_json::to_string(&session).ok() } CreateObject::Validation { method, identity } => match method { - ValidationMethod::Email { address } => { - secd.create_validation_request(Some(&address)).await?; - None + ValidationMethod::Email { address } => serde_json::to_string(&Validation { + validation_id: secd.create_validation_request_email(Some(&address)).await?, + note: Some("<secret code> sent to client".into()), + oauth_auth_url: None, + }) + .ok(), + + ValidationMethod::Oauth2 { + provider, + scope, + identity, + } => { + let redirect = secd + .create_validation_request_oauth(&provider, scope) + .await? + .to_string(); + let validation_id = redirect + .split("state=") + .collect::<Vec<&str>>() + .last() + .map(|i| Uuid::parse_str(i).ok()) + .flatten() + .unwrap(); + serde_json::to_string(&Validation { + validation_id, + note: Some( + "<secret code> is retrieved by completing oauth flow in the browser".into(), + ), + oauth_auth_url: Some(redirect), + }) + .ok() } _ => unimplemented!(), }, }) } + +async fn dev(cmd: DevObject) -> Result<Option<String>> { + Ok(match cmd { + DevObject::Oauth2Server { port } => serde_json::to_string(&dev_oauth2_listen(port)?).ok(), + }) +} + async fn get(secd: &Secd, cmd: GetObject) -> Result<Option<String>> { Ok(match cmd { GetObject::ApiKey { public_key } => { diff --git a/crates/iam/src/util.rs b/crates/iam/src/util.rs index 01ce851..a74ea4a 100644 --- a/crates/iam/src/util.rs +++ b/crates/iam/src/util.rs @@ -86,3 +86,7 @@ pub fn read_config(profile_name: Option<String>) -> Result<ConfigProfile> { Ok(cfg) } + +pub fn error_detail(id: &str, d: &str) -> String { + format!("[debug info {}] {}", id, d) +} diff --git a/crates/secd/Cargo.toml b/crates/secd/Cargo.toml index 7e80277..d65bf51 100644 --- a/crates/secd/Cargo.toml +++ b/crates/secd/Cargo.toml @@ -6,13 +6,16 @@ edition = "2021" [dependencies] async-std = { version = "1.12.0", features = [ "attributes" ] } async-trait = "0.1" +anyhow = "1.0" base64 = "0.13.1" +clap = { version = "4.0.29", features = ["derive"] } derive_more = "0.99" email_address = "0.2" lazy_static = "1.4" log = "0.4" openssl = "0.10.42" rand = "0.8" +reqwest = { version = "0.11.13", features = ["json"] } serde = "1" serde_json = { version = "1.0", features = ["raw_value"] } strum = "0.24.1" @@ -20,4 +23,5 @@ strum_macros = "0.24" sqlx = { version = "0.6", features = [ "runtime-async-std-native-tls", "postgres", "uuid", "sqlite", "time" ] } time = { version = "0.3", features = [ "serde" ] } thiserror = "1.0" +url = "2.3.1" uuid = { version = "1.2", features = ["v4", "serde"]}
\ No newline at end of file diff --git a/crates/secd/src/client/mod.rs b/crates/secd/src/client/mod.rs index 3925657..38426ef 100644 --- a/crates/secd/src/client/mod.rs +++ b/crates/secd/src/client/mod.rs @@ -1,13 +1,24 @@ -pub mod email; -pub mod sqldb; +pub(crate) mod email; +pub(crate) mod sqldb; +pub(crate) mod types; -use std::collections::HashMap; +use std::{collections::HashMap, str::FromStr}; use super::Identity; -use crate::{EmailValidation, Session, SessionSecret}; +use crate::{ + EmailValidation, OauthProvider, OauthProviderName, OauthResponseType, OauthValidation, Session, + SessionSecret, ValidationRequestId, ValidationType, +}; +use email_address::EmailAddress; use lazy_static::lazy_static; +use sqlx::{ + database::HasValueRef, sqlite::SqliteRow, ColumnIndex, Database, Decode, FromRow, Row, Sqlite, + Type, +}; use thiserror::Error; +use time::OffsetDateTime; +use url::Url; use uuid::Uuid; pub enum EmailType { @@ -36,13 +47,15 @@ pub trait EmailMessenger { #[derive(Error, Debug, derive_more::Display)] pub enum StoreError { SqlxError(#[from] sqlx::Error), - EmailAlreadyExists, CodeAppearsMoreThanOnce, CodeDoesNotExist(String), IdentityIdMustExistInvariant, - TooManyEmailValidations, + TooManyValidations, + TooManyIdentitiesFound, NoEmailValidationFound, - Unknown, + OauthProviderDoesNotExist(OauthProviderName), + OauthValidationDoesNotExist(ValidationRequestId), + Other(String), } const EMAIL_TEMPLATE_DEFAULT_LOGIN: &str = "You requested a login link. Please click the following link %secd_code% to login as %secd_email_address%"; @@ -56,6 +69,7 @@ const PGSQL: &str = "pgsql"; const WRITE_IDENTITY: &str = "write_identity"; const WRITE_EMAIL_VALIDATION: &str = "write_email_validation"; const FIND_EMAIL_VALIDATION: &str = "find_email_validation"; +const READ_VALIDATION_TYPE: &str = "read_validation_type"; const WRITE_EMAIL: &str = "write_email"; @@ -69,6 +83,11 @@ const READ_EMAIL_RAW_ID: &str = "read_email_raw_id"; const WRITE_SESSION: &str = "write_session"; const READ_SESSION: &str = "read_session"; +const WRITE_OAUTH_PROVIDER: &str = "write_oauth_provider"; +const READ_OAUTH_PROVIDER: &str = "read_oauth_provider"; +const WRITE_OAUTH_VALIDATION: &str = "write_oauth_validation"; +const READ_OAUTH_VALIDATION: &str = "read_oauth_validation"; + lazy_static! { static ref SQLS: HashMap<&'static str, HashMap<&'static str, &'static str>> = { let sqlite_sqls: HashMap<&'static str, &'static str> = [ @@ -116,6 +135,26 @@ lazy_static! { FIND_EMAIL_VALIDATION, include_str!("../../store/sqlite/sql/find_email_validation.sql"), ), + ( + WRITE_OAUTH_PROVIDER, + include_str!("../../store/sqlite/sql/write_oauth_provider.sql"), + ), + ( + READ_OAUTH_PROVIDER, + include_str!("../../store/sqlite/sql/read_oauth_provider.sql"), + ), + ( + READ_OAUTH_VALIDATION, + include_str!("../../store/sqlite/sql/read_oauth_validation.sql"), + ), + ( + WRITE_OAUTH_VALIDATION, + include_str!("../../store/sqlite/sql/write_oauth_validation.sql"), + ), + ( + READ_VALIDATION_TYPE, + include_str!("../../store/sqlite/sql/read_validation_type.sql"), + ), ] .iter() .cloned() @@ -166,6 +205,26 @@ lazy_static! { FIND_EMAIL_VALIDATION, include_str!("../../store/pg/sql/find_email_validation.sql"), ), + ( + WRITE_OAUTH_PROVIDER, + include_str!("../../store/pg/sql/write_oauth_provider.sql"), + ), + ( + READ_OAUTH_PROVIDER, + include_str!("../../store/pg/sql/read_oauth_provider.sql"), + ), + ( + READ_OAUTH_VALIDATION, + include_str!("../../store/pg/sql/read_oauth_validation.sql"), + ), + ( + WRITE_OAUTH_VALIDATION, + include_str!("../../store/pg/sql/write_oauth_validation.sql"), + ), + ( + READ_VALIDATION_TYPE, + include_str!("../../store/pg/sql/read_validation_type.sql"), + ), ] .iter() .cloned() @@ -180,9 +239,143 @@ lazy_static! { }; } +impl<'a, R: Row> FromRow<'a, R> for OauthValidation +where + &'a str: ColumnIndex<R>, + OauthProviderName: Decode<'a, R::Database> + Type<R::Database>, + OauthResponseType: Decode<'a, R::Database> + Type<R::Database>, + OffsetDateTime: Decode<'a, R::Database> + Type<R::Database>, + String: Decode<'a, R::Database> + Type<R::Database>, + Uuid: Decode<'a, R::Database> + Type<R::Database>, +{ + fn from_row(row: &'a R) -> Result<Self, sqlx::Error> { + let id: Option<Uuid> = row.try_get("oauth_validation_public_id")?; + let identity_id: Option<Uuid> = row.try_get("identity_public_id")?; + let access_token: Option<String> = row.try_get("access_token")?; + let raw_response: Option<String> = row.try_get("raw_response")?; + let created_at: Option<OffsetDateTime> = row.try_get("created_at")?; + let validated_at: Option<OffsetDateTime> = row.try_get("validated_at")?; + let revoked_at: Option<OffsetDateTime> = row.try_get("revoked_at")?; + let deleted_at: Option<OffsetDateTime> = row.try_get("deleted_at")?; + + let op_name: Option<OauthProviderName> = row.try_get("oauth_provider_name")?; + let op_flow: Option<String> = row.try_get("oauth_provider_flow")?; + let op_base_url: Option<String> = row.try_get("oauth_provider_base_url")?; + let op_response_type: Option<OauthResponseType> = + row.try_get("oauth_provider_response_type")?; + let op_default_scope: Option<String> = row.try_get("oauth_provider_default_scope")?; + let op_client_id: Option<String> = row.try_get("oauth_provider_client_id")?; + let op_client_secret: Option<String> = row.try_get("oauth_provider_client_secret")?; + let op_redirect_url: Option<String> = row.try_get("oauth_provider_redirect_url")?; + let op_created_at: Option<OffsetDateTime> = row.try_get("oauth_provider_created_at")?; + let op_deleted_at: Option<OffsetDateTime> = row.try_get("oauth_provider_deleted_at")?; + + let op_base_url = op_base_url + .map(|s| Url::from_str(&s).ok()) + .flatten() + .ok_or(sqlx::Error::ColumnDecode { + index: "oauth_provider_base_url".into(), + source: "secd".into(), + })?; + + let op_redirect_url = op_redirect_url + .map(|s| Url::from_str(&s).ok()) + .flatten() + .ok_or(sqlx::Error::ColumnDecode { + index: "oauth_provider_redirect_url".into(), + source: "secd".into(), + })?; + + Ok(OauthValidation { + id, + identity_id, + access_token, + raw_response, + created_at: created_at.ok_or(sqlx::Error::ColumnDecode { + index: "created_at".into(), + source: "secd".into(), + })?, + validated_at, + revoked_at, + deleted_at, + oauth_provider: OauthProvider { + name: op_name.unwrap(), + flow: op_flow, + base_url: op_base_url, + response: op_response_type.ok_or(sqlx::Error::ColumnDecode { + index: "oauth_provider_response_type".into(), + source: "secd".into(), + })?, + default_scope: op_default_scope.ok_or(sqlx::Error::ColumnDecode { + index: "oauth_provider_default_scope".into(), + source: "secd".into(), + })?, + client_id: op_client_id.ok_or(sqlx::Error::ColumnDecode { + index: "oauth_provider_client_id".into(), + source: "secd".into(), + })?, + client_secret: op_client_secret.ok_or(sqlx::Error::ColumnDecode { + index: "oauth_provider_client_secret".into(), + source: "secd".into(), + })?, + redirect_url: op_redirect_url, + created_at: op_created_at.ok_or(sqlx::Error::ColumnDecode { + index: "oauth_provider_created_at".into(), + source: "secd".into(), + })?, + deleted_at: op_deleted_at, + }, + }) + } +} + +impl<'a, D: Database> Decode<'a, D> for OauthProviderName +where + &'a str: Decode<'a, D>, +{ + fn decode( + value: <D as HasValueRef<'a>>::ValueRef, + ) -> Result<Self, Box<dyn ::std::error::Error + 'static + Send + Sync>> { + let v = <&str as Decode<D>>::decode(value)?; + <OauthProviderName as clap::ValueEnum>::from_str(v, true) + .map_err(|_| "OauthProviderName should exist and decode to a program value.".into()) + } +} + +impl<D: Database> Type<D> for OauthProviderName +where + str: Type<D>, +{ + fn type_info() -> D::TypeInfo { + <&str as Type<D>>::type_info() + } +} + +impl<'a, D: Database> Decode<'a, D> for OauthResponseType +where + &'a str: Decode<'a, D>, +{ + fn decode( + value: <D as HasValueRef<'a>>::ValueRef, + ) -> Result<Self, Box<dyn ::std::error::Error + 'static + Send + Sync>> { + let v = <&str as Decode<D>>::decode(value)?; + <OauthResponseType as clap::ValueEnum>::from_str(v, true) + .map_err(|_| "OauthResponseType should exist and decode to a program value.".into()) + } +} + +impl<D: Database> Type<D> for OauthResponseType +where + str: Type<D>, +{ + fn type_info() -> D::TypeInfo { + <&str as Type<D>>::type_info() + } +} + #[async_trait::async_trait] pub trait Store { - async fn write_email(&self, identity_id: Uuid, email_address: &str) -> Result<(), StoreError>; + async fn write_email(&self, email_address: &str) -> Result<(), StoreError>; async fn find_email_validation( &self, @@ -193,17 +386,37 @@ pub trait Store { &self, ev: &EmailValidation, // TODO: Make this write an EmailValidation - ) -> Result<Uuid, StoreError>; + ) -> anyhow::Result<Uuid>; async fn find_identity( &self, identity_id: Option<&Uuid>, email: Option<&str>, - ) -> Result<Option<Identity>, StoreError>; + ) -> anyhow::Result<Option<Identity>>; async fn find_identity_by_code(&self, code: &str) -> Result<Identity, StoreError>; async fn write_identity(&self, i: &Identity) -> Result<(), StoreError>; async fn read_identity(&self, identity_id: &Uuid) -> Result<Identity, StoreError>; async fn write_session(&self, session: &Session) -> Result<(), StoreError>; async fn read_session(&self, secret: &SessionSecret) -> Result<Session, StoreError>; + + async fn write_oauth_provider(&self, provider: &OauthProvider) -> Result<(), StoreError>; + async fn read_oauth_provider( + &self, + provider: &OauthProviderName, + flow: Option<String>, + ) -> Result<OauthProvider, StoreError>; + async fn write_oauth_validation( + &self, + validation: &OauthValidation, + ) -> anyhow::Result<ValidationRequestId>; + async fn read_oauth_validation( + &self, + validation_id: &ValidationRequestId, + ) -> anyhow::Result<OauthValidation>; + + async fn find_validation_type( + &self, + validation_id: &ValidationRequestId, + ) -> anyhow::Result<ValidationType>; } diff --git a/crates/secd/src/client/sqldb.rs b/crates/secd/src/client/sqldb.rs index 6048c48..15cc4b5 100644 --- a/crates/secd/src/client/sqldb.rs +++ b/crates/secd/src/client/sqldb.rs @@ -1,19 +1,23 @@ -use std::sync::Arc; +use std::{str::FromStr, sync::Arc}; use super::{ - EmailValidation, Identity, Session, SessionSecret, Store, StoreError, ERR_MSG_MIGRATION_FAILED, - FIND_EMAIL_VALIDATION, FIND_IDENTITY, FIND_IDENTITY_BY_CODE, PGSQL, READ_EMAIL_RAW_ID, - READ_IDENTITY_RAW_ID, READ_SESSION, SQLITE, SQLS, WRITE_EMAIL, WRITE_EMAIL_VALIDATION, - WRITE_IDENTITY, WRITE_SESSION, + EmailValidation, Identity, OauthProvider, OauthProviderName, OauthResponseType, Session, + SessionSecret, Store, StoreError, ERR_MSG_MIGRATION_FAILED, FIND_EMAIL_VALIDATION, + FIND_IDENTITY, FIND_IDENTITY_BY_CODE, PGSQL, READ_EMAIL_RAW_ID, READ_IDENTITY_RAW_ID, + READ_OAUTH_PROVIDER, READ_OAUTH_VALIDATION, READ_SESSION, READ_VALIDATION_TYPE, SQLITE, SQLS, + WRITE_EMAIL, WRITE_EMAIL_VALIDATION, WRITE_IDENTITY, WRITE_OAUTH_PROVIDER, + WRITE_OAUTH_VALIDATION, WRITE_SESSION, }; -use crate::util; -use log::error; +use crate::{util, OauthValidation, ValidationRequestId, ValidationType}; +use anyhow::bail; +use log::{debug, error}; use openssl::sha::Sha256; use sqlx::{ self, database::HasArguments, ColumnIndex, Database, Decode, Encode, Executor, IntoArguments, Pool, Postgres, Sqlite, Transaction, Type, }; use time::OffsetDateTime; +use url::Url; use uuid::Uuid; fn get_sqls(root: &str, file: &str) -> Vec<String> { @@ -97,6 +101,8 @@ where for<'c> String: Encode<'c, D> + Type<D>, for<'c> Option<String>: Decode<'c, D> + Type<D>, for<'c> Option<String>: Encode<'c, D> + Type<D>, + for<'c> OauthProviderName: Decode<'c, D> + Type<D>, + for<'c> OauthResponseType: Decode<'c, D> + Type<D>, for<'c> usize: ColumnIndex<<D as Database>::Row>, for<'c> Uuid: Decode<'c, D> + Type<D>, for<'c> Uuid: Encode<'c, D> + Type<D>, @@ -108,29 +114,11 @@ where for<'c> &'c Pool<D>: Executor<'c, Database = D>, for<'c> &'c mut Transaction<'c, D>: Executor<'c, Database = D>, { - async fn write_email(&self, identity_id: Uuid, email_address: &str) -> Result<(), StoreError> { + async fn write_email(&self, email_address: &str) -> Result<(), StoreError> { let sqls = get_sqls(&self.sqls_root, WRITE_EMAIL); - let identity_id = self.read_identity_raw_id(&identity_id).await?; - - let email_id: (i64,) = match sqlx::query_as(&sqls[0]) + sqlx::query(&sqls[0]) .bind(email_address) - .fetch_one(&self.pool) - .await - { - Ok(i) => i, - Err(sqlx::Error::RowNotFound) => sqlx::query_as::<_, (i64,)>(&sqls[1]) - .bind(email_address) - .fetch_one(&self.pool) - .await - .map_err(util::log_err_sqlx)?, - Err(e) => return Err(StoreError::SqlxError(e)), - }; - - sqlx::query(&sqls[2]) - .bind(identity_id) - .bind(email_id.0) - .bind(OffsetDateTime::now_utc()) .execute(&self.pool) .await .map_err(util::log_err_sqlx)?; @@ -154,57 +142,84 @@ where match rows.len() { 0 => Err(StoreError::NoEmailValidationFound), 1 => Ok(rows.swap_remove(0)), - _ => Err(StoreError::TooManyEmailValidations), + _ => Err(StoreError::TooManyValidations), } } - async fn write_email_validation(&self, ev: &EmailValidation) -> Result<Uuid, StoreError> { + async fn write_email_validation(&self, ev: &EmailValidation) -> anyhow::Result<Uuid> { let sqls = get_sqls(&self.sqls_root, WRITE_EMAIL_VALIDATION); - let identity_id = self - .read_identity_raw_id( - &ev.identity_id - .ok_or(StoreError::IdentityIdMustExistInvariant)?, - ) - .await?; let email_id = self.read_email_raw_id(&ev.email_address).await?; - - let new_id = Uuid::new_v4(); + let validation_id = ev.id.unwrap_or(Uuid::new_v4()); sqlx::query(&sqls[0]) - .bind(ev.id.unwrap_or(new_id)) - .bind(identity_id) + .bind(validation_id) .bind(email_id) - .bind(ev.attempts) .bind(&ev.code) - .bind(ev.is_validated) + .bind(ev.is_oauth_derived) .bind(ev.created_at) - .bind(ev.expires_at) + .bind(ev.validated_at) + .bind(ev.expired_at) .execute(&self.pool) .await .map_err(util::log_err_sqlx)?; - Ok(new_id) + if ev.identity_id.is_some() || ev.revoked_at.is_some() || ev.deleted_at.is_some() { + sqlx::query(&sqls[1]) + .bind(ev.identity_id.as_ref()) + .bind(validation_id) + .bind(ev.revoked_at) + .bind(ev.deleted_at) + .execute(&self.pool) + .await + .map_err(util::log_err_sqlx)?; + } + + Ok(validation_id) } async fn find_identity( &self, id: Option<&Uuid>, email: Option<&str>, - ) -> Result<Option<Identity>, StoreError> { + ) -> anyhow::Result<Option<Identity>> { let sqls = get_sqls(&self.sqls_root, FIND_IDENTITY); Ok( match sqlx::query_as::<_, Identity>(&sqls[0]) .bind(id) .bind(email) - .fetch_one(&self.pool) + .fetch_all(&self.pool) .await { - Ok(i) => Some(i), + Ok(mut is) => match is.len() { + // if only 1 found, then that's fine + // if multiple are fond, then if they all have the same id, that's okay + 1 => { + let i = is.swap_remove(0); + match i.deleted_at { + Some(t) if t > OffsetDateTime::now_utc() => Some(i), + None => Some(i), + _ => None, + } + } + 0 => None, + _ => { + match is + .iter() + .filter(|&i| i.id != is[0].id) + .collect::<Vec<&Identity>>() + .len() + { + 0 => Some(is.swap_remove(0)), + _ => bail!(StoreError::TooManyIdentitiesFound), + } + } + }, Err(sqlx::Error::RowNotFound) => None, - Err(e) => return Err(StoreError::SqlxError(e)), + Err(e) => bail!(StoreError::SqlxError(e)), }, ) } + async fn find_identity_by_code(&self, code: &str) -> Result<Identity, StoreError> { let sqls = get_sqls(&self.sqls_root, FIND_IDENTITY_BY_CODE); @@ -250,14 +265,16 @@ where Ok(()) } async fn read_identity(&self, id: &Uuid) -> Result<Identity, StoreError> { - Ok(sqlx::query_as::<_, Identity>( + let identity = sqlx::query_as::<_, Identity>( " select identity_public_id, data, created_at from identity where identity_public_id = ?", ) .bind(id) .fetch_one(&self.pool) .await - .map_err(util::log_err_sqlx)?) + .map_err(util::log_err_sqlx)?; + + Ok(identity) } async fn write_session(&self, session: &Session) -> Result<(), StoreError> { @@ -269,7 +286,6 @@ select identity_public_id, data, created_at from identity where identity_public_ .bind(&session.identity_id) .bind(secret_hash.as_ref()) .bind(session.created_at) - .bind(OffsetDateTime::now_utc()) .bind(session.expires_at) .bind(session.revoked_at) .execute(&self.pool) @@ -296,6 +312,142 @@ select identity_public_id, data, created_at from identity where identity_public_ Ok(session) } + + async fn write_oauth_provider(&self, provider: &OauthProvider) -> Result<(), StoreError> { + let sqls = get_sqls(&self.sqls_root, WRITE_OAUTH_PROVIDER); + sqlx::query(&sqls[0]) + .bind(&provider.name.to_string()) + .bind(&provider.flow) + .bind(&provider.base_url.to_string()) + .bind(&provider.response.to_string()) + .bind(&provider.default_scope) + .bind(&provider.client_id) + // TODO: encrypt secret before writing + .bind(&provider.client_secret) + .bind(&provider.redirect_url.to_string()) + .bind(provider.created_at) + .bind(provider.deleted_at) + .execute(&self.pool) + .await + .map_err(util::log_err_sqlx)?; + Ok(()) + } + + async fn read_oauth_provider( + &self, + provider: &OauthProviderName, + flow: Option<String>, + ) -> Result<OauthProvider, StoreError> { + let sqls = get_sqls(&self.sqls_root, READ_OAUTH_PROVIDER); + let flow = flow.unwrap_or("default".into()); + debug!("provider: {:?}, flow: {:?}", provider, flow); + // TODO: Write the generic FromRow impl for OauthProvider... + let res = sqlx::query_as::< + _, + ( + String, + String, + String, + String, + String, + String, + String, + OffsetDateTime, + Option<OffsetDateTime>, + ), + >(&sqls[0]) + .bind(&provider.to_string()) + .bind(&flow) + .fetch_one(&self.pool) + .await + .map_err(util::log_err_sqlx)?; + + debug!("res: {:?}", res); + + Ok(OauthProvider { + name: provider.clone(), + flow: Some(res.0), + base_url: Url::from_str(&res.1) + .map_err(|_| StoreError::OauthProviderDoesNotExist(*provider))?, + response: OauthResponseType::from_str(&res.2) + .map_err(|_| StoreError::OauthProviderDoesNotExist(*provider))?, + default_scope: res.3, + client_id: res.4, + client_secret: res.5, + redirect_url: Url::from_str(&res.6) + .map_err(|_| StoreError::OauthProviderDoesNotExist(*provider))?, + created_at: res.7, + deleted_at: res.8, + }) + } + async fn write_oauth_validation( + &self, + v: &OauthValidation, + ) -> anyhow::Result<ValidationRequestId> { + let sqls = get_sqls(&self.sqls_root, WRITE_OAUTH_VALIDATION); + + let validation_id = v.id.unwrap_or(Uuid::new_v4()); + sqlx::query(&sqls[0]) + .bind(validation_id) + .bind(v.oauth_provider.name.to_string()) + .bind(v.oauth_provider.flow.clone()) + .bind(v.access_token.clone()) + .bind(v.raw_response.clone()) + .bind(v.created_at) + .bind(v.validated_at) + .execute(&self.pool) + .await?; + + if v.identity_id.is_some() || v.revoked_at.is_some() || v.deleted_at.is_some() { + sqlx::query(&sqls[1]) + .bind(v.identity_id.as_ref()) + .bind(validation_id) + .bind(v.revoked_at) + .bind(v.deleted_at) + .execute(&self.pool) + .await?; + } + + Ok(validation_id) + } + async fn read_oauth_validation( + &self, + validation_id: &ValidationRequestId, + ) -> anyhow::Result<OauthValidation> { + let sqls = get_sqls(&self.sqls_root, READ_OAUTH_VALIDATION); + + let mut es = sqlx::query_as::<_, OauthValidation>(&sqls[0]) + .bind(validation_id) + .fetch_all(&self.pool) + .await?; + + if es.len() != 1 { + bail!(StoreError::OauthValidationDoesNotExist( + validation_id.clone() + )); + } + + Ok(es.swap_remove(0)) + } + async fn find_validation_type( + &self, + validation_id: &ValidationRequestId, + ) -> anyhow::Result<ValidationType> { + let sqls = get_sqls(&self.sqls_root, READ_VALIDATION_TYPE); + + let mut es = sqlx::query_as::<_, (String,)>(&sqls[0]) + .bind(validation_id) + .fetch_all(&self.pool) + .await + .map_err(util::log_err_sqlx)?; + + match es.len() { + 1 => Ok(ValidationType::from_str(&es.swap_remove(0).0)?), + _ => bail!(StoreError::Other( + "expected a single validation but recieved 0 or multiple validations".into() + )), + } + } } pub struct PgClient { @@ -320,8 +472,8 @@ impl PgClient { #[async_trait::async_trait] impl Store for PgClient { - async fn write_email(&self, identity_id: Uuid, email_address: &str) -> Result<(), StoreError> { - self.sql.write_email(identity_id, email_address).await + async fn write_email(&self, email_address: &str) -> Result<(), StoreError> { + self.sql.write_email(email_address).await } async fn find_email_validation( &self, @@ -330,14 +482,14 @@ impl Store for PgClient { ) -> Result<EmailValidation, StoreError> { self.sql.find_email_validation(validation_id, code).await } - async fn write_email_validation(&self, ev: &EmailValidation) -> Result<Uuid, StoreError> { + async fn write_email_validation(&self, ev: &EmailValidation) -> anyhow::Result<Uuid> { self.sql.write_email_validation(ev).await } async fn find_identity( &self, identity_id: Option<&Uuid>, email: Option<&str>, - ) -> Result<Option<Identity>, StoreError> { + ) -> anyhow::Result<Option<Identity>> { self.sql.find_identity(identity_id, email).await } async fn find_identity_by_code(&self, code: &str) -> Result<Identity, StoreError> { @@ -355,6 +507,34 @@ impl Store for PgClient { async fn read_session(&self, secret: &SessionSecret) -> Result<Session, StoreError> { self.sql.read_session(secret).await } + async fn write_oauth_provider(&self, provider: &OauthProvider) -> Result<(), StoreError> { + self.sql.write_oauth_provider(provider).await + } + async fn read_oauth_provider( + &self, + provider: &OauthProviderName, + flow: Option<String>, + ) -> Result<OauthProvider, StoreError> { + self.sql.read_oauth_provider(provider, flow).await + } + async fn write_oauth_validation( + &self, + validation: &OauthValidation, + ) -> anyhow::Result<ValidationRequestId> { + self.sql.write_oauth_validation(validation).await + } + async fn read_oauth_validation( + &self, + validation_id: &ValidationRequestId, + ) -> anyhow::Result<OauthValidation> { + self.sql.read_oauth_validation(validation_id).await + } + async fn find_validation_type( + &self, + validation_id: &ValidationRequestId, + ) -> anyhow::Result<ValidationType> { + self.sql.find_validation_type(validation_id).await + } } pub struct SqliteClient { @@ -386,8 +566,8 @@ impl SqliteClient { #[async_trait::async_trait] impl Store for SqliteClient { - async fn write_email(&self, identity_id: Uuid, email_address: &str) -> Result<(), StoreError> { - self.sql.write_email(identity_id, email_address).await + async fn write_email(&self, email_address: &str) -> Result<(), StoreError> { + self.sql.write_email(email_address).await } async fn find_email_validation( &self, @@ -396,14 +576,14 @@ impl Store for SqliteClient { ) -> Result<EmailValidation, StoreError> { self.sql.find_email_validation(validation_id, code).await } - async fn write_email_validation(&self, ev: &EmailValidation) -> Result<Uuid, StoreError> { + async fn write_email_validation(&self, ev: &EmailValidation) -> anyhow::Result<Uuid> { self.sql.write_email_validation(ev).await } async fn find_identity( &self, identity_id: Option<&Uuid>, email: Option<&str>, - ) -> Result<Option<Identity>, StoreError> { + ) -> anyhow::Result<Option<Identity>> { self.sql.find_identity(identity_id, email).await } async fn find_identity_by_code(&self, code: &str) -> Result<Identity, StoreError> { @@ -421,4 +601,32 @@ impl Store for SqliteClient { async fn read_session(&self, secret: &SessionSecret) -> Result<Session, StoreError> { self.sql.read_session(secret).await } + async fn write_oauth_provider(&self, provider: &OauthProvider) -> Result<(), StoreError> { + self.sql.write_oauth_provider(provider).await + } + async fn read_oauth_provider( + &self, + provider: &OauthProviderName, + flow: Option<String>, + ) -> Result<OauthProvider, StoreError> { + self.sql.read_oauth_provider(provider, flow).await + } + async fn write_oauth_validation( + &self, + validation: &OauthValidation, + ) -> anyhow::Result<ValidationRequestId> { + self.sql.write_oauth_validation(validation).await + } + async fn read_oauth_validation( + &self, + validation_id: &ValidationRequestId, + ) -> anyhow::Result<OauthValidation> { + self.sql.read_oauth_validation(validation_id).await + } + async fn find_validation_type( + &self, + validation_id: &ValidationRequestId, + ) -> anyhow::Result<ValidationType> { + self.sql.find_validation_type(validation_id).await + } } diff --git a/crates/secd/src/client/types.rs b/crates/secd/src/client/types.rs new file mode 100644 index 0000000..bacade4 --- /dev/null +++ b/crates/secd/src/client/types.rs @@ -0,0 +1,3 @@ +pub(crate) struct Email { + address: String, +} diff --git a/crates/secd/src/command/admin.rs b/crates/secd/src/command/admin.rs new file mode 100644 index 0000000..b04dbef --- /dev/null +++ b/crates/secd/src/command/admin.rs @@ -0,0 +1,57 @@ +use std::str::FromStr; + +use time::OffsetDateTime; +use url::Url; + +use crate::{OauthProviderName, Secd, SecdError}; + +impl OauthProviderName { + fn base_url(&self) -> Url { + match self { + OauthProviderName::Google => { + Url::from_str("https://accounts.google.com/o/oauth2/v2/auth").unwrap() + } + OauthProviderName::Microsoft => { + Url::from_str("https://login.microsoftonline.com/common/oauth2/v2.0/authorize") + .unwrap() + } + _ => unimplemented!(), + } + } + + fn default_scope(&self) -> String { + match self { + OauthProviderName::Google => "openid%20email".into(), + OauthProviderName::Microsoft => "openid%20email".into(), + _ => unimplemented!(), + } + } +} + +impl Secd { + pub async fn create_oauth_provider( + &self, + provider: &OauthProviderName, + client_id: String, + client_secret: String, + redirect_url: Url, + ) -> Result<(), SecdError> { + self.store + .write_oauth_provider(&crate::OauthProvider { + name: provider.clone(), + flow: Some("default".into()), + base_url: provider.base_url(), + response: crate::OauthResponseType::Code, + default_scope: provider.default_scope(), + client_id, + client_secret, + redirect_url, + created_at: OffsetDateTime::now_utc(), + deleted_at: None, + }) + .await + .map_err(|_| SecdError::Todo)?; + + Ok(()) + } +} diff --git a/crates/secd/src/command/authn.rs b/crates/secd/src/command/authn.rs new file mode 100644 index 0000000..862d921 --- /dev/null +++ b/crates/secd/src/command/authn.rs @@ -0,0 +1,230 @@ +use email_address::EmailAddress; +use log::debug; +use rand::distributions::{Alphanumeric, DistString}; +use time::Duration; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::util::{build_oauth_auth_url, get_oauth_access_token}; +use crate::OauthRedirectAuthUrl; +use crate::Validation; +use crate::ValidationType; +use crate::INTERNAL_ERR_MSG; +use crate::{ + client, util, EmailValidation, Identity, OauthProviderName, Secd, SecdError, Session, + ValidationRequestId, ValidationSecretCode, EMAIL_VALIDATION_DURATION, SESSION_DURATION, + SESSION_SIZE_BYTES, VALIDATION_CODE_SIZE, +}; + +impl Secd { + /// create_validation_request_oauth + /// + /// Generate a request to validate with the specified oauth provider.[ + // TODO: How to handle different oauth "flows"? e.g. web app vs desktop vs mobile... + pub async fn create_validation_request_oauth( + &self, + provider: &OauthProviderName, + scope: Option<String>, + ) -> Result<OauthRedirectAuthUrl, SecdError> { + if scope.is_some() { + return Err(SecdError::NotImplemented( + "Only default scopes are currently supported.".into(), + )); + } + + let p = self + .store + .read_oauth_provider(provider, None) + .await + .map_err(|_| SecdError::InternalError(INTERNAL_ERR_MSG.to_string()))?; + + let req_id = self + .store + .write_oauth_validation(&crate::OauthValidation { + id: Some(Uuid::new_v4()), + identity_id: None, + oauth_provider: p.clone(), + access_token: None, + raw_response: None, + created_at: OffsetDateTime::now_utc(), + validated_at: None, + revoked_at: None, + deleted_at: None, + }) + .await + .map_err(|e| util::to_secd_err(e, SecdError::OauthValidationRequestError))?; + + build_oauth_auth_url(&p, req_id) + } + /// create_validation_request_email + /// + /// Generate a request to validate the provided email. + pub async fn create_validation_request_email( + &self, + email: Option<&str>, + ) -> Result<ValidationRequestId, SecdError> { + let now = OffsetDateTime::now_utc(); + + let email = match email { + Some(ea) => { + if EmailAddress::is_valid(ea) { + ea + } else { + return Err(SecdError::InvalidEmailAddress); + } + } + None => return Err(SecdError::InvalidEmailAddress), + }; + + let mut ev = EmailValidation { + id: None, + identity_id: None, + email_address: email.to_string(), + code: Some( + Alphanumeric + .sample_string(&mut rand::thread_rng(), VALIDATION_CODE_SIZE) + .to_lowercase(), + ), + is_oauth_derived: false, + created_at: now, + expired_at: now + .checked_add(Duration::new(EMAIL_VALIDATION_DURATION, 0)) + .ok_or(SecdError::EmailValidationExpiryOverflow)?, + validated_at: None, + revoked_at: None, + deleted_at: None, + }; + + let (req_id, mail_type) = match self + .store + .find_identity(None, Some(email)) + .await + .map_err(|e| util::log_err(e.into(), SecdError::Todo))? + { + Some(identity) => { + let req_id = { + ev.identity_id = Some(identity.id); + self.store + .write_email_validation(&ev) + .await + .map_err(|e| util::log_err(e.into(), SecdError::Todo))? + }; + (req_id, client::EmailType::Login) + } + None => { + self.store + .write_email(email) + .await + .map_err(|e| util::log_err(e.into(), SecdError::Todo))?; + + let req_id = { + self.store + .write_email_validation(&ev) + .await + .map_err(|e| util::log_err(e.into(), SecdError::Todo))? + }; + + (req_id, client::EmailType::Signup) + } + }; + + self.email_messenger + .send_email(email, &req_id.to_string(), &ev.code.unwrap(), mail_type) + .await?; + + Ok(req_id) + } + /// exchange_secret_for_session + /// + /// Exchanges a secret, which consists of a validation_request_id and secret_code + /// for a session which allows authentication on behalf of the associated identity. + /// + /// Session secrets should be used to return authorization for the associated identity. + pub async fn exchange_code_for_session( + &self, + validation_request_id: ValidationRequestId, + code: ValidationSecretCode, + ) -> Result<Session, SecdError> { + let mut v: Box<dyn Validation> = match self + .store + .find_validation_type(&validation_request_id) + .await + .map_err(|e| util::to_secd_err(e, SecdError::Todo))? + { + ValidationType::Email => Box::new( + self.store + .find_email_validation(Some(&validation_request_id), Some(&code)) + .await + .map_err(|e| { + util::log_err(e.into(), SecdError::EmailValidationExpiryOverflow) + })?, + ), + ValidationType::Oauth => Box::new({ + let mut t = self + .store + .read_oauth_validation(&validation_request_id) + .await + .map_err(|e| util::to_secd_err(e, SecdError::Todo))?; + + let access_token = get_oauth_access_token(&t, &code) + .await + .map_err(|_| SecdError::Todo)?; + + t.access_token = Some(access_token); + t + }), + }; + + if v.expired() || v.is_validated() { + return Err(SecdError::InvalidCode); + }; + + let mut identity = Identity { + id: Uuid::new_v4(), + data: None, + created_at: OffsetDateTime::now_utc(), + deleted_at: None, + }; + + match v + .find_associated_identities(self.store.clone()) + .await + .map_err(|e| util::to_secd_err(e, SecdError::IdentityIdShouldExistInvariant))? + { + Some(i) => identity.id = i.id, + _ => self.store.write_identity(&identity).await.map_err(|_| { + SecdError::InternalError("failed to write identity during session exchange".into()) + })?, + }; + + v.validate(&identity, self.store.clone()) + .await + .map_err(|e| { + util::to_secd_err( + e, + SecdError::InternalError( + "failed to update validation during session exchange".into(), + ), + ) + })?; + + // TODO: clear previous sessions if they fit the criteria + let now = OffsetDateTime::now_utc(); + let s = Session { + identity_id: identity.id, + secret: Some(Alphanumeric.sample_string(&mut rand::thread_rng(), SESSION_SIZE_BYTES)), + created_at: now, + expires_at: now + .checked_add(Duration::new(SESSION_DURATION, 0)) + .ok_or(SecdError::SessionExpiryOverflow)?, + revoked_at: None, + }; + + self.store + .write_session(&s) + .await + .map_err(|e| util::log_err(e.into(), SecdError::Todo))?; + + Ok(s) + } +} diff --git a/crates/secd/src/command/mod.rs b/crates/secd/src/command/mod.rs new file mode 100644 index 0000000..cd0d8c3 --- /dev/null +++ b/crates/secd/src/command/mod.rs @@ -0,0 +1,66 @@ +pub mod admin; +pub mod authn; + +use crate::client::{ + email, + sqldb::{PgClient, SqliteClient}, +}; +use crate::{AuthEmail, AuthStore, Secd, SecdError}; +use log::error; +use std::sync::Arc; + +impl Secd { + /// init + /// + /// Initialize SecD with the specified configuration, established the necessary + /// constraints, persistance stores, and options. + pub async fn init( + auth_store: AuthStore, + conn_string: Option<&str>, + email_messenger: AuthEmail, + email_template_login: Option<String>, + email_template_signup: Option<String>, + ) -> Result<Self, SecdError> { + let store = match auth_store { + AuthStore::Sqlite => { + SqliteClient::new( + sqlx::sqlite::SqlitePoolOptions::new() + .connect(conn_string.unwrap_or("sqlite::memory:".into())) + .await + .map_err(|e| SecdError::InitializationFailure(e))?, + ) + .await + } + AuthStore::Postgres => { + PgClient::new( + sqlx::postgres::PgPoolOptions::new() + .connect(conn_string.expect("No postgres connection string provided.")) + .await + .map_err(|e| SecdError::InitializationFailure(e))?, + ) + .await + } + rest @ _ => { + error!( + "requested an AuthStore which has not yet been implemented: {:?}", + rest + ); + unimplemented!() + } + }; + + let email_sender = match email_messenger { + // TODO: initialize email and SMS templates with secd + AuthEmail::LocalStub => email::LocalEmailStubber { + email_template_login, + email_template_signup, + }, + _ => unimplemented!(), + }; + + Ok(Secd { + store, + email_messenger: Arc::new(email_sender), + }) + } +} diff --git a/crates/secd/src/lib.rs b/crates/secd/src/lib.rs index 4feda04..faa92ca 100644 --- a/crates/secd/src/lib.rs +++ b/crates/secd/src/lib.rs @@ -1,28 +1,28 @@ mod client; +mod command; mod util; use std::sync::Arc; -use client::{ - email, - sqldb::{PgClient, SqliteClient}, - EmailMessenger, EmailMessengerError, Store, StoreError, -}; +use clap::ValueEnum; +use client::{EmailMessenger, EmailMessengerError, Store}; use derive_more::Display; use email_address::EmailAddress; -use log::error; -use rand::distributions::{Alphanumeric, DistString}; use serde::{Deserialize, Serialize}; +use sqlx::FromRow; use strum_macros::{EnumString, EnumVariantNames}; -use time::{Duration, OffsetDateTime}; +use time::OffsetDateTime; +use url::Url; +use util::get_oauth_identity_data; use uuid::Uuid; const SESSION_SIZE_BYTES: usize = 32; const SESSION_DURATION: i64 = 60 /* seconds*/ * 60 /* minutes */ * 24 /* hours */ * 360 /* days */; const EMAIL_VALIDATION_DURATION: i64 = 60 /* seconds*/ * 15 /* minutes */; -const VALIDATION_ATTEMPTS_MAX: i32 = 5; const VALIDATION_CODE_SIZE: usize = 6; +const INTERNAL_ERR_MSG: &str = "It seems an invariant was borked or something non-deterministic happened. Please file a bug with secd."; + #[derive(sqlx::FromRow, Debug, Serialize)] pub struct ApiKey { pub public_key: String, @@ -38,9 +38,11 @@ pub struct Authorization { pub struct Identity { #[sqlx(rename = "identity_public_id")] id: Uuid, - created_at: OffsetDateTime, #[serde(skip_serializing_if = "Option::is_none")] data: Option<String>, + created_at: OffsetDateTime, + #[serde(skip_serializing_if = "Option::is_none")] + deleted_at: Option<OffsetDateTime>, } #[derive(sqlx::FromRow, Debug, Serialize)] @@ -58,6 +60,121 @@ pub struct Session { pub revoked_at: Option<OffsetDateTime>, } +#[async_trait::async_trait] +trait Validation { + fn expired(&self) -> bool; + fn is_validated(&self) -> bool; + async fn find_associated_identities( + &self, + store: Arc<dyn Store + Send + Sync>, + ) -> anyhow::Result<Option<Identity>>; + async fn validate( + &mut self, + i: &Identity, + store: Arc<dyn Store + Send + Sync>, + ) -> anyhow::Result<()>; +} + +#[async_trait::async_trait] +impl Validation for EmailValidation { + fn expired(&self) -> bool { + let now = OffsetDateTime::now_utc(); + self.expired_at < now + || self.revoked_at.map(|t| t < now).unwrap_or(false) + || self.deleted_at.map(|t| t < now).unwrap_or(false) + } + fn is_validated(&self) -> bool { + self.validated_at + .map(|t| t >= OffsetDateTime::now_utc()) + .unwrap_or(false) + } + async fn find_associated_identities( + &self, + store: Arc<dyn Store + Send + Sync>, + ) -> anyhow::Result<Option<Identity>> { + store.find_identity(None, Some(&self.email_address)).await + } + async fn validate( + &mut self, + i: &Identity, + store: Arc<dyn Store + Send + Sync>, + ) -> anyhow::Result<()> { + self.identity_id = Some(i.id); + self.validated_at = Some(OffsetDateTime::now_utc()); + store.write_email_validation(&self).await?; + Ok(()) + } +} + +#[async_trait::async_trait] +impl Validation for OauthValidation { + fn expired(&self) -> bool { + let now = OffsetDateTime::now_utc(); + self.revoked_at.map(|t| t < now).unwrap_or(false) + || self.deleted_at.map(|t| t < now).unwrap_or(false) + } + fn is_validated(&self) -> bool { + self.validated_at + .map(|t| t >= OffsetDateTime::now_utc()) + .unwrap_or(false) + } + async fn find_associated_identities( + &self, + store: Arc<dyn Store + Send + Sync>, + ) -> anyhow::Result<Option<Identity>> { + let oauth_identity = get_oauth_identity_data(&self).await?; + + let identity = store + .find_identity(None, oauth_identity.email.as_deref()) + .await?; + + let now = OffsetDateTime::now_utc(); + if let Some(email) = oauth_identity.email.clone() { + let identity = identity.unwrap_or(Identity { + id: Uuid::new_v4(), + data: None, + created_at: OffsetDateTime::now_utc(), + deleted_at: None, + }); + store.write_identity(&identity).await?; + store.write_email(&email).await?; + store + .write_email_validation(&EmailValidation { + id: Some(Uuid::new_v4()), + identity_id: Some(identity.id), + email_address: email, + code: None, + is_oauth_derived: true, + created_at: now, + expired_at: now, + validated_at: Some(now), + revoked_at: None, + deleted_at: None, + }) + .await?; + Ok(Some(identity)) + } else { + Ok(identity) + } + } + async fn validate( + &mut self, + i: &Identity, + store: Arc<dyn Store + Send + Sync>, + ) -> anyhow::Result<()> { + self.identity_id = Some(i.id); + self.validated_at = Some(OffsetDateTime::now_utc()); + store.write_oauth_validation(&self).await?; + Ok(()) + } +} + +#[derive(Debug, EnumString)] +pub enum ValidationType { + Email, + Oauth, +} + #[derive(sqlx::FromRow, Debug)] pub struct EmailValidation { #[sqlx(rename = "email_validation_public_id")] @@ -66,16 +183,53 @@ pub struct EmailValidation { identity_id: Option<IdentityId>, #[sqlx(rename = "address")] email_address: String, - attempts: i32, - code: String, - is_validated: bool, + code: Option<String>, + is_oauth_derived: bool, + created_at: OffsetDateTime, + expired_at: OffsetDateTime, + validated_at: Option<OffsetDateTime>, + revoked_at: Option<OffsetDateTime>, + deleted_at: Option<OffsetDateTime>, +} + +#[derive(Debug)] +pub struct OauthValidation { + id: Option<Uuid>, + identity_id: Option<IdentityId>, + oauth_provider: OauthProvider, + access_token: Option<String>, + raw_response: Option<String>, created_at: OffsetDateTime, - expires_at: OffsetDateTime, + validated_at: Option<OffsetDateTime>, revoked_at: Option<OffsetDateTime>, + deleted_at: Option<OffsetDateTime>, +} + +#[derive(Debug, Clone)] +pub struct OauthProvider { + pub name: OauthProviderName, + pub flow: Option<String>, + pub base_url: Url, + pub response: OauthResponseType, + pub default_scope: String, + pub client_id: String, + pub client_secret: String, + pub redirect_url: Url, + pub created_at: OffsetDateTime, + pub deleted_at: Option<OffsetDateTime>, +} + +#[derive(Debug, Display, Clone, Copy, ValueEnum, EnumString)] +pub enum OauthResponseType { + Code, + IdToken, + None, + Token, } -#[derive(Copy, Display, Clone, Debug)] -pub enum OauthProvider { +// TODO: feature gate ValueEnum since it's only needed for iam builds +#[derive(Copy, Display, Clone, Debug, ValueEnum, EnumString)] +pub enum OauthProviderName { Amazon, Apple, Dropbox, @@ -121,19 +275,24 @@ pub type SessionSecret = String; pub type SessionSecretHash = String; pub type ValidationRequestId = Uuid; pub type ValidationSecretCode = String; +pub type OauthRedirectAuthUrl = Url; #[derive(Debug, derive_more::Display, thiserror::Error)] pub enum SecdError { - InvalidEmailAddress, - InvalidCode, - InitializationFailure(sqlx::Error), - IdentityIdShouldExistInvariant, EmailSendError(#[from] EmailMessengerError), - EmailValidationRequestError, EmailValidationExpiryOverflow, + EmailValidationRequestError, + OauthValidationRequestError, + IdentityIdShouldExistInvariant, + InitializationFailure(sqlx::Error), + InvalidCode, + InvalidEmailAddress, + InputValidation(String), + InternalError(String), + NotImplemented(String), SessionExpiryOverflow, Unauthenticated, - Unknown, + Todo, } pub struct Secd { @@ -142,191 +301,6 @@ pub struct Secd { } impl Secd { - pub async fn init( - auth_store: AuthStore, - conn_string: Option<&str>, - email_messenger: AuthEmail, - email_template_login: Option<String>, - email_template_signup: Option<String>, - ) -> Result<Self, SecdError> { - let store = match auth_store { - AuthStore::Sqlite => { - SqliteClient::new( - sqlx::sqlite::SqlitePoolOptions::new() - .connect(conn_string.unwrap_or("sqlite::memory:".into())) - .await - .map_err(|e| SecdError::InitializationFailure(e))?, - ) - .await - } - AuthStore::Postgres => { - PgClient::new( - sqlx::postgres::PgPoolOptions::new() - .connect(conn_string.expect("No postgres connection string provided.")) - .await - .map_err(|e| SecdError::InitializationFailure(e))?, - ) - .await - } - rest @ _ => { - error!( - "requested an AuthStore which has not yet been implemented: {:?}", - rest - ); - unimplemented!() - } - }; - - let email_sender = match email_messenger { - // TODO: initialize email and SMS templates with secd - AuthEmail::LocalStub => email::LocalEmailStubber { - email_template_login, - email_template_signup, - }, - _ => unimplemented!(), - }; - - Ok(Secd { - store, - email_messenger: Arc::new(email_sender), - }) - } - /// create_validation_request - /// - /// Generate a request to validate the provided email. - pub async fn create_validation_request( - &self, - email: Option<&str>, - ) -> Result<ValidationRequestId, SecdError> { - let now = OffsetDateTime::now_utc(); - - let email = match email { - Some(ea) => { - if EmailAddress::is_valid(ea) { - ea - } else { - return Err(SecdError::InvalidEmailAddress); - } - } - None => return Err(SecdError::InvalidEmailAddress), - }; - - let mut ev = EmailValidation { - id: None, - identity_id: None, - email_address: email.to_string(), - attempts: 0, - code: Alphanumeric - .sample_string(&mut rand::thread_rng(), VALIDATION_CODE_SIZE) - .to_lowercase(), - is_validated: false, - created_at: now, - expires_at: now - .checked_add(Duration::new(EMAIL_VALIDATION_DURATION, 0)) - .ok_or(SecdError::EmailValidationExpiryOverflow)?, - revoked_at: None, - }; - - let (req_id, mail_type) = match self - .store - .find_identity(None, Some(email)) - .await - .map_err(|e| util::log_err(e.into(), SecdError::Unknown))? - { - Some(identity) => { - let req_id = { - ev.identity_id = Some(identity.id); - self.store - .write_email_validation(&ev) - .await - .map_err(|e| util::log_err(e.into(), SecdError::Unknown))? - }; - (req_id, client::EmailType::Login) - } - None => { - let identity = Identity { - id: Uuid::new_v4(), - created_at: OffsetDateTime::now_utc(), - data: None, - }; - self.store - .write_identity(&identity) - .await - .map_err(|e| util::log_err(e.into(), SecdError::Unknown))?; - self.store - .write_email(identity.id, email) - .await - .map_err(|e| util::log_err(e.into(), SecdError::Unknown))?; - - let req_id = { - ev.identity_id = Some(identity.id); - self.store - .write_email_validation(&ev) - .await - .map_err(|e| util::log_err(e.into(), SecdError::Unknown))? - }; - - (req_id, client::EmailType::Signup) - } - }; - - self.email_messenger - .send_email(email, &req_id.to_string(), &ev.code, mail_type) - .await?; - - Ok(req_id) - } - /// exchange_secret_for_session - /// - /// Exchanges a secret, which consists of a validation_request_id and secret_code - /// for a session which allows authentication on behalf of the associated identity. - /// - /// Session secrets should be used to return authorization for the associated identity. - pub async fn exchange_code_for_session( - &self, - validation_request_id: ValidationRequestId, - code: ValidationSecretCode, - ) -> Result<Session, SecdError> { - let mut ev = self - .store - .find_email_validation(Some(&validation_request_id), Some(&code)) - .await - .map_err(|e| util::log_err(e.into(), SecdError::EmailValidationExpiryOverflow))?; - - if ev.is_validated - || ev.expires_at < OffsetDateTime::now_utc() - || ev.attempts >= VALIDATION_ATTEMPTS_MAX - { - return Err(SecdError::InvalidCode); - }; - - ev.is_validated = true; - ev.attempts += 1; - self.store - .write_email_validation(&ev) - .await - .map_err(|e| util::log_err(e.into(), SecdError::Unknown))?; - - // TODO: clear previous sessions if they fit the criteria - let now = OffsetDateTime::now_utc(); - let s = Session { - identity_id: ev - .identity_id - .ok_or(SecdError::IdentityIdShouldExistInvariant)?, - secret: Some(Alphanumeric.sample_string(&mut rand::thread_rng(), SESSION_SIZE_BYTES)), - created_at: now, - expires_at: now - .checked_add(Duration::new(SESSION_DURATION, 0)) - .ok_or(SecdError::SessionExpiryOverflow)?, - revoked_at: None, - }; - self.store - .write_session(&s) - .await - .map_err(|e| util::log_err(e.into(), SecdError::Unknown))?; - - Ok(s) - } /// get_identity /// /// Return all information associated with the identity id. @@ -350,7 +324,7 @@ impl Secd { Ok(Authorization { session }) } Ok(_) => Err(SecdError::Unauthenticated), - Err(_e) => Err(SecdError::Unknown), + Err(_e) => Err(SecdError::Todo), } } /// revoke_session diff --git a/crates/secd/src/util/mod.rs b/crates/secd/src/util/mod.rs index da16901..bb177cb 100644 --- a/crates/secd/src/util/mod.rs +++ b/crates/secd/src/util/mod.rs @@ -1,13 +1,27 @@ +use std::str::FromStr; + +use anyhow::{bail, Context}; use log::error; use rand::distributions::Alphanumeric; use rand::{thread_rng, Rng}; +use reqwest::header; +use serde::{Deserialize, Serialize}; +use url::Url; -use crate::SecdError; +use crate::{ + OauthProvider, OauthProviderName, OauthValidation, SecdError, ValidationRequestId, + INTERNAL_ERR_MSG, +}; pub(crate) fn log_err(e: Box<dyn std::error::Error>, new_e: SecdError) -> SecdError { error!("{:?}", e); new_e } +pub(crate) fn to_secd_err(e: anyhow::Error, new_e: SecdError) -> SecdError { + error!("{:?}", e); + new_e +} + pub(crate) fn log_err_sqlx(e: sqlx::Error) -> sqlx::Error { error!("{:?}", e); e @@ -19,3 +33,145 @@ pub(crate) fn generate_random_url_safe(n: usize) -> String { .map(char::from) .collect() } + +pub(crate) fn remove_trailing_slash(url: &mut Url) -> String { + let mut u = url.to_string(); + + if u.ends_with('/') { + u.pop(); + } + + u +} + +pub(crate) fn build_oauth_auth_url( + p: &OauthProvider, + validation_id: ValidationRequestId, +) -> Result<Url, SecdError> { + let redirect_url = remove_trailing_slash(&mut p.redirect_url.clone()); + + Ok(Url::from_str(&format!( + "{}?client_id={}&response_type={}&redirect_uri={}&scope={}&state={}", + p.base_url, + p.client_id, + p.response.to_string().to_lowercase(), + redirect_url, + p.default_scope, + validation_id.to_string() + )) + .map_err(|_| SecdError::InternalError(INTERNAL_ERR_MSG.into()))?) +} + +pub(crate) async fn get_oauth_identity_data( + validation: &OauthValidation, +) -> anyhow::Result<OauthAccessIdentity> { + let provider = validation.oauth_provider.name; + let token = validation + .access_token + .clone() + .ok_or(SecdError::InternalError( + "no access token provided with which to build oauth data url".into(), + ))?; + + let url = Url::from_str(&format!( + "{}{}", + match provider { + OauthProviderName::Google => + "https://www.googleapis.com/oauth2/v2/userinfo?access_token=", + _ => unimplemented!(), + }, + token + ))?; + + let resp = reqwest::get(url).await?.json::<serde_json::Value>().await?; + let identity = match provider { + OauthProviderName::Google => OauthAccessIdentity { + email: resp + .get("email") + .and_then(|v| v.as_str().map(|s| s.to_string())), + email_is_verified: resp.get("verified_email").and_then(|v| v.as_bool()), + picture_url: resp + .get("picture") + .and_then(|v| Url::from_str(&v.to_string()).ok()), + }, + _ => unimplemented!(), + }; + + Ok(identity) +} + +#[derive(Debug, Serialize)] +pub(crate) struct OauthAccessTokenGoogleRequest { + grant_type: String, + code: String, + client_id: String, + client_secret: String, + redirect_uri: String, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct OauthAccessTokenGoogleResponse { + access_token: String, + expires_in: i32, + token_type: String, + scope: String, + id_token: String, +} + +#[derive(Debug)] +pub(crate) struct OauthAccessIdentity { + pub(crate) email: Option<String>, + pub(crate) email_is_verified: Option<bool>, + pub(crate) picture_url: Option<Url>, +} + +type AccessTokenRequestData = String; + +pub(crate) async fn get_oauth_access_token( + validation: &OauthValidation, + secret_code: &String, +) -> anyhow::Result<String> { + let provider = validation.oauth_provider.name; + + let url = Url::from_str(match provider { + OauthProviderName::Google => "https://accounts.google.com/o/oauth2/token", + _ => unimplemented!(), + })?; + + let request_data = serde_json::to_string(&match provider { + OauthProviderName::Google => OauthAccessTokenGoogleRequest { + grant_type: "authorization_code".to_string(), + code: secret_code.to_string(), + client_id: validation.oauth_provider.client_id.clone(), + client_secret: validation.oauth_provider.client_secret.clone(), + redirect_uri: remove_trailing_slash( + &mut validation.oauth_provider.redirect_url.clone(), + ), + }, + _ => unimplemented!(), + })?; + + let r = reqwest::Client::new() + .post(url) + .body(request_data) + .header(header::CONTENT_TYPE, "application/json") + .send() + .await + .context(format!( + "Failed to successfully POST a new access token for: {}", + provider + ))?; + + let access_token = match provider { + OauthProviderName::Google => { + let resp: OauthAccessTokenGoogleResponse = r.json().await.context(format!( + "Failed to parse access token response for: {}", + provider + ))?; + resp.access_token + } + _ => unimplemented!(), + }; + + Ok(access_token) +} diff --git a/crates/secd/store/pg/migrations/20221116062550_bootstrap.sql b/crates/secd/store/pg/migrations/20221116062550_bootstrap.sql index 3f5fb40..3d4d84c 100644 --- a/crates/secd/store/pg/migrations/20221116062550_bootstrap.sql +++ b/crates/secd/store/pg/migrations/20221116062550_bootstrap.sql @@ -3,47 +3,84 @@ create extension if not exists citext; create schema if not exists secd; create table if not exists secd.identity ( - identity_id bigserial primary key + identity_id bigserial primary key , identity_public_id uuid , data text - , created_at timestamptz not null + , created_at timestamptz not null + , deleted_at timestamptz , unique(identity_public_id) ); -create table if not exists secd.email ( - email_id bigserial primary key - , address text not null - , unique(address) +create table if not exists secd.session ( + session_id bigserial primary key + , identity_id bigint not null references secd.identity(identity_id) + , secret_hash bytea not null + , created_at timestamptz not null + , expired_at timestamptz + , revoked_at timestamptz + , unique(secret_hash) ); -create table if not exists secd.identity_email ( - identity_email_id bigserial primary key - , identity_id bigint not null references secd.identity(identity_id) - , email_id bigint not null references secd.email(email_id) +create table if not exists secd.oauth_provider ( + oauth_provider_id serial primary key + , name text not null + , flow text not null + , base_url text not null + , response_type text not null + , default_scope text + , client_id text not null + , client_secret text not null + , redirect_url text not null , created_at timestamptz not null , deleted_at timestamptz + , unique (name, flow) +); + +create table if not exists secd.oauth_validation ( + oauth_validation_id bigserial primary key + , oauth_validation_public_id uuid not null + , oauth_provider_id integer not null references secd.oauth_provider(oauth_provider_id) + , access_token text + , raw_response text + , created_at timestamptz not null + , validated_at timestamptz + , unique (oauth_validation_public_id) +); + +create table if not exists secd.identity_oauth_validation ( + identity_oauth_validation_id bigserial primary key + -- A validation does not require an identity to initiate + , identity_id bigint references secd.identity(identity_id) + , oauth_validation_id bigint not null references secd.oauth_validation(oauth_validation_id) + , revoked_at timestamptz + , deleted_at timestamptz + , unique(identity_id, oauth_validation_id) +); + +create table if not exists secd.email ( + email_id bigserial primary key + , address text not null + , unique(address) ); create table if not exists secd.email_validation ( email_validation_id bigserial primary key , email_validation_public_id uuid not null - , identity_email_id integer not null references secd.identity_email(identity_email_id) - , attempts integer not null + , email_id bigint not null references secd.email(email_id) , code text - , is_validated boolean not null default false + , is_oauth_derived boolean not null , created_at timestamptz not null - , expires_at timestamptz - , revoked_at timestamptz + , validated_at timestamptz + , expired_at timestamptz , unique(email_validation_public_id) ); -create table if not exists secd.session ( - session_id bigserial primary key - , identity_id bigint not null references secd.identity(identity_id) - , secret_hash bytea not null - , created_at timestamptz not null - , touched_at timestamptz not null - , expires_at timestamptz +create table if not exists secd.identity_email_validation ( + identity_email_validation_id bigserial primary key + -- A validation does not require an identity to initiate + , identity_id bigint references secd.identity(identity_id) + , email_validation_id bigint not null references secd.email_validation(email_validation_id) , revoked_at timestamptz - , unique(secret_hash) + , deleted_at timestamptz + , unique(identity_id, email_validation_id) ); diff --git a/crates/secd/store/pg/sql/find_email_validation.sql b/crates/secd/store/pg/sql/find_email_validation.sql index 96a8cc4..1eb3e43 100644 --- a/crates/secd/store/pg/sql/find_email_validation.sql +++ b/crates/secd/store/pg/sql/find_email_validation.sql @@ -2,16 +2,17 @@ select ev.email_validation_public_id , i.identity_public_id , e.address - , ev.attempts , ev.code - , ev.is_validated + , ev.is_oauth_derived , ev.created_at - , ev.expires_at - , ev.revoked_at -from secd.email_validation ev -join secd.identity_email ie using (identity_email_id) -join secd.email e using (email_id) -join secd.identity i using (identity_id) + , ev.expired_at + , ev.validated_at + , iev.revoked_at + , iev.deleted_at +from secd.email_validation ev +join secd.email e using (email_id) +left join secd.identity_email_validation iev using (email_validation_id) +left join secd.identity i using (identity_id) where (($1 is null) or (email_validation_public_id = $1)) and (($2 is null) or (code = $2)); -- diff --git a/crates/secd/store/pg/sql/find_identity.sql b/crates/secd/store/pg/sql/find_identity.sql index f4c9cbf..135ff9a 100644 --- a/crates/secd/store/pg/sql/find_identity.sql +++ b/crates/secd/store/pg/sql/find_identity.sql @@ -1,9 +1,11 @@ select - identity_public_id, - data, - i.created_at + identity_public_id + , data + , i.created_at + , i.deleted_at from secd.identity i -join secd.identity_email ie using (identity_id) -join secd.email e using (email_id) +join secd.identity_email_validation iev using (identity_id) +join secd.email_validation ev using (email_validation_id) +join secd.email e using (email_id) where (($1 is null) or (i.identity_public_id = $1)) -and (($2 is null) or (e.address = $2)) +and (($2 is null) or (e.address = $2)); diff --git a/crates/secd/store/pg/sql/find_identity_by_code.sql b/crates/secd/store/pg/sql/find_identity_by_code.sql index e016a0e..e5a0970 100644 --- a/crates/secd/store/pg/sql/find_identity_by_code.sql +++ b/crates/secd/store/pg/sql/find_identity_by_code.sql @@ -1,4 +1,4 @@ -select identity_email_id +select identity_email_validation_id from secd.email_validation where email_validation_public_id = $1::uuid -- @@ -8,4 +8,4 @@ select , i.created_at from secd.identity i left join secd.identity_email ie using (identity_id) -where ie.identity_email_id = $1; +where ie.identity_email_validation_id = $1; diff --git a/crates/secd/store/pg/sql/read_oauth_provider.sql b/crates/secd/store/pg/sql/read_oauth_provider.sql new file mode 100644 index 0000000..edaa114 --- /dev/null +++ b/crates/secd/store/pg/sql/read_oauth_provider.sql @@ -0,0 +1,12 @@ +select flow + , base_url + , response_type + , default_scope + , client_id + , client_secret + , redirect_url + , created_at + , deleted_at +from secd.oauth_provider +where name = $1 +and flow = $2; diff --git a/crates/secd/store/pg/sql/read_oauth_validation.sql b/crates/secd/store/pg/sql/read_oauth_validation.sql new file mode 100644 index 0000000..d8361ea --- /dev/null +++ b/crates/secd/store/pg/sql/read_oauth_validation.sql @@ -0,0 +1,23 @@ +select oauth_validation_public_id + , i.identity_public_id + , ov.access_token + , ov.raw_response + , ov.created_at + , ov.validated_at + , iov.revoked_at + , iov.deleted_at + , op.name as oauth_provider_name + , op.flow as oauth_provider_flow + , op.base_url as oauth_provider_base_url + , op.response_type as oauth_provider_response_type + , op.default_scope as oauth_provider_default_scope + , op.client_id as oauth_provider_client_id + , op.client_secret as oauth_provider_client_secret + , op.redirect_url as oauth_provider_redirect_url + , op.created_at as oauth_provider_created_at + , op.deleted_at as oauth_provider_deleted_at +from secd.oauth_validation ov +join secd.oauth_provider op using(oauth_provider_id) +left join secd.identity_oauth_validation iov using(oauth_validation_id) +left join secd.identity i using(identity_id) +where oauth_validation_public_id = $1; diff --git a/crates/secd/store/pg/sql/read_validation_type.sql b/crates/secd/store/pg/sql/read_validation_type.sql new file mode 100644 index 0000000..2eceb98 --- /dev/null +++ b/crates/secd/store/pg/sql/read_validation_type.sql @@ -0,0 +1,7 @@ +select 'Email' +from secd.email_validation +where email_validation_public_id = $1 +union +select 'Oauth' +from secd.oauth_validation +where oauth_validation_public_id = $1; diff --git a/crates/secd/store/pg/sql/write_email.sql b/crates/secd/store/pg/sql/write_email.sql index cdcc971..06a1dc5 100644 --- a/crates/secd/store/pg/sql/write_email.sql +++ b/crates/secd/store/pg/sql/write_email.sql @@ -4,8 +4,3 @@ insert into secd.email ( $1 ) on conflict (address) do nothing returning email_id; --- -select email_id from secd.email where address = $1; --- -insert into secd.identity_email (identity_id, email_id, created_at) values ($1, $2, $3); --- diff --git a/crates/secd/store/pg/sql/write_email_validation.sql b/crates/secd/store/pg/sql/write_email_validation.sql index d99a04c..ff25b87 100644 --- a/crates/secd/store/pg/sql/write_email_validation.sql +++ b/crates/secd/store/pg/sql/write_email_validation.sql @@ -1,27 +1,43 @@ insert into secd.email_validation ( email_validation_public_id - , identity_email_id - , attempts + , email_id , code - , is_validated + , is_oauth_derived , created_at - , expires_at + , validated_at + , expired_at ) values ( $1 - , ( - select identity_email_id - from secd.identity_email - where identity_id = $2 - and email_id = $3 - ) + , $2 + , $3 , $4 , $5 , $6 , $7 - , $8 ) on conflict (email_validation_public_id) do update - set attempts = excluded.attempts - , is_validated = excluded.is_validated - , expires_at = excluded.expires_at; + set validated_at = excluded.validated_at + , expired_at = excluded.expired_at; +-- +insert into secd.identity_email_validation ( + identity_id + , email_validation_id + , revoked_at + , deleted_at +) values ( + ( + select identity_id + from secd.identity + where identity_public_id = $1 + ) + , ( + select email_validation_id + from secd.email_validation + where email_validation_public_id = $2 + ) + , $3 + , $4 +) on conflict (identity_id, email_validation_id) do update + set revoked_at = excluded.revoked_at + , deleted_at = excluded.deleted_at; diff --git a/crates/secd/store/pg/sql/write_identity.sql b/crates/secd/store/pg/sql/write_identity.sql index 7d53ee1..94a51fe 100644 --- a/crates/secd/store/pg/sql/write_identity.sql +++ b/crates/secd/store/pg/sql/write_identity.sql @@ -6,4 +6,6 @@ insert into secd.identity ( $1, $2, $3 -); +) on conflict(identity_public_id) do update + set data = excluded.data + , deleted_at = excluded.deleted_at; diff --git a/crates/secd/store/pg/sql/write_oauth_provider.sql b/crates/secd/store/pg/sql/write_oauth_provider.sql new file mode 100644 index 0000000..ba69857 --- /dev/null +++ b/crates/secd/store/pg/sql/write_oauth_provider.sql @@ -0,0 +1,25 @@ +insert into secd.oauth_provider ( + oauth_provider_id + , name + , flow + , base_url + , response_type + , default_scope + , client_id + , client_secret + , redirect_url + , created_at + , deleted_at +) values ( + default + , $1 + , $2 + , $3 + , $4 + , $5 + , $6 + , $7 + , $8 + , $9 + , $10 +) on conflict (name, flow) do nothing; diff --git a/crates/secd/store/pg/sql/write_oauth_validation.sql b/crates/secd/store/pg/sql/write_oauth_validation.sql new file mode 100644 index 0000000..11f2578 --- /dev/null +++ b/crates/secd/store/pg/sql/write_oauth_validation.sql @@ -0,0 +1,45 @@ +insert into secd.oauth_validation ( + oauth_validation_public_id + , oauth_provider_id + , access_token + , raw_response + , created_at + , validated_at +) values ( + $1 + , ( + select oauth_provider_id + from secd.oauth_provider + where name = $2 + and flow = $3 + ) + , $4 + , $5 + , $6 + , $7 +) on conflict (oauth_validation_public_id) do update + set access_token = excluded.access_token + , validated_at = excluded.validated_at + , raw_response = excluded.raw_response; +-- +insert into secd.identity_oauth_validation ( + identity_id + , oauth_validation_id + , revoked_at + , deleted_at +) values ( + ( + select identity_id + from secd.identity + where identity_public_id = $1 + ) + , ( + select oauth_validation_id + from secd.oauth_validation + where oauth_validation_public_id = $2 + ) + , $3 + , $4 +) on conflict (identity_id, oauth_validation_id) do update + set revoked_at = excluded.revoked_at + , deleted_at = excluded.deleted_at; diff --git a/crates/secd/store/pg/sql/write_session.sql b/crates/secd/store/pg/sql/write_session.sql index 86cde55..1b238c6 100644 --- a/crates/secd/store/pg/sql/write_session.sql +++ b/crates/secd/store/pg/sql/write_session.sql @@ -2,8 +2,7 @@ insert into secd.session ( identity_id , secret_hash , created_at - , touched_at - , expires_at + , expired_at , revoked_at ) values ( (select identity_id from secd.identity where identity_public_id = $1) @@ -11,8 +10,6 @@ insert into secd.session ( , $3 , $4 , $5 - , $6 ) on conflict (secret_hash) do update - set touched_at = excluded.touched_at - , revoked_at = excluded.revoked_at; + set revoked_at = excluded.revoked_at; -- diff --git a/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql b/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql index aa95afc..a8784f5 100644 --- a/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql +++ b/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql @@ -2,44 +2,81 @@ create table if not exists identity ( identity_id integer primary key autoincrement , identity_public_id uuid , data text - , created_at timestamp not null + , created_at timestamptz not null + , deleted_at timestamptz , unique(identity_public_id) ); +create table if not exists session ( + session_id integer primary key autoincrement + , identity_id bigint not null references identity(identity_id) + , secret_hash bytea not null + , created_at timestamptz not null + , expired_at timestamptz + , revoked_at timestamptz + , unique(secret_hash) +); + +create table if not exists oauth_provider ( + oauth_provider_id integer primary key autoincrement + , name text not null + , flow text not null + , base_url text not null + , response_type text not null + , default_scope text + , client_id text not null + , client_secret text not null + , redirect_url text not null + , created_at timestamptz not null + , deleted_at timestamptz + , unique (name, flow) +); + +create table if not exists oauth_validation ( + oauth_validation_id integer primary key autoincrement + , oauth_validation_public_id uuid not null + , oauth_provider_id integer not null references oauth_provider(oauth_provider_id) + , access_token text + , raw_response text + , created_at timestamptz not null + , validated_at timestamptz + , unique (oauth_validation_public_id) +); + +create table if not exists identity_oauth_validation ( + identity_oauth_validation_id integer primary key autoincrement + -- A validation does not require an identity to initiate + , identity_id bigint references identity(identity_id) + , oauth_validation_id bigint not null references oauth_validation(oauth_validation_id) + , revoked_at timestamptz + , deleted_at timestamptz + , unique(identity_id, oauth_validation_id) +); + create table if not exists email ( email_id integer primary key autoincrement , address text not null , unique(address) ); -create table if not exists identity_email ( - identity_email_id integer primary key autoincrement - , identity_id integer not null references identity(identity_id) - , email_id integer not null references email(email_id) - , created_at timestamp not null - , deleted_at timestamp -); - create table if not exists email_validation ( email_validation_id integer primary key autoincrement - , email_validation_public_id text not null -- uuid - , identity_email_id integer not null references identity_email(identity_email_id) - , attempts integer not null + , email_validation_public_id uuid not null + , email_id bigint not null references email(email_id) , code text - , is_validated boolean not null - , created_at timestamp not null - , expires_at timestamp - , revoked_at timestamp + , is_oauth_derived boolean not null + , created_at timestamptz not null + , validated_at timestamptz + , expired_at timestamptz , unique(email_validation_public_id) ); -create table if not exists session ( - session_id integer primary key autoincrement - , identity_id not null references identity(identity_id) - , secret_hash blob not null - , created_at timestamp not null - , touched_at timestamp not null - , expires_at timestamp - , revoked_at timestamp - , unique(secret_hash) +create table if not exists identity_email_validation ( + identity_email_validation_id integer primary key autoincrement + -- A validation does not require an identity to initiate + , identity_id bigint references identity(identity_id) + , email_validation_id bigint not null references email_validation(email_validation_id) + , revoked_at timestamptz + , deleted_at timestamptz + , unique(identity_id, email_validation_id) ); diff --git a/crates/secd/store/sqlite/sql/find_email_validation.sql b/crates/secd/store/sqlite/sql/find_email_validation.sql index a34c149..d7f311c 100644 --- a/crates/secd/store/sqlite/sql/find_email_validation.sql +++ b/crates/secd/store/sqlite/sql/find_email_validation.sql @@ -2,15 +2,17 @@ select ev.email_validation_public_id , i.identity_public_id , e.address - , ev.attempts , ev.code - , ev.is_validated + , ev.is_oauth_derived , ev.created_at - , ev.expires_at - , ev.revoked_at -from email_validation ev -join identity_email ie using (identity_email_id) -join email e using (email_id) -join identity i using (identity_id) + , ev.expired_at + , ev.validated_at + , iev.revoked_at + , iev.deleted_at +from email_validation ev +join email e using (email_id) +left join identity_email_validation iev using (email_validation_id) +left join identity i using (identity_id) where ((?1 is null) or (email_validation_public_id = ?1)) and ((?2 is null) or (code = ?2)); +-- diff --git a/crates/secd/store/sqlite/sql/find_identity.sql b/crates/secd/store/sqlite/sql/find_identity.sql index bd1654d..f94e7b1 100644 --- a/crates/secd/store/sqlite/sql/find_identity.sql +++ b/crates/secd/store/sqlite/sql/find_identity.sql @@ -1,9 +1,11 @@ select - identity_public_id, - data, - i.created_at + identity_public_id + , data + , i.created_at + , i.deleted_at from identity i -join identity_email ie using (identity_id) -join email e using (email_id) +join identity_email_validation iev using (identity_id) +join email_validation ev using (email_validation_id) +join email e using (email_id) where ((?1 is null) or (i.identity_public_id = ?1)) -and ((?2 is null) or (e.address = ?2)) +and ((?2 is null) or (e.address = ?2)); diff --git a/crates/secd/store/sqlite/sql/find_identity_by_code.sql b/crates/secd/store/sqlite/sql/find_identity_by_code.sql index 77844ff..b70a13a 100644 --- a/crates/secd/store/sqlite/sql/find_identity_by_code.sql +++ b/crates/secd/store/sqlite/sql/find_identity_by_code.sql @@ -1,11 +1,11 @@ -select identity_email_id -from secd.email_validation -where email_validation_public_id = ?1; +select identity_email_validation_id +from email_validation +where email_validation_public_id = $1::uuid -- select identity_public_id , data , i.created_at -from secd.identity i -left join secd.identity_email ie using (identity_id) -where ie.identity_email_id = ?1; +from identity i +left join identity_email ie using (identity_id) +where ie.identity_email_validation_id = ?1; diff --git a/crates/secd/store/sqlite/sql/read_email_raw_id.sql b/crates/secd/store/sqlite/sql/read_email_raw_id.sql index 0bbafad..a65c717 100644 --- a/crates/secd/store/sqlite/sql/read_email_raw_id.sql +++ b/crates/secd/store/sqlite/sql/read_email_raw_id.sql @@ -1 +1 @@ -select email_id from email where address = ? +select email_id from email where address = ?1 diff --git a/crates/secd/store/sqlite/sql/read_identity_raw_id.sql b/crates/secd/store/sqlite/sql/read_identity_raw_id.sql index 552c570..2bdb718 100644 --- a/crates/secd/store/sqlite/sql/read_identity_raw_id.sql +++ b/crates/secd/store/sqlite/sql/read_identity_raw_id.sql @@ -1,2 +1,2 @@ -select identity_id from identity where identity_public_id = ?; +select identity_id from identity where identity_public_id = ?1; -- diff --git a/crates/secd/store/sqlite/sql/read_oauth_provider.sql b/crates/secd/store/sqlite/sql/read_oauth_provider.sql new file mode 100644 index 0000000..5c33cf0 --- /dev/null +++ b/crates/secd/store/sqlite/sql/read_oauth_provider.sql @@ -0,0 +1,12 @@ +select flow + , base_url + , response_type + , default_scope + , client_id + , client_secret + , redirect_url + , created_at + , deleted_at +from oauth_provider +where name = ?1 +and flow = ?2; diff --git a/crates/secd/store/sqlite/sql/read_oauth_validation.sql b/crates/secd/store/sqlite/sql/read_oauth_validation.sql new file mode 100644 index 0000000..75f5a94 --- /dev/null +++ b/crates/secd/store/sqlite/sql/read_oauth_validation.sql @@ -0,0 +1,23 @@ +select oauth_validation_public_id + , i.identity_public_id + , ov.access_token + , ov.raw_response + , ov.created_at + , ov.validated_at + , iov.revoked_at + , iov.deleted_at + , op.name as oauth_provider_name + , op.flow as oauth_provider_flow + , op.base_url as oauth_provider_base_url + , op.response_type as oauth_provider_response_type + , op.default_scope as oauth_provider_default_scope + , op.client_id as oauth_provider_client_id + , op.client_secret as oauth_provider_client_secret + , op.redirect_url as oauth_provider_redirect_url + , op.created_at as oauth_provider_created_at + , op.deleted_at as oauth_provider_deleted_at +from oauth_validation ov +join oauth_provider op using(oauth_provider_id) +left join identity_oauth_validation iov using(oauth_validation_id) +left join identity i using(identity_id) +where oauth_validation_public_id = ?1; diff --git a/crates/secd/store/sqlite/sql/read_validation_type.sql b/crates/secd/store/sqlite/sql/read_validation_type.sql new file mode 100644 index 0000000..cc02ead --- /dev/null +++ b/crates/secd/store/sqlite/sql/read_validation_type.sql @@ -0,0 +1,7 @@ +select 'Email' +from email_validation +where email_validation_public_id = ?1 +union +select 'Oauth' +from oauth_validation +where oauth_validation_public_id = ?1; diff --git a/crates/secd/store/sqlite/sql/write_email.sql b/crates/secd/store/sqlite/sql/write_email.sql index c127d9c..a64aed4 100644 --- a/crates/secd/store/sqlite/sql/write_email.sql +++ b/crates/secd/store/sqlite/sql/write_email.sql @@ -1,11 +1,6 @@ insert into email ( address ) values ( - ?1 + $1 ) on conflict (address) do nothing returning email_id; --- -select email_id from email where email = ?1; --- -insert into identity_email (identity_id, email_id, created_at) values (?1, ?2, ?3); --- diff --git a/crates/secd/store/sqlite/sql/write_email_validation.sql b/crates/secd/store/sqlite/sql/write_email_validation.sql index 37b13e1..d839310 100644 --- a/crates/secd/store/sqlite/sql/write_email_validation.sql +++ b/crates/secd/store/sqlite/sql/write_email_validation.sql @@ -1,27 +1,43 @@ insert into email_validation ( email_validation_public_id - , identity_email_id - , attempts + , email_id , code - , is_validated + , is_oauth_derived , created_at - , expires_at + , validated_at + , expired_at ) values ( ?1 - , ( - select identity_email_id - from identity_email - where identity_id = ?2 - and email_id = ?3 - ) + , ?2 + , ?3 , ?4 , ?5 , ?6 , ?7 - , ?8 ) on conflict (email_validation_public_id) do update - set attempts = excluded.attempts - , is_validated = excluded.is_validated - , expires_at = excluded.expires_at; + set validated_at = excluded.validated_at + , expired_at = excluded.expired_at; +-- +insert into identity_email_validation ( + identity_id + , email_validation_id + , revoked_at + , deleted_at +) values ( + ( + select identity_id + from identity + where identity_public_id = ?1 + ) + , ( + select email_validation_id + from email_validation + where email_validation_public_id = ?2 + ) + , ?3 + , ?4 +) on conflict (identity_id, email_validation_id) do update + set revoked_at = excluded.revoked_at + , deleted_at = excluded.deleted_at; diff --git a/crates/secd/store/sqlite/sql/write_identity.sql b/crates/secd/store/sqlite/sql/write_identity.sql index ff54468..8cf46c5 100644 --- a/crates/secd/store/sqlite/sql/write_identity.sql +++ b/crates/secd/store/sqlite/sql/write_identity.sql @@ -1 +1,11 @@ -insert into identity (identity_public_id, data, created_at) values (?1, ?2, ?3); +insert into identity ( + identity_public_id, + data, + created_at +) values ( + ?1, + ?2, + ?3 +) on conflict(identity_public_id) do update + set data = excluded.data + , deleted_at = excluded.deleted_at; diff --git a/crates/secd/store/sqlite/sql/write_oauth_provider.sql b/crates/secd/store/sqlite/sql/write_oauth_provider.sql new file mode 100644 index 0000000..421caf7 --- /dev/null +++ b/crates/secd/store/sqlite/sql/write_oauth_provider.sql @@ -0,0 +1,23 @@ +insert into oauth_provider ( + name + , flow + , base_url + , response_type + , default_scope + , client_id + , client_secret + , redirect_url + , created_at + , deleted_at +) values ( + ?1 + , ?2 + , ?3 + , ?4 + , ?5 + , ?6 + , ?7 + , ?8 + , ?9 + , ?10 +) on conflict (name, flow) do nothing; diff --git a/crates/secd/store/sqlite/sql/write_oauth_validation.sql b/crates/secd/store/sqlite/sql/write_oauth_validation.sql new file mode 100644 index 0000000..ccb11aa --- /dev/null +++ b/crates/secd/store/sqlite/sql/write_oauth_validation.sql @@ -0,0 +1,45 @@ +insert into oauth_validation ( + oauth_validation_public_id + , oauth_provider_id + , access_token + , raw_response + , created_at + , validated_at +) values ( + ?1 + , ( + select oauth_provider_id + from oauth_provider + where name = ?2 + and flow = ?3 + ) + , ?4 + , ?5 + , ?6 + , ?7 +) on conflict (oauth_validation_public_id) do update + set access_token = excluded.access_token + , validated_at = excluded.validated_at + , raw_response = excluded.raw_response; +-- +insert into identity_oauth_validation ( + identity_id + , oauth_validation_id + , revoked_at + , deleted_at +) values ( + ( + select identity_id + from identity + where identity_public_id = ?1 + ) + , ( + select oauth_validation_id + from oauth_validation + where oauth_validation_public_id = ?2 + ) + , ?3 + , ?4 +) on conflict (identity_id, oauth_validation_id) do update + set revoked_at = excluded.revoked_at + , deleted_at = excluded.deleted_at; diff --git a/crates/secd/store/sqlite/sql/write_session.sql b/crates/secd/store/sqlite/sql/write_session.sql index 3c26986..480af54 100644 --- a/crates/secd/store/sqlite/sql/write_session.sql +++ b/crates/secd/store/sqlite/sql/write_session.sql @@ -2,8 +2,7 @@ insert into session ( identity_id , secret_hash , created_at - , touched_at - , expires_at + , expired_at , revoked_at ) values ( (select identity_id from identity where identity_public_id = ?1) @@ -11,8 +10,6 @@ insert into session ( , ?3 , ?4 , ?5 - , ?6 ) on conflict (secret_hash) do update - set touched_at = excluded.touched_at - , revoked_at = excluded.revoked_at; + set revoked_at = excluded.revoked_at; -- @@ -1,7 +1,10 @@ run-debug: @RUST_BACKTRACE=1 cargo run $@ -build: +build-dev: + @cargo build + +build-prod: @cargo build --release start-postgres: |
