diff --git a/.env.sample b/.env.sample index ae067fa..793c47e 100644 --- a/.env.sample +++ b/.env.sample @@ -6,9 +6,18 @@ POSTGRES_HOST=localhost # Root env ROOT_DB_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB} -RUST_ENV=development +ROOT_ENV=development ROOT_SECRET=insecuresecret123 # Used to verify origin of attendance mutations ROOT_PORT=3000 +# GitHub OAuth for authentication +GITHUB_CLIENT_ID=your_github_oauth_app_client_id +GITHUB_CLIENT_SECRET=your_github_oauth_app_client_secret +GITHUB_REDIRECT_URL=http://localhost:5000/auth/github/callback # Oauth Callback +GITHUB_ORG_NAME=amfoss + +FRONTEND_URL=http://localhost:3000/dashboard # Redirect here after OAuth +HOSTNAME=localhost + # Seed toggle -SEEDING_ENABLED=false \ No newline at end of file +SEEDING_ENABLED=false diff --git a/Cargo.lock b/Cargo.lock index 8c0e323..320d183 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,7 +82,7 @@ dependencies = [ "futures-timer", "futures-util", "handlebars", - "http", + "http 1.2.0", "indexmap", "mime", "multer", @@ -211,19 +211,19 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" dependencies = [ "axum-core", "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.6.0", "hyper-util", "itoa", "matchit", @@ -236,7 +236,7 @@ dependencies = [ "serde_path_to_error", "serde_urlencoded", "sha1", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-tungstenite", "tower", @@ -253,12 +253,34 @@ checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbfe9f610fe4e99cf0cfcd03ccf8c63c28c616fe714d80475ef731f3b13dd21b" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", - "sync_wrapper", "tower-layer", "tower-service", "tracing", @@ -279,6 +301,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -297,6 +325,25 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bcrypt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64 0.22.1", + "blowfish", + "getrandom 0.2.15", + "subtle", + "zeroize", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.8.0" @@ -315,6 +362,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bumpalo" version = "3.17.0" @@ -360,6 +417,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link 0.2.0", ] @@ -374,6 +432,16 @@ dependencies = [ "phf", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -438,6 +506,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -850,8 +929,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -872,6 +953,25 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.8" @@ -883,7 +983,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.2.0", "indexmap", "slab", "tokio", @@ -970,6 +1070,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.2.0" @@ -981,6 +1092,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -988,7 +1110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.2.0", ] [[package]] @@ -999,8 +1121,8 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1016,6 +1138,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.6.0" @@ -1025,9 +1171,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.8", + "http 1.2.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1037,6 +1183,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.5" @@ -1044,13 +1204,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" dependencies = [ "futures-util", - "http", - "hyper", + "http 1.2.0", + "hyper 1.6.0", "hyper-util", - "rustls", + "rustls 0.23.23", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.1", "tower-service", ] @@ -1062,7 +1222,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.6.0", "hyper-util", "native-tls", "tokio", @@ -1081,15 +1241,15 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", - "hyper", + "http 1.2.0", + "http-body 1.0.1", + "hyper 1.6.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", - "system-configuration", + "socket2 0.6.0", + "system-configuration 0.6.1", "tokio", "tower-service", "tracing", @@ -1275,13 +1435,22 @@ dependencies = [ "serde", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "io-uring" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags", + "bitflags 2.8.0", "cfg-if", "libc", ] @@ -1454,7 +1623,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.2.0", "httparse", "memchr", "mime", @@ -1541,6 +1710,26 @@ dependencies = [ "libm", ] +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom 0.2.15", + "http 0.2.12", + "rand 0.8.5", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "object" version = "0.36.7" @@ -1562,7 +1751,7 @@ version = "0.10.71" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e14130c6a98cd258fdcb0fb6d744152343ff729cbfcb28c656a9d12b999fbcd" dependencies = [ - "bitflags", + "bitflags 2.8.0", "cfg-if", "foreign-types", "libc", @@ -1871,7 +2060,7 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f" dependencies = [ - "bitflags", + "bitflags 2.8.0", ] [[package]] @@ -1903,6 +2092,47 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + [[package]] name = "reqwest" version = "0.12.23" @@ -1913,12 +2143,12 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.8", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.6.0", + "hyper-rustls 0.27.5", "hyper-tls", "hyper-util", "js-sys", @@ -1931,7 +2161,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", "tower", @@ -1964,7 +2194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ "base64 0.21.7", - "bitflags", + "bitflags 2.8.0", "serde", "serde_derive", ] @@ -1975,14 +2205,19 @@ version = "2.0.0" dependencies = [ "async-graphql", "async-graphql-axum", + "async-trait", "axum", + "axum-extra", + "bcrypt", "chrono", "chrono-tz", "config", "dotenv", "hex", "hmac", - "reqwest", + "oauth2", + "rand 0.8.5", + "reqwest 0.12.23", "serde", "serde_json", "sha2", @@ -2037,13 +2272,25 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.8.0", "errno", "libc", "linux-raw-sys", "windows-sys 0.59.0", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.23" @@ -2052,17 +2299,36 @@ checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395" dependencies = [ "once_cell", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.102.8", "subtle", "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.102.8" @@ -2101,13 +2367,23 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.8.0", "core-foundation", "core-foundation-sys", "libc", @@ -2281,6 +2557,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.0" @@ -2404,7 +2690,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.8.0", "byteorder", "bytes", "chrono", @@ -2447,7 +2733,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.8.0", "byteorder", "chrono", "crc", @@ -2570,6 +2856,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2590,15 +2882,36 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys 0.5.0", +] + [[package]] name = "system-configuration" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.8.0", "core-foundation", - "system-configuration-sys", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -2753,7 +3066,7 @@ dependencies = [ "mio", "pin-project-lite", "slab", - "socket2", + "socket2 0.6.0", "tokio-macros", "windows-sys 0.59.0", ] @@ -2779,13 +3092,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" dependencies = [ - "rustls", + "rustls 0.23.23", "tokio", ] @@ -2883,7 +3206,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -2896,11 +3219,11 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags", + "bitflags 2.8.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -2997,7 +3320,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.2.0", "httparse", "log", "rand 0.9.0", @@ -3072,6 +3395,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -3221,6 +3545,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "whoami" version = "1.5.2" @@ -3438,13 +3768,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen-rt" version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" dependencies = [ - "bitflags", + "bitflags 2.8.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ddf26b8..09ccc21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" async-graphql = { version = "7.0.15", features = ["chrono"] } async-graphql-axum = "7.0.17" axum = "0.8.6" +axum-extra = { version = "0.12.2", features = ["cookie"] } chrono = { version = "0.4.42", features = ["clock"] } serde = { version = "1.0.219", features = ["derive"] } sqlx = { version = "0.8.6", features = ["chrono", "postgres", "runtime-tokio"] } @@ -24,3 +25,7 @@ tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter", "time", "fmt", "std"] } dotenv = "0.15.0" time = { version = "0.3.44", features = ["formatting"] } +oauth2 = "4.4.2" +bcrypt = "0.15.1" +rand = "0.8.5" +async-trait = "0.1.83" diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..6ddd7c8 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,292 @@ +# Authentication System Documentation + +This document describes the authentication and authorization system for Root backend. + +| Section | Description | +|--------|-------------| +| [Overview](#overview) | Summary of supported authentication methods | +| [Roles and Permissions](#roles-and-permissions) | Roles (Admin, Member, Bot) and protected mutations | +| [Setup](#setup) | How to configure OAuth, env vars, and database migration | +| [OAuth Flow](#usage) | Full OAuth login flow and response structure | +| [Bot Management](#bot-management) | Bot creation and API key handling | +| [Role Based Access Control (RBAC)](#permission-checking-in-code) | GraphQL guards and access control | +| [Troubleshooting](#troubleshooting) | Common issues and how to fix them | +| [API Reference](#api-reference) | GraphQL mutations and their inputs/outputs | +| [Example](#example) | Complete OAuth authentication flow example | +| [Architecture Notes](#architecture-notes) | Middleware flow, bot member structure, key management | + + + +## Overview + +The authentication system supports three types of authentication: +1. **GitHub OAuth** - For human members who are part of the amfoss GitHub organization +2. **API Keys** - For headless bots and automated services +3. **Session Tokens** - For maintaining logged-in state after OAuth + +## Roles and Permissions + +### Roles + +- **Admin**: Full system access, can create/manage bots, access all mutations and queries +- **Member**: Standard user permissions, authenticated via GitHub OAuth, limited access +- **Bot**: Service accounts with API key authentication, can access protected mutations + +Note that unauthenticated users have essentially no access to the system. + +### Protected Mutations + +The following mutations require Admin or Bot role: +- `markAttendance` +- `markStatusUpdate` +- `createStatusBreak` + +Regular Members cannot access these mutations. + +## Setup + +### 1. GitHub OAuth Application + +Create a GitHub OAuth application at: https://github.com/settings/developers + +**Settings:** +- Application name: Root Backend (or your choice) +- Homepage URL: `http://localhost:3000` (or your domain) +- Authorization callback URL: `http://localhost:5000/auth/github/callback` + +After creating, note down the **Client ID** and **Client Secret**. + +### 2. Environment Variables + +Add the following to your `.env` file: + +```bash +# GitHub OAuth credentials +GITHUB_CLIENT_ID=your_client_id_here +GITHUB_CLIENT_SECRET=your_client_secret_here +GITHUB_REDIRECT_URL=http://localhost:5000/auth/github/callback # Oauth Callback +FRONTEND_URL=http://localhost:3000/dashboard # Redirect after OAuth +GITHUB_ORG_NAME=amfoss # Organization that users must be part of +``` + +### 3. Database Migration + +Run the authentication system migration: + +```bash +sqlx migrate run +``` + +This creates the following tables: +- `Member` - Stores authenticated members with their details +- `Sessions` - Stores session tokens +- `ApiKeys` - Stores hashed API keys for bots + +### 4. Making Your First Admin + +After setting up the system, you need to create the first admin user manually: + +1. Register via GitHub OAuth (see below) +2. Connect to the database: + ```bash + psql $ROOT_DB_URL + ``` +3. Promote your user to admin: + ```sql + UPDATE Member SET role = 'Admin' WHERE email = 'your@email.com'; + ``` +4. Verify: + ```sql + SELECT member_id, name, email, role FROM Member WHERE email = 'your@email.com'; + ``` + +Now you can create bots and manage the system! +## Usage + +### Member Registration & Login (GitHub OAuth) + +The OAuth flow is now handled entirely by the backend, simplifying the frontend implementation. + +1. **Initiate Login**: The user visits `http://localhost:5000/auth/github`. +2. **GitHub Authorization**: The user is redirected to GitHub to authorize the application. +3. **Backend Callback**: After authorization, GitHub redirects the user to the backend's callback URL: `http://localhost:5000/auth/github/callback`. +4. **Session Creation**: The backend exchanges the OAuth code for an access token, fetches user info, and either registers a new member or logs in an existing one. A session is created for the user. +5. **Cookie and Redirect**: The backend sets a secure, HTTP-only `session_token` cookie in the user's browser and redirects them to the `FRONTEND_URL` specified in your `.env` file. +6. **Authenticated State**: The user is now logged in. The browser will automatically send the session cookie with all subsequent requests to the backend API. + +**Important:** +- First time users are automatically registered +- Users must be members of the amfoss GitHub organization +- Non-members receive an error + +### Making Authenticated Requests + +**For Members (Browser):** +After logging in via GitHub OAuth, the browser automatically handles authentication by sending the `session_token` cookie with every request. No manual header management is needed in the frontend code. + +**For Bots (API Keys):** +Bots must include their API key in the `Authorization` header: +``` +Authorization: Bearer +``` + +Example with curl (simulating a bot request): +```bash +curl -X POST http://localhost:5000/ \ + -H "Authorization: Bearer root_wnTK5uRq8FECFSvSC8OVZ8h0SSJefTMlvGWJmsS4" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "{ member(memberId: 1) { name email } }" + }' +``` + +## Bot Management +### Creating Bots (Admin Only) + +Admins can create bot accounts with API keys: + +```graphql +mutation { + createBot(name: "Presence Bot") +} +``` + +**Response:** +```json +{ + "data": { + "createBot": { + "apiKey": "root_wnTK5uRq8FECFSvSC8OVZ8h0SSJefTMlvGWJmsS4" + } + } +} +``` + +**⚠️ Important:** The API key is only returned ONCE. Store it securely! + +### Using API Keys (Bots) + +Bots use API keys instead of session tokens. Include the API key in the `Authorization` header in the same format as before. + + +## Permission Checking in Code + +### In GraphQL Resolvers + +Protected mutations use guards: + +```rust +use crate::auth::guards::{AdminGuard, AdminOrBotGuard, AuthGuard}; + +#[Object] +impl SomeMutations { + #[graphql(name = "protectedMutation", guard = "AdminOrBotGuard")] + async fn protected_mutation(&self, ctx: &Context<'_>) -> Result { + // Only admins and bots can reach here + Ok("Success".to_string()) + } + + #[graphql(name = "memberOnlyMutation", guard = "AuthGuard")] + async fn member_only_mutation(&self, ctx: &Context<'_>) -> Result { + // Any authenticated user can reach here + Ok("Success".to_string()) + } + + #[graphql(name = "adminOnlyMutation", guard = "AdminGuard")] + async fn admin_only_mutation(&self, ctx: &Context<'_>) -> Result { + // Only admins can reach here + Ok("Success".to_string()) + } +} +``` + +### Available Guards + +- `AuthGuard` - Requires any authenticated user (Member, Admin, or Bot) +- `AdminGuard` - Requires Admin role +- `AdminOrBotGuard` - Requires Admin or Bot role + +## Troubleshooting + +### "Authentication context not found" Error + +The auth middleware is not properly configured. Ensure: + +- That the `Authorization:` header is present in the HTTP Request. + +### "User is not a member of the amfoss organization" + +The GitHub account is not part of the specified organization. Either: +1. Add the user to the organization +2. Change `GITHUB_ORG_NAME` in .env (for testing) + +## API Reference + +### GraphQL Mutations + +#### `createBot(name: String!): String!` 🔒 Admin only + +Create a new bot with API key. + +**Input:** +- `name`: Bot name/description + +**Returns:** The API key string (only shown once!) + +## Example + +### Complete Member Authentication Flow + +```javascript +// 1. Redirect user to the backend's OAuth initiation endpoint +function login() { + window.location.href = 'http://localhost:5000/auth/github'; +} + +// After the user authorizes on GitHub, the backend handles everything +// and redirects the user back to the frontend (e.g., to the dashboard). +// The user is now authenticated, and the session cookie is set. + +// 2. Make authenticated requests +// The browser will automatically include the session cookie. +// No need to manually set Authorization headers. +async function fetchMemberData() { + const response = await fetch('http://localhost:5000/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: '{ member(memberId: 1) { name } }' + }) + }); + + const result = await response.json(); + console.log(result.data.member); +} +``` +## Architecture Notes + +### Authentication Flow + +1. **Request arrives** → `auth_middleware` is executed. +2. **Cookie check** → The middleware first checks for a `session_token` cookie. If valid, the associated member is found. +3. **API Key check** → If no valid session cookie is found, it checks the `Authorization: Bearer ` header for an API key. +4. **Member lookup** → If a valid key is found, the associated bot member is retrieved. +5. **Context injection** → An `AuthContext` with the member (or `None`) is added to the request extensions. +6. **GraphQL execution** → Guards check `AuthContext` for permissions. +7. **Response** → Returns data or a permission error. + +### Bot Members + +Bots are represented as synthetic Member objects with: +- Negative member_id (to distinguish from real members) +- Role set to Bot +- Email format: `bot-{api_key_id}@internal.amfoss.in` +- Name from API key name + + +### API Key Management + +- API keys are prefixed with `root_` +- Keys are hashed with bcrypt (cost factor 12) before storage +- Last used timestamp is updated on each validation +- Keys can only be deleted by admins diff --git a/migrations/20251114165724_create_auth_system.sql b/migrations/20251114165724_create_auth_system.sql new file mode 100644 index 0000000..9f15d79 --- /dev/null +++ b/migrations/20251114165724_create_auth_system.sql @@ -0,0 +1,48 @@ +-- Create authentication system tables + +-- Custom type for user roles +CREATE TYPE role_type AS ENUM ('Admin', 'Member', 'Bot'); + +-- Add role column to Member table +ALTER TABLE Member ADD COLUMN role role_type NOT NULL DEFAULT 'Member'; + +-- Make member fields nullable for GitHub-only registrations +ALTER TABLE Member ALTER COLUMN roll_no DROP NOT NULL; +ALTER TABLE Member ALTER COLUMN sex DROP NOT NULL; +ALTER TABLE Member ALTER COLUMN year DROP NOT NULL; +ALTER TABLE Member ALTER COLUMN hostel DROP NOT NULL; +ALTER TABLE Member ALTER COLUMN mac_address DROP NOT NULL; +ALTER TABLE Member ALTER COLUMN discord_id DROP NOT NULL; +ALTER TABLE Member ALTER COLUMN group_id DROP NOT NULL; + +-- Add updated_at column to Member table +ALTER TABLE Member ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT NOW(); + +-- Sessions table: stores session tokens for logged-in users +CREATE TABLE IF NOT EXISTS Sessions ( + session_id SERIAL PRIMARY KEY, + member_id INTEGER NOT NULL REFERENCES Member(member_id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- API Keys table: stores hashed API keys for bot authentication +CREATE TABLE IF NOT EXISTS ApiKeys ( + api_key_id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + key_hash TEXT NOT NULL, + created_by INTEGER REFERENCES Member(member_id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMP +); + +-- Few critical indexes +CREATE INDEX idx_sessions_token_hash ON Sessions(token_hash); +CREATE INDEX idx_apikeys_key_hash ON ApiKeys(key_hash); + +-- Trigger to update timestamp on Users table +CREATE TRIGGER update_users_timestamp +BEFORE UPDATE ON Member +FOR EACH ROW +EXECUTE FUNCTION update_timestamp(); diff --git a/src/auth/api_key.rs b/src/auth/api_key.rs new file mode 100644 index 0000000..31847d2 --- /dev/null +++ b/src/auth/api_key.rs @@ -0,0 +1,146 @@ +use crate::models::auth::{ApiKey, Role}; +use crate::models::member::Member; +use bcrypt::{hash, verify, DEFAULT_COST}; +use chrono_tz::Asia::Kolkata; +use rand::Rng; +use sqlx::PgPool; + +const API_KEY_LENGTH: usize = 48; +const API_KEY_PREFIX: &str = "root_"; + +pub struct ApiKeyService; + +impl ApiKeyService { + fn generate_api_key() -> String { + let mut rng = rand::thread_rng(); + let key: String = (0..API_KEY_LENGTH) + .map(|_| { + let idx = rng.gen_range(0..62); + match idx { + 0..=25 => (b'A' + idx) as char, + 26..=51 => (b'a' + (idx - 26)) as char, + _ => (b'0' + (idx - 52)) as char, + } + }) + .collect(); + format!("{}{}", API_KEY_PREFIX, key) + } + + pub async fn create_api_key( + pool: &PgPool, + name: String, + created_by: i32, + ) -> Result { + let api_key = Self::generate_api_key(); + let key_hash = + hash(&api_key, DEFAULT_COST).map_err(|e| format!("Failed to hash API key: {}", e))?; + + let _ = sqlx::query_as::<_, ApiKey>( + r#" + INSERT INTO ApiKeys (name, key_hash, created_by) + VALUES ($1, $2, $3) + RETURNING + api_key_id, + name, + key_hash, + created_by, + created_at, + last_used_at + "#, + ) + .bind(name) + .bind(key_hash) + .bind(created_by) + .fetch_one(pool) + .await + .map_err(|e| format!("Failed to create API key: {}", e))?; + + Ok(api_key) + } + + pub async fn validate_api_key(pool: &PgPool, api_key: &str) -> Result, String> { + if !api_key.starts_with(API_KEY_PREFIX) { + return Ok(None); + } + + // Fetch all API keys (we need to bcrypt verify each one) + let api_keys = sqlx::query_as::<_, ApiKey>( + r#" + SELECT + api_key_id, + name, + key_hash, + created_by, + created_at, + last_used_at + FROM ApiKeys + "#, + ) + .fetch_all(pool) + .await + .map_err(|e| format!("Failed to fetch API keys: {}", e))?; + + for key in api_keys { + if verify(api_key, &key.key_hash).unwrap_or(false) { + let _ = Self::update_last_used(pool, key.api_key_id).await; + + // Create a synthetic Member for the bot + let bot_member = Member { + member_id: -(key.api_key_id), // Negative ID to distinguish from real members + roll_no: None, + name: key.name.clone(), + email: format!("bot-{}@internal.amfoss.in", key.api_key_id), + sex: None, + year: None, + hostel: None, + mac_address: None, + discord_id: None, + group_id: None, + track: None, + github_user: None, + role: Role::Bot, + created_at: key.created_at, + updated_at: key.created_at, + }; + + return Ok(Some(bot_member)); + } + } + + Ok(None) + } + + async fn update_last_used(pool: &PgPool, api_key_id: i32) -> Result<(), String> { + let now = chrono::Utc::now().with_timezone(&Kolkata); + + sqlx::query( + r#" + UPDATE ApiKeys + SET last_used_at = $1 + WHERE api_key_id = $2 + "#, + ) + .bind(now) + .bind(api_key_id) + .execute(pool) + .await + .map_err(|e| format!("Failed to update last_used_at: {}", e))?; + + Ok(()) + } + + pub async fn delete_api_key(pool: &PgPool, api_key_id: i32) -> Result<(), String> { + sqlx::query( + r#" + DELETE FROM ApiKeys + WHERE api_key_id = $1 + "#, + ) + .bind(api_key_id) + .execute(pool) + .await + .map_err(|e| format!("Failed to delete API key: {}", e))?; + + Ok(()) + } +} diff --git a/src/auth/auth_service.rs b/src/auth/auth_service.rs new file mode 100644 index 0000000..6b6fbb6 --- /dev/null +++ b/src/auth/auth_service.rs @@ -0,0 +1,62 @@ +use crate::auth::oauth::GitHubOAuthService; +use crate::models::auth::{GitHubUser, Role}; +use crate::models::member::Member; +use chrono_tz::Asia::Kolkata; +use sqlx::PgPool; + +pub struct AuthService; + +impl AuthService { + pub async fn handle_github_callback(pool: &PgPool, code: String) -> Result { + let oauth_service = GitHubOAuthService::new() + .map_err(|e| format!("Failed to initialize OAuth service: {}", e))?; + + let github_user = oauth_service + .complete_oauth_flow(code) + .await + .map_err(|e| format!("OAuth flow failed: {}", e))?; + + let existing_member = sqlx::query_as::<_, Member>( + "SELECT member_id, roll_no, name, email, sex, year, hostel, mac_address, discord_id, + group_id, track, github_user, role, created_at, updated_at + FROM Member + WHERE github_user = $1", + ) + .bind(&github_user.github_username) + .fetch_optional(pool) + .await + .map_err(|e| format!("Failed to query member: {}", e))?; + + let member = if let Some(member) = existing_member { + // Member exists - return existing member + member + } else { + // Member doesn't exist - register new member + Self::register_member(pool, github_user).await? + }; + + Ok(member) + } + + async fn register_member(pool: &PgPool, github_user: GitHubUser) -> Result { + let now = chrono::Utc::now().with_timezone(&Kolkata); + + let member = sqlx::query_as::<_, Member>( + "INSERT INTO Member (name, email, github_user, role, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING member_id, roll_no, name, email, sex, year, hostel, mac_address, discord_id, + group_id, track, github_user, role, created_at, updated_at", + ) + .bind(github_user.name) + .bind(github_user.email) + .bind(github_user.github_username) + .bind(Role::Member) + .bind(now) + .bind(now) + .fetch_one(pool) + .await + .map_err(|e| format!("Failed to register member: {}", e))?; + + Ok(member) + } +} diff --git a/src/auth/guards.rs b/src/auth/guards.rs new file mode 100644 index 0000000..0dffb95 --- /dev/null +++ b/src/auth/guards.rs @@ -0,0 +1,54 @@ +use crate::auth::AuthContext; +use async_graphql::{Context, Error, Guard, Result}; + +pub struct AuthGuard; + +impl Guard for AuthGuard { + async fn check(&self, ctx: &Context<'_>) -> Result<()> { + let auth = ctx.data::().map_err(|_| { + Error::new("Authentication context not found. This is an internal server error.") + })?; + + if auth.is_authenticated() { + Ok(()) + } else { + Err(Error::new( + "Authentication required to access this resource.", + )) + } + } +} + +pub struct AdminGuard; + +impl Guard for AdminGuard { + async fn check(&self, ctx: &Context<'_>) -> Result<()> { + let auth = ctx.data::().map_err(|_| { + Error::new("Authentication context not found. This is an internal server error.") + })?; + + if auth.is_admin() { + Ok(()) + } else { + Err(Error::new("Admin privileges required for this operation")) + } + } +} + +pub struct AdminOrBotGuard; + +impl Guard for AdminOrBotGuard { + async fn check(&self, ctx: &Context<'_>) -> Result<()> { + let auth = ctx.data::().map_err(|_| { + Error::new("Authentication context not found. This is an internal server error.") + })?; + + if auth.is_bot() || auth.is_admin() { + Ok(()) + } else { + Err(Error::new( + "Admin or Bot privileges required for this operation", + )) + } + } +} diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs new file mode 100644 index 0000000..174eef1 --- /dev/null +++ b/src/auth/middleware.rs @@ -0,0 +1,44 @@ +use crate::auth::api_key::ApiKeyService; +use crate::auth::session::SessionService; +use crate::auth::AuthContext; +use axum::{ + extract::Request, + http::{header::AUTHORIZATION, StatusCode}, + middleware::Next, + response::Response, +}; +use axum_extra::extract::CookieJar; +use sqlx::PgPool; +use std::sync::Arc; + +pub async fn auth_middleware( + pool: Arc, + mut request: Request, + next: Next, +) -> Result { + let auth_header = request + .headers() + .get(AUTHORIZATION) + .and_then(|h| h.to_str().ok()); + + let jar = CookieJar::from_headers(request.headers()); + + let member = if let Some(cookie) = jar.get("session_token") { + SessionService::validate_session(&pool, cookie.value()) + .await + .ok() + .flatten() + } else if let Some(auth_value) = auth_header { + let token = auth_value.strip_prefix("Bearer ").unwrap_or(auth_value); + ApiKeyService::validate_api_key(&pool, token) + .await + .ok() + .flatten() + } else { + None + }; + // Inject auth context into request extensions + request.extensions_mut().insert(AuthContext::new(member)); + + Ok(next.run(request).await) +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..b296843 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,44 @@ +pub mod api_key; +pub mod auth_service; +pub mod guards; +pub mod middleware; +pub mod oauth; +pub mod session; + +use crate::models::auth::Role; +use crate::models::member::Member; + +#[derive(Clone, Debug)] +pub struct AuthContext { + pub user: Option, +} + +impl AuthContext { + pub fn new(user: Option) -> Self { + Self { user } + } + + pub fn user(&self) -> Option<&Member> { + self.user.as_ref() + } + + pub fn role(&self) -> Option { + self.user.as_ref().map(|u| u.role) + } + + pub fn has_role(&self, role: Role) -> bool { + self.role() == Some(role) + } + + pub fn is_authenticated(&self) -> bool { + self.user.is_some() + } + + pub fn is_admin(&self) -> bool { + self.has_role(Role::Admin) + } + + pub fn is_bot(&self) -> bool { + self.has_role(Role::Bot) + } +} diff --git a/src/auth/oauth.rs b/src/auth/oauth.rs new file mode 100644 index 0000000..ecd3c8d --- /dev/null +++ b/src/auth/oauth.rs @@ -0,0 +1,188 @@ +use crate::models::auth::GitHubUser; +use oauth2::{ + basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, RedirectUrl, + Scope, TokenResponse, TokenUrl, +}; +use reqwest; +use serde::{Deserialize, Serialize}; +use std::env; + +#[derive(Debug, Clone)] +pub struct GitHubOAuthConfig { + pub client_id: String, + pub client_secret: String, + pub redirect_url: String, + pub org_name: String, +} + +impl GitHubOAuthConfig { + pub fn from_env() -> Result { + Ok(Self { + client_id: env::var("GITHUB_CLIENT_ID") + .map_err(|_| "GITHUB_CLIENT_ID not set".to_string())?, + client_secret: env::var("GITHUB_CLIENT_SECRET") + .map_err(|_| "GITHUB_CLIENT_SECRET not set".to_string())?, + redirect_url: env::var("GITHUB_REDIRECT_URL") + .map_err(|_| "GITHUB_REDIRECT_URL not set".to_string())?, + org_name: env::var("GITHUB_ORG_NAME").unwrap_or_else(|_| "amfoss".to_string()), + }) + } + + pub fn create_client(&self) -> Result { + let auth_url = AuthUrl::new("https://github.com/login/oauth/authorize".to_string()) + .map_err(|e| format!("Invalid auth URL: {}", e))?; + let token_url = TokenUrl::new("https://github.com/login/oauth/access_token".to_string()) + .map_err(|e| format!("Invalid token URL: {}", e))?; + + let client = BasicClient::new( + ClientId::new(self.client_id.clone()), + Some(ClientSecret::new(self.client_secret.clone())), + auth_url, + Some(token_url), + ) + .set_redirect_uri( + RedirectUrl::new(self.redirect_url.clone()) + .map_err(|e| format!("Invalid redirect URL: {}", e))?, + ); + + Ok(client) + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct GitHubUserResponse { + id: i64, + login: String, + name: Option, + email: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct GitHubEmailResponse { + email: String, + primary: bool, + verified: bool, +} + +pub struct GitHubOAuthService { + config: GitHubOAuthConfig, + client: BasicClient, +} + +impl GitHubOAuthService { + pub fn new() -> Result { + let config = GitHubOAuthConfig::from_env()?; + let client = config.create_client()?; + Ok(Self { config, client }) + } + + pub fn get_authorization_url(&self) -> (String, CsrfToken) { + let (auth_url, csrf_token) = self + .client + .authorize_url(CsrfToken::new_random) + .add_scope(Scope::new("read:user".to_string())) + .add_scope(Scope::new("user:email".to_string())) + .add_scope(Scope::new("read:org".to_string())) + .url(); + + (auth_url.to_string(), csrf_token) + } + + pub async fn exchange_code(&self, code: String) -> Result { + let token_result = self + .client + .exchange_code(AuthorizationCode::new(code)) + .request_async(oauth2::reqwest::async_http_client) + .await + .map_err(|e| format!("Failed to exchange code: {}", e))?; + + Ok(token_result.access_token().secret().clone()) + } + + pub async fn get_user_info(&self, access_token: &str) -> Result { + let client = reqwest::Client::new(); + + let user_response: GitHubUserResponse = client + .get("https://api.github.com/user") + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "Root-Backend") + .send() + .await + .map_err(|e| format!("Failed to fetch user info: {}", e))? + .json() + .await + .map_err(|e| format!("Failed to parse user info: {}", e))?; + + // Fetch from /user/emails if not in profile + let email = if let Some(email) = user_response.email { + email + } else { + let emails: Vec = client + .get("https://api.github.com/user/emails") + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "Root-Backend") + .send() + .await + .map_err(|e| format!("Failed to fetch user emails: {}", e))? + .json() + .await + .map_err(|e| format!("Failed to parse user emails: {}", e))?; + + emails + .into_iter() + .find(|e| e.primary && e.verified) + .ok_or("No verified primary email found".to_string())? + .email + }; + + Ok(GitHubUser { + github_id: user_response.id, + github_username: user_response.login, + name: user_response.name.unwrap_or_else(|| "Unknown".to_string()), + email, + }) + } + + pub async fn verify_org_membership( + &self, + access_token: &str, + username: &str, + ) -> Result { + let client = reqwest::Client::new(); + + let url = format!( + "https://api.github.com/orgs/{}/members/{}", + self.config.org_name, username + ); + + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "Root-Backend") + .send() + .await + .map_err(|e| format!("Failed to check org membership: {}", e))?; + + // GitHub returns 204 if member, 404 if not, 302 if needs authentication + Ok(response.status().as_u16() == 204) + } + + pub async fn complete_oauth_flow(&self, code: String) -> Result { + let access_token = self.exchange_code(code).await?; + + let user_info = self.get_user_info(&access_token).await?; + + let is_member = self + .verify_org_membership(&access_token, &user_info.github_username) + .await?; + + if !is_member { + return Err(format!( + "User is not a member of the {} organization", + self.config.org_name + )); + } + + Ok(user_info) + } +} diff --git a/src/auth/session.rs b/src/auth/session.rs new file mode 100644 index 0000000..1a79867 --- /dev/null +++ b/src/auth/session.rs @@ -0,0 +1,92 @@ +use crate::models::member::Member; +use chrono::{Duration, Utc}; +use chrono_tz::Asia::Kolkata; +use rand::Rng; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; + +const SESSION_DURATION_DAYS: i64 = 30; +const TOKEN_LENGTH: usize = 64; + +pub struct SessionService; + +impl SessionService { + fn generate_token() -> String { + let mut rng = rand::thread_rng(); + let token: String = (0..TOKEN_LENGTH) + .map(|_| { + let idx = rng.gen_range(0..62); + match idx { + 0..=25 => (b'A' + idx) as char, + 26..=51 => (b'a' + (idx - 26)) as char, + _ => (b'0' + (idx - 52)) as char, + } + }) + .collect(); + token + } + + fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + format!("{:x}", hasher.finalize()) + } + + pub async fn create_session(pool: &PgPool, member_id: i32) -> Result { + let token = Self::generate_token(); + let token_hash = Self::hash_token(&token); + let expires_at = Utc::now().with_timezone(&Kolkata) + Duration::days(SESSION_DURATION_DAYS); + + sqlx::query( + r#" + INSERT INTO Sessions (member_id, token_hash, expires_at) + VALUES ($1, $2, $3) + "#, + ) + .bind(member_id) + .bind(token_hash) + .bind(expires_at) + .execute(pool) + .await + .map_err(|e| format!("Failed to create session: {}", e))?; + + Ok(token) + } + + pub async fn validate_session(pool: &PgPool, token: &str) -> Result, String> { + let token_hash = Self::hash_token(token); + let now = chrono::Utc::now().with_timezone(&Kolkata); + + let result = sqlx::query_as::<_, Member>( + r#" + SELECT m.* FROM Member m + INNER JOIN Sessions s ON m.member_id = s.member_id + WHERE s.token_hash = $1 AND s.expires_at > $2 + "#, + ) + .bind(token_hash) + .bind(now) + .fetch_optional(pool) + .await + .map_err(|e| format!("Failed to validate session: {}", e))?; + + Ok(result) + } + + pub async fn cleanup_expired_sessions(pool: &PgPool) -> Result { + let now = chrono::Utc::now().with_timezone(&Kolkata); + + let result = sqlx::query( + r#" + DELETE FROM Sessions + WHERE expires_at <= $1 + "#, + ) + .bind(now) + .execute(pool) + .await + .map_err(|e| format!("Failed to cleanup sessions: {}", e))?; + + Ok(result.rows_affected()) + } +} diff --git a/src/daily_task/mod.rs b/src/daily_task/mod.rs index 7789d4b..a3b5bbc 100644 --- a/src/daily_task/mod.rs +++ b/src/daily_task/mod.rs @@ -1,3 +1,4 @@ +use crate::auth::session::SessionService; use chrono::NaiveTime; use chrono_tz::Asia::Kolkata; use sqlx::PgPool; @@ -36,7 +37,14 @@ pub async fn run_daily_task_at_midnight(pool: Arc) { /// This function does a number of things, including: /// * Insert new attendance records everyday for [`presense`](https://www.github.com/amfoss/presense) to update them later in the day. +/// * Delete expired user sessions. async fn execute_daily_task(pool: Arc) { + if let Ok(rows_deleted) = SessionService::cleanup_expired_sessions(&pool).await { + if rows_deleted > 0 { + tracing::info!("Cleaned up {:?} expired sessions", rows_deleted); + } + } + // Members is queried outside of each function to avoid repetition let members = sqlx::query_as::<_, Member>("SELECT * FROM Member") .fetch_all(&*pool) diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index a3e2615..e6475df 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -1,5 +1,5 @@ use async_graphql::MergedObject; -use mutations::{AttendanceMutations, MemberMutations, StatusMutations}; +use mutations::{AttendanceMutations, AuthMutations, MemberMutations, StatusMutations}; use queries::MemberQueries; pub mod mutations; @@ -9,4 +9,9 @@ pub mod queries; pub struct Query(MemberQueries); #[derive(MergedObject, Default)] -pub struct Mutation(MemberMutations, AttendanceMutations, StatusMutations); +pub struct Mutation( + MemberMutations, + AttendanceMutations, + StatusMutations, + AuthMutations, +); diff --git a/src/graphql/mutations/attendance_mutations.rs b/src/graphql/mutations/attendance_mutations.rs index f3aea54..953b032 100644 --- a/src/graphql/mutations/attendance_mutations.rs +++ b/src/graphql/mutations/attendance_mutations.rs @@ -1,12 +1,12 @@ use std::sync::Arc; use async_graphql::{Context, Object, Result}; -use chrono::Local; use chrono_tz::Asia::Kolkata; use hmac::{Hmac, Mac}; use sha2::Sha256; use sqlx::PgPool; +use crate::auth::guards::AdminOrBotGuard; use crate::models::attendance::{AttendanceRecord, MarkAttendanceInput}; type HmacSha256 = Hmac; @@ -16,7 +16,7 @@ pub struct AttendanceMutations; #[Object] impl AttendanceMutations { - #[graphql(name = "markAttendance")] + #[graphql(name = "markAttendance", guard = "AdminOrBotGuard")] async fn mark_attendance( &self, ctx: &Context<'_>, @@ -42,7 +42,8 @@ impl AttendanceMutations { return Err(async_graphql::Error::new("HMAC verification failed")); } - let now = Local::now().with_timezone(&Kolkata).time(); + let now = chrono::Utc::now().with_timezone(&Kolkata); + let attendance = sqlx::query_as::<_, AttendanceRecord>( "UPDATE Attendance SET time_in = CASE WHEN time_in IS NULL THEN $1 diff --git a/src/graphql/mutations/auth_mutations.rs b/src/graphql/mutations/auth_mutations.rs new file mode 100644 index 0000000..963a775 --- /dev/null +++ b/src/graphql/mutations/auth_mutations.rs @@ -0,0 +1,34 @@ +use crate::auth::api_key::ApiKeyService; +use crate::auth::guards::AdminGuard; +use crate::auth::AuthContext; +use crate::models::auth::ApiKeyResponse; +use async_graphql::{Context, Object, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Default)] +pub struct AuthMutations; + +#[Object] +impl AuthMutations { + /// Create a new bot with API key (Admin only) + #[graphql(name = "createBot", guard = "AdminGuard")] + async fn create_bot(&self, ctx: &Context<'_>, name: String) -> Result { + let pool = ctx.data::>().expect("Pool must be in context."); + let auth = ctx + .data::() + .expect("AuthContext must be in context."); + + let admin_member = auth + .user + .as_ref() + .ok_or("Admin member not found in context")?; + + // Create API key + let api_key = ApiKeyService::create_api_key(pool.as_ref(), name, admin_member.member_id) + .await + .map_err(|e| format!("Failed to create bot: {}", e))?; + + Ok(ApiKeyResponse { api_key }) + } +} diff --git a/src/graphql/mutations/member_mutations.rs b/src/graphql/mutations/member_mutations.rs index c6c6ebf..515de22 100644 --- a/src/graphql/mutations/member_mutations.rs +++ b/src/graphql/mutations/member_mutations.rs @@ -1,7 +1,7 @@ -use crate::models::member::{CreateMemberInput, Member, UpdateMemberInput}; +use crate::auth::guards::AuthGuard; +use crate::auth::AuthContext; +use crate::models::member::{Member, UpdateMemberInput}; use async_graphql::{Context, Object, Result}; -use chrono::Local; -use chrono_tz::Asia::Kolkata; use sqlx::PgPool; use std::sync::Arc; @@ -10,36 +10,15 @@ pub struct MemberMutations; #[Object] impl MemberMutations { - #[graphql(name = "createMember")] - async fn create_member(&self, ctx: &Context<'_>, input: CreateMemberInput) -> Result { + /// Update the details of the currently logged in member + #[graphql(name = "updateMe", guard = "AuthGuard")] + async fn update_me(&self, ctx: &Context<'_>, input: UpdateMemberInput) -> Result { let pool = ctx.data::>().expect("Pool must be in context."); - let now = Local::now().with_timezone(&Kolkata).date_naive(); + let auth = ctx + .data::() + .expect("AuthContext must be in context."); - let member = sqlx::query_as::<_, Member>( - "INSERT INTO Member (roll_no, name, email, sex, year, hostel, mac_address, discord_id, group_id, track, github_user, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *" - ) - .bind(&input.roll_no) - .bind(&input.name) - .bind(&input.email) - .bind(input.sex) - .bind(input.year) - .bind(&input.hostel) - .bind(&input.mac_address) - .bind(&input.discord_id) - .bind(input.group_id) - .bind(&input.track) - .bind(&input.github_user) - .bind(now) - .fetch_one(pool.as_ref()) - .await?; - - Ok(member) - } - - #[graphql(name = "updateMember")] - async fn update_member(&self, ctx: &Context<'_>, input: UpdateMemberInput) -> Result { - let pool = ctx.data::>().expect("Pool must be in context."); + let logged_in_user = auth.user.as_ref().ok_or("User not found in context")?; let member = sqlx::query_as::<_, Member>( "UPDATE Member SET @@ -68,7 +47,7 @@ impl MemberMutations { .bind(input.group_id) .bind(&input.track) .bind(&input.github_user) - .bind(input.member_id) + .bind(logged_in_user.member_id) .fetch_one(pool.as_ref()) .await?; diff --git a/src/graphql/mutations/mod.rs b/src/graphql/mutations/mod.rs index 8bfc1c9..6c58cfa 100644 --- a/src/graphql/mutations/mod.rs +++ b/src/graphql/mutations/mod.rs @@ -1,7 +1,9 @@ pub mod attendance_mutations; +pub mod auth_mutations; pub mod member_mutations; pub mod status_mutations; pub use attendance_mutations::AttendanceMutations; +pub use auth_mutations::AuthMutations; pub use member_mutations::MemberMutations; pub use status_mutations::StatusMutations; diff --git a/src/graphql/mutations/status_mutations.rs b/src/graphql/mutations/status_mutations.rs index efe811c..874e8ba 100644 --- a/src/graphql/mutations/status_mutations.rs +++ b/src/graphql/mutations/status_mutations.rs @@ -3,6 +3,7 @@ use chrono::NaiveDate; use sqlx::PgPool; use std::sync::Arc; +use crate::auth::guards::{AdminGuard, AdminOrBotGuard}; use crate::models::status_update::{CreateStatusBreakInput, StatusBreakRecord, StatusUpdateRecord}; #[derive(Default)] @@ -10,6 +11,7 @@ pub struct StatusMutations; #[Object] impl StatusMutations { + #[graphql(name = "markStatusUpdate", guard = "AdminOrBotGuard")] async fn mark_status_update( &self, ctx: &Context<'_>, @@ -34,6 +36,7 @@ impl StatusMutations { Ok(status) } + #[graphql(name = "createStatusBreak", guard = "AdminGuard")] async fn create_status_break( &self, ctx: &Context<'_>, diff --git a/src/graphql/queries/member_queries.rs b/src/graphql/queries/member_queries.rs index 209f542..468a0b0 100644 --- a/src/graphql/queries/member_queries.rs +++ b/src/graphql/queries/member_queries.rs @@ -1,3 +1,5 @@ +use crate::auth::guards::AuthGuard; +use crate::auth::AuthContext; use crate::models::{attendance::AttendanceRecord, status_update::StatusUpdateRecord}; use async_graphql::{ComplexObject, Context, Object, Result}; use chrono::NaiveDate; @@ -19,6 +21,7 @@ pub struct AttendanceInfo { #[Object] impl MemberQueries { + #[graphql(guard = "AuthGuard")] pub async fn all_members( &self, ctx: &Context<'_>, @@ -47,6 +50,8 @@ impl MemberQueries { Ok(members) } + /// Fetch the details of a specific member + #[graphql(guard = "AuthGuard")] async fn member( &self, ctx: &Context<'_>, @@ -75,6 +80,15 @@ impl MemberQueries { (None, None) => Err("Provide either member_id or email".into()), } } + + /// Fetch the details of the currently logged in member + #[graphql(guard = "AuthGuard")] + async fn me(&self, ctx: &Context<'_>) -> Result { + let auth = ctx.data::()?; + + // The AuthGuard ensures that the user is authenticated, so we can unwrap here. + Ok(auth.user.clone().unwrap()) + } } #[Object] diff --git a/src/main.rs b/src/main.rs index c69d170..8332d7a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ use async_graphql::EmptySubscription; +use axum::http::header::CONTENT_TYPE; use axum::http::{HeaderValue, Method}; use sqlx::Executor; use sqlx::PgPool; use std::sync::Arc; use time::UtcOffset; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::CorsLayer; use tracing::info; use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; @@ -13,6 +14,7 @@ use database_seeder::seed_database; use graphql::{Mutation, Query}; use routes::setup_router; +pub mod auth; pub mod daily_task; pub mod database_seeder; pub mod graphql; @@ -21,12 +23,15 @@ pub mod routes; /// Handles all over environment variables in one place. // TODO: Replace with `Config.rs` crate. -struct Config { - env: String, +#[derive(Clone)] +pub struct Config { + pub env: String, secret_key: String, database_url: String, port: String, seeding_enabled: bool, + pub frontend_url: String, + pub hostname: String, } impl Config { @@ -40,6 +45,8 @@ impl Config { seeding_enabled: std::env::var("SEEDING_ENABLED") .map(|v| v.to_lowercase() == "true") .unwrap_or(false), + frontend_url: std::env::var("FRONTEND_URL").expect("FRONTEND_URL not set"), + hostname: std::env::var("HOSTNAME").expect("HOSTNAME not set"), } } } @@ -50,19 +57,20 @@ async fn main() { setup_tracing(&config.env); let pool = setup_database(&config.database_url).await; - let schema = build_graphql_schema(pool.clone(), config.secret_key); + let schema = build_graphql_schema(pool.clone(), config.secret_key.clone()); if config.seeding_enabled { info!("Seeding database..."); seed_database(&pool).await; } - tokio::task::spawn(async { - run_daily_task_at_midnight(pool).await; + let pool_for_task = pool.clone(); + tokio::task::spawn(async move { + run_daily_task_at_midnight(pool_for_task).await; }); let cors = setup_cors(); - let router = setup_router(schema, cors, config.env == "development"); + let router = setup_router(schema, cors, config.clone(), pool); info!("Starting Root..."); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port)) @@ -145,14 +153,15 @@ fn build_graphql_schema( fn setup_cors() -> CorsLayer { // TODO: Replace hardcoded strings - let _origins: [HeaderValue; 2] = [ - "http://127.0.0.1:3000".parse().unwrap(), + let origins: [HeaderValue; 2] = [ + "http://localhost:3000".parse().unwrap(), "https://home.amfoss.in".parse().unwrap(), ]; CorsLayer::new() - // TODO 2: https://github.com/amfoss/root/issues/151, enabling all origins for the time being - .allow_origin(Any) + // TODO 2: https://github.com/amfoss/root/issues/151 + .allow_credentials(true) + .allow_origin(origins) .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) - .allow_headers(tower_http::cors::Any) + .allow_headers([CONTENT_TYPE]) } diff --git a/src/models/auth.rs b/src/models/auth.rs new file mode 100644 index 0000000..3d3a6f0 --- /dev/null +++ b/src/models/auth.rs @@ -0,0 +1,55 @@ +use crate::models::member::Member; +use async_graphql::{Enum, SimpleObject}; +use chrono::NaiveDateTime; +use sqlx::FromRow; + +#[derive(Enum, Copy, Clone, Eq, PartialEq, sqlx::Type, Debug)] +#[sqlx(type_name = "role_type")] +pub enum Role { + Admin, + Member, + Bot, +} + +#[derive(SimpleObject, FromRow, Debug)] +pub struct Session { + pub session_id: i32, + pub member_id: i32, + #[graphql(skip)] + pub token_hash: String, + pub expires_at: NaiveDateTime, + #[graphql(skip)] + pub created_at: NaiveDateTime, +} + +#[derive(SimpleObject, FromRow, Debug)] +pub struct ApiKey { + pub api_key_id: i32, + pub name: String, + #[graphql(skip)] + pub key_hash: String, + pub created_by: Option, + pub created_at: NaiveDateTime, + pub last_used_at: Option, +} + +// Response types for auth mutations +#[derive(SimpleObject)] +pub struct AuthResponse { + pub member: Member, + pub session_token: String, +} + +#[derive(SimpleObject)] +pub struct ApiKeyResponse { + pub api_key: String, +} + +// OAuth callback data (not an input, used internally) +#[derive(Debug, Clone)] +pub struct GitHubUser { + pub github_id: i64, + pub github_username: String, + pub name: String, + pub email: String, +} diff --git a/src/models/member.rs b/src/models/member.rs index 4fdfed5..21569fa 100644 --- a/src/models/member.rs +++ b/src/models/member.rs @@ -1,8 +1,9 @@ +use crate::models::auth::Role; use async_graphql::{Enum, InputObject, SimpleObject}; use chrono::NaiveDateTime; use sqlx::FromRow; -#[derive(Enum, Copy, Clone, Eq, PartialEq, sqlx::Type)] +#[derive(Enum, Copy, Clone, Eq, PartialEq, sqlx::Type, Debug)] #[sqlx(type_name = "sex_type")] pub enum Sex { M, @@ -10,22 +11,24 @@ pub enum Sex { Other, } -#[derive(SimpleObject, FromRow)] +#[derive(SimpleObject, FromRow, Clone, Debug)] #[graphql(complex)] pub struct Member { pub member_id: i32, - pub roll_no: String, + pub roll_no: Option, pub name: String, pub email: String, - pub sex: Sex, - pub year: i32, - pub hostel: String, - pub mac_address: String, - pub discord_id: String, - pub group_id: i32, + pub sex: Option, + pub year: Option, + pub hostel: Option, + pub mac_address: Option, + pub discord_id: Option, + pub group_id: Option, + pub role: Role, pub track: Option, pub github_user: Option, pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, } #[derive(InputObject)] @@ -45,7 +48,6 @@ pub struct CreateMemberInput { #[derive(InputObject)] pub struct UpdateMemberInput { - pub member_id: i32, pub roll_no: Option, pub name: Option, pub email: Option, diff --git a/src/models/mod.rs b/src/models/mod.rs index f0a4582..3dcf6d7 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ pub mod attendance; +pub mod auth; pub mod member; pub mod status_update; diff --git a/src/routes.rs b/src/routes.rs index f6c6db2..2737973 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,32 +1,71 @@ use async_graphql::{http::GraphiQLSource, EmptySubscription, Schema}; -use async_graphql_axum::GraphQL; +use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; use axum::{ - response::{Html, IntoResponse}, - routing::get, + extract::{Extension, Query as AxumQuery, State}, + http::{header, StatusCode}, + middleware, + response::{Html, IntoResponse, Redirect}, + routing::{get, post}, Router, }; +use axum_extra::extract::cookie::{Cookie, SameSite}; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; use tower_http::cors::CorsLayer; +use crate::auth::auth_service::AuthService; +use crate::auth::middleware::auth_middleware; +use crate::auth::oauth::GitHubOAuthService; +use crate::auth::session::SessionService; +use crate::auth::AuthContext; use crate::graphql::{Mutation, Query}; +use crate::Config; + +#[derive(Clone)] +struct AppState { + schema: Schema, + pool: Arc, + config: Config, +} + +async fn graphql_handler( + State(state): State, + Extension(auth_context): Extension, + req: GraphQLRequest, +) -> GraphQLResponse { + state + .schema + .execute(req.into_inner().data(auth_context)) + .await + .into() +} pub fn setup_router( schema: Schema, cors: CorsLayer, - is_dev: bool, + config: Config, + pool: Arc, ) -> Router { + let pool_for_middleware = pool.clone(); + let app_state = AppState { + schema, + pool, + config: config.clone(), + }; + let router = Router::new() - .route_service("/", GraphQL::new(schema.clone())) - .layer(cors); - - if is_dev { - tracing::info!("GraphiQL playground enabled at /graphiql"); - router.route( - "/graphiql", - get(graphiql).post_service(GraphQL::new(schema)), - ) - } else { - router - } + .route("/", post(graphql_handler)) + .route("/auth/github", get(github_oauth_init)) + .route("/auth/github/callback", get(github_oauth_callback)) + .route("/graphiql", get(graphiql).post(graphql_handler)); + + router + .layer(middleware::from_fn(move |req, next| { + auth_middleware(pool_for_middleware.clone(), req, next) + })) + .layer(cors) + .with_state(app_state) } async fn graphiql() -> impl IntoResponse { @@ -37,3 +76,58 @@ async fn graphiql() -> impl IntoResponse { .finish(), ) } + +// OAuth handlers + +/// Initiates GitHub OAuth flow +async fn github_oauth_init() -> Result { + let oauth_service = + GitHubOAuthService::new().map_err(|e| format!("Failed to initialize OAuth: {}", e))?; + + let (auth_url, _csrf_token) = oauth_service.get_authorization_url(); + + Ok(Redirect::temporary(&auth_url)) +} + +#[derive(Deserialize)] +struct OAuthCallbackQuery { + code: String, + // In a production system, this state variable should be populated + // and verified using server side cookies. For now, we'll ignore it. + #[allow(dead_code)] + state: Option, +} + +/// GitHub OAuth callback handler - completes authentication and sets session cookie +async fn github_oauth_callback( + State(state): State, + AxumQuery(query): AxumQuery, +) -> Result { + let member = AuthService::handle_github_callback(state.pool.as_ref(), query.code) + .await + .map_err(|e| (StatusCode::UNAUTHORIZED, format!("OAuth failed: {}", e)))?; + + let session_token = SessionService::create_session(state.pool.as_ref(), member.member_id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create session: {}", e), + ) + })?; + + let cookie = Cookie::build(("session_token", session_token)) + .path("/") + .http_only(true) + .secure(state.config.env != "development") + .domain(state.config.hostname) + .same_site(SameSite::Lax) + .max_age(time::Duration::days(30)) + .build(); + + // Redirect to frontend with cookie + Ok(( + [(header::SET_COOKIE, cookie.to_string())], + Redirect::to(&state.config.frontend_url), + )) +}