Browse Source

Support external authentication via github.

Currently the association must be added manually to the database.
Kestrel 10 months ago
parent
commit
4d45d48eb7
16 changed files with 722 additions and 54 deletions
  1. 2 0
      .gitignore
  2. 297 1
      Cargo.lock
  3. 2 0
      Cargo.toml
  4. 4 2
      src/cli.rs
  5. 17 0
      src/cli/user.rs
  6. 4 7
      src/config.rs
  7. 79 0
      src/ext.rs
  8. 12 0
      src/ext/generic_oidc.rs
  9. 212 0
      src/ext/github.rs
  10. 1 0
      src/main.rs
  11. 10 2
      src/schema.rs
  12. 31 8
      src/server.rs
  13. 12 8
      src/server/oidc.rs
  14. 30 24
      src/server/session.rs
  15. 4 2
      src/user.rs
  16. 5 0
      tmpl/id_v1_login.tmpl

+ 2 - 0
.gitignore

@@ -2,3 +2,5 @@
 /uidc
 /uidc
 /uidc.db
 /uidc.db
 .*.sw?
 .*.sw?
+/uidc-gh.toml
+/tmp

+ 297 - 1
Cargo.lock

@@ -253,7 +253,7 @@ dependencies = [
  "polling 2.8.0",
  "polling 2.8.0",
  "rustix 0.37.27",
  "rustix 0.37.27",
  "slab",
  "slab",
- "socket2",
+ "socket2 0.4.10",
  "waker-fn",
  "waker-fn",
 ]
 ]
 
 
@@ -548,6 +548,18 @@ version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
 checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
 
 
+[[package]]
+name = "bytes"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38"
+
+[[package]]
+name = "bytes"
+version = "1.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3"
+
 [[package]]
 [[package]]
 name = "cc"
 name = "cc"
 version = "1.1.15"
 version = "1.1.15"
@@ -780,6 +792,37 @@ dependencies = [
  "cipher",
  "cipher",
 ]
 ]
 
 
+[[package]]
+name = "curl"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e2161dd6eba090ff1594084e95fd67aeccf04382ffea77999ea94ed42ec67b6"
+dependencies = [
+ "curl-sys",
+ "libc",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "socket2 0.5.7",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "curl-sys"
+version = "0.4.76+curl-8.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00462dbe9cbb9344e1b2be34d9094d74e3b8aac59a883495b335eafd02e25120"
+dependencies = [
+ "cc",
+ "libc",
+ "libnghttp2-sys",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+ "vcpkg",
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 [[package]]
 name = "deranged"
 name = "deranged"
 version = "0.3.11"
 version = "0.3.11"
@@ -821,6 +864,15 @@ version = "1.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
 checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
 
 
+[[package]]
+name = "encoding_rs"
+version = "0.8.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59"
+dependencies = [
+ "cfg-if 1.0.0",
+]
+
 [[package]]
 [[package]]
 name = "env_logger"
 name = "env_logger"
 version = "0.10.2"
 version = "0.10.2"
@@ -929,6 +981,23 @@ dependencies = [
  "web-sys",
  "web-sys",
 ]
 ]
 
 
+[[package]]
+name = "flume"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bebadab126f8120d410b677ed95eee4ba6eb7c6dd8e34a5ec88a08050e26132"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spinning_top",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
 [[package]]
 [[package]]
 name = "form_urlencoded"
 name = "form_urlencoded"
 version = "1.2.1"
 version = "1.2.1"
@@ -998,6 +1067,12 @@ dependencies = [
  "syn 2.0.77",
  "syn 2.0.77",
 ]
 ]
 
 
+[[package]]
+name = "futures-sink"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
+
 [[package]]
 [[package]]
 name = "futures-task"
 name = "futures-task"
 version = "0.3.30"
 version = "0.3.30"
@@ -1011,8 +1086,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
 checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
 dependencies = [
 dependencies = [
  "futures-core",
  "futures-core",
+ "futures-io",
  "futures-macro",
  "futures-macro",
  "futures-task",
  "futures-task",
+ "memchr",
  "pin-project-lite 0.2.14",
  "pin-project-lite 0.2.14",
  "pin-utils",
  "pin-utils",
  "slab",
  "slab",
@@ -1158,15 +1235,28 @@ dependencies = [
  "digest 0.10.7",
  "digest 0.10.7",
 ]
 ]
 
 
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes 1.7.2",
+ "fnv",
+ "itoa",
+]
+
 [[package]]
 [[package]]
 name = "http-client"
 name = "http-client"
 version = "6.5.3"
 version = "6.5.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1947510dc91e2bf586ea5ffb412caad7673264e14bb39fb9078da114a94ce1a5"
 checksum = "1947510dc91e2bf586ea5ffb412caad7673264e14bb39fb9078da114a94ce1a5"
 dependencies = [
 dependencies = [
+ "async-std",
  "async-trait",
  "async-trait",
  "cfg-if 1.0.0",
  "cfg-if 1.0.0",
  "http-types",
  "http-types",
+ "isahc",
  "log",
  "log",
 ]
 ]
 
 
@@ -1290,6 +1380,29 @@ version = "1.70.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
 checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
 
 
+[[package]]
+name = "isahc"
+version = "0.9.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2948a0ce43e2c2ef11d7edf6816508998d99e13badd1150be0914205df9388a"
+dependencies = [
+ "bytes 0.5.6",
+ "crossbeam-utils",
+ "curl",
+ "curl-sys",
+ "flume",
+ "futures-lite 1.13.0",
+ "http",
+ "log",
+ "once_cell",
+ "slab",
+ "sluice",
+ "tracing",
+ "tracing-futures",
+ "url",
+ "waker-fn",
+]
+
 [[package]]
 [[package]]
 name = "itertools"
 name = "itertools"
 version = "0.12.1"
 version = "0.12.1"
@@ -1350,6 +1463,16 @@ version = "0.2.158"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439"
 checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439"
 
 
+[[package]]
+name = "libnghttp2-sys"
+version = "0.1.10+1.61.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "959c25552127d2e1fa72f0e52548ec04fc386e827ba71a7bd01db46a447dc135"
+dependencies = [
+ "cc",
+ "libc",
+]
+
 [[package]]
 [[package]]
 name = "libsqlite3-sys"
 name = "libsqlite3-sys"
 version = "0.28.0"
 version = "0.28.0"
@@ -1361,6 +1484,18 @@ dependencies = [
  "vcpkg",
  "vcpkg",
 ]
 ]
 
 
+[[package]]
+name = "libz-sys"
+version = "1.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
 [[package]]
 [[package]]
 name = "linux-raw-sys"
 name = "linux-raw-sys"
 version = "0.3.8"
 version = "0.3.8"
@@ -1427,6 +1562,22 @@ dependencies = [
  "syn 1.0.109",
  "syn 1.0.109",
 ]
 ]
 
 
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
 [[package]]
 [[package]]
 name = "mio"
 name = "mio"
 version = "0.8.11"
 version = "0.8.11"
@@ -1485,6 +1636,24 @@ version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
 checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
 
 
+[[package]]
+name = "openssl-probe"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
 [[package]]
 [[package]]
 name = "parking"
 name = "parking"
 version = "2.2.0"
 version = "2.2.0"
@@ -1933,6 +2102,12 @@ dependencies = [
  "windows-sys 0.52.0",
  "windows-sys 0.52.0",
 ]
 ]
 
 
+[[package]]
+name = "rustversion"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6"
+
 [[package]]
 [[package]]
 name = "ryu"
 name = "ryu"
 version = "1.0.18"
 version = "1.0.18"
@@ -1948,6 +2123,15 @@ dependencies = [
  "winapi-util",
  "winapi-util",
 ]
 ]
 
 
+[[package]]
+name = "schannel"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9aaafd5a2b6e3d657ff009d82fbd630b6bd54dd4eb06f21693925cdf80f9b8b"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
 [[package]]
 [[package]]
 name = "scopeguard"
 name = "scopeguard"
 version = "1.2.0"
 version = "1.2.0"
@@ -2158,6 +2342,17 @@ dependencies = [
  "autocfg",
  "autocfg",
 ]
 ]
 
 
+[[package]]
+name = "sluice"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5"
+dependencies = [
+ "async-channel 1.9.0",
+ "futures-core",
+ "futures-io",
+]
+
 [[package]]
 [[package]]
 name = "smallvec"
 name = "smallvec"
 version = "1.13.2"
 version = "1.13.2"
@@ -2191,6 +2386,16 @@ dependencies = [
  "winapi",
  "winapi",
 ]
 ]
 
 
+[[package]]
+name = "socket2"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 [[package]]
 name = "spin"
 name = "spin"
 version = "0.5.2"
 version = "0.5.2"
@@ -2203,6 +2408,15 @@ version = "0.9.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
 
 
+[[package]]
+name = "spinning_top"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b9eb1a2f4c41445a3a0ff9abc5221c5fcd28e1f13cd7c0397706f9ac938ddb0"
+dependencies = [
+ "lock_api",
+]
+
 [[package]]
 [[package]]
 name = "standback"
 name = "standback"
 version = "0.2.17"
 version = "0.2.17"
@@ -2267,12 +2481,57 @@ version = "0.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
 
 
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.77",
+]
+
 [[package]]
 [[package]]
 name = "subtle"
 name = "subtle"
 version = "2.6.1"
 version = "2.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
 checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
 
 
+[[package]]
+name = "surf"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "718b1ae6b50351982dedff021db0def601677f2120938b070eadb10ba4038dd7"
+dependencies = [
+ "async-std",
+ "async-trait",
+ "cfg-if 1.0.0",
+ "encoding_rs",
+ "futures-util",
+ "getrandom 0.2.15",
+ "http-client",
+ "http-types",
+ "log",
+ "mime_guess",
+ "once_cell",
+ "pin-project-lite 0.2.14",
+ "serde",
+ "serde_json",
+ "web-sys",
+]
+
 [[package]]
 [[package]]
 name = "sval"
 name = "sval"
 version = "2.13.0"
 version = "2.13.0"
@@ -2549,15 +2808,41 @@ version = "0.1.40"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
 checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
 dependencies = [
 dependencies = [
+ "log",
  "pin-project-lite 0.2.14",
  "pin-project-lite 0.2.14",
+ "tracing-attributes",
  "tracing-core",
  "tracing-core",
 ]
 ]
 
 
+[[package]]
+name = "tracing-attributes"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.77",
+]
+
 [[package]]
 [[package]]
 name = "tracing-core"
 name = "tracing-core"
 version = "0.1.32"
 version = "0.1.32"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
 checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "tracing-futures"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
+dependencies = [
+ "pin-project",
+ "tracing",
+]
 
 
 [[package]]
 [[package]]
 name = "typeid"
 name = "typeid"
@@ -2603,11 +2888,22 @@ dependencies = [
  "sha1 0.10.6",
  "sha1 0.10.6",
  "sha2 0.10.8",
  "sha2 0.10.8",
  "smol",
  "smol",
+ "strum",
+ "surf",
  "tide",
  "tide",
  "time 0.3.36",
  "time 0.3.36",
  "toml",
  "toml",
 ]
 ]
 
 
+[[package]]
+name = "unicase"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
+dependencies = [
+ "version_check",
+]
+
 [[package]]
 [[package]]
 name = "unicode-bidi"
 name = "unicode-bidi"
 version = "0.3.15"
 version = "0.3.15"

+ 2 - 0
Cargo.toml

@@ -14,6 +14,7 @@ lazy_static = "1.4.0"
 time = { version = "0.3", features = ["std", "formatting"] }
 time = { version = "0.3", features = ["std", "formatting"] }
 itertools = "0.12"
 itertools = "0.12"
 glob = "0.3"
 glob = "0.3"
+strum = { version = "0.26", features = ["derive"] }
 
 
 # crypto
 # crypto
 ring = { version = "0.16", features = ["std"] }
 ring = { version = "0.16", features = ["std"] }
@@ -36,6 +37,7 @@ tide = { version = "0.16.0" }
 handlebars = { version = "4.3", features = ["dir_source"] }
 handlebars = { version = "4.3", features = ["dir_source"] }
 serde_json = "1.0"
 serde_json = "1.0"
 jsonwebtoken = "9.3"
 jsonwebtoken = "9.3"
+surf = { version = "2.3" }
 
 
 # CLI dependencies
 # CLI dependencies
 clap = { version = "4.5", features = ["derive", "env", "string"] }
 clap = { version = "4.5", features = ["derive", "env", "string"] }

+ 4 - 2
src/cli.rs

@@ -86,8 +86,10 @@ struct RunArgs {
 
 
 impl RootArgs {
 impl RootArgs {
     async fn run(self) -> Result<(), UIDCError> {
     async fn run(self) -> Result<(), UIDCError> {
-        let config_contents = std::fs::read_to_string(self.config_path.as_str()).expect("couldn't open configuration file");
-        let config : Config = toml::from_str(config_contents.as_str()).expect("couldn't parse configuration file");
+        let config_contents = std::fs::read_to_string(self.config_path.as_str())
+            .expect("couldn't open configuration file");
+        let config: Config =
+            toml::from_str(config_contents.as_str()).expect("couldn't parse configuration file");
 
 
         if let Command::Init = self.command {
         if let Command::Init = self.command {
             return self.init(config).await;
             return self.init(config).await;

+ 17 - 0
src/cli/user.rs

@@ -16,6 +16,10 @@ pub enum UserCommands {
         #[clap(short = 't', long, action = clap::ArgAction::Count)]
         #[clap(short = 't', long, action = clap::ArgAction::Count)]
         totp: u8,
         totp: u8,
     },
     },
+    RegisterExternalAuth {
+        username: String,
+        provider: schema::ExternalAuthProvider,
+    },
 }
 }
 
 
 impl microrm::cli::EntityInterface for UserInterface {
 impl microrm::cli::EntityInterface for UserInterface {
@@ -34,6 +38,7 @@ impl microrm::cli::EntityInterface for UserInterface {
                 query_ctx.insert(schema::User {
                 query_ctx.insert(schema::User {
                     realm: ctx.id(),
                     realm: ctx.id(),
                     username,
                     username,
+                    pending_external_auths: Default::default(),
                     auth: Default::default(),
                     auth: Default::default(),
                     groups: Default::default(),
                     groups: Default::default(),
                 })?;
                 })?;
@@ -79,6 +84,18 @@ impl microrm::cli::EntityInterface for UserInterface {
                     user.set_new_totp(new_secret.as_slice())?;
                     user.set_new_totp(new_secret.as_slice())?;
                 }
                 }
             }
             }
+            UserCommands::RegisterExternalAuth { username, provider } => {
+                let mut user = query_ctx
+                    .with(schema::User::Username, &username)
+                    .first()
+                    .get()?
+                    .ok_or(Self::Error::no_such_entity("user", username))?;
+
+                user.pending_external_auths.as_mut().push(provider);
+                user.pending_external_auths.as_mut().sort();
+                user.pending_external_auths.as_mut().dedup();
+                user.sync().expect("couldn't sync user model");
+            }
         }
         }
 
 
         Ok(())
         Ok(())

+ 4 - 7
src/config.rs

@@ -1,10 +1,6 @@
-use serde::{Deserialize, Serialize};
+use serde::Deserialize;
 
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct GithubConfig {
-    pub client_id: String,
-    pub client_secret: String,
-}
+use crate::ext::{GithubConfig, OIDCConfig};
 
 
 fn default_auth_token_expiry() -> u64 {
 fn default_auth_token_expiry() -> u64 {
     600
     600
@@ -18,7 +14,7 @@ fn default_refresh_token_expiry() -> u64 {
     3600
     3600
 }
 }
 
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Deserialize)]
 pub struct Config {
 pub struct Config {
     pub db_path: String,
     pub db_path: String,
 
 
@@ -34,4 +30,5 @@ pub struct Config {
     pub refresh_token_expiry: u64,
     pub refresh_token_expiry: u64,
 
 
     pub github: Option<GithubConfig>,
     pub github: Option<GithubConfig>,
+    pub oidc: Option<OIDCConfig>,
 }
 }

+ 79 - 0
src/ext.rs

@@ -0,0 +1,79 @@
+use std::future::Future;
+
+use crate::{
+    config::Config,
+    schema::{self, UIDCDatabase},
+    server::{self, SessionHelper, UIDCRequest},
+};
+
+mod generic_oidc;
+mod github;
+
+pub trait ExternalAuthenticator {
+    fn build(db: &UIDCDatabase, config: &Config) -> Option<Self>
+    where
+        Self: Sized;
+    fn register_routes(&'static self, server: &mut tide::Server<server::ServerStateWrapper>);
+
+    fn generate_login_url(&self, realm: &str, redirect: &str) -> String;
+    fn generate_registration_url(&self, realm: &str, redirect: &str) -> String;
+    fn extract_login_state(&self, req: UIDCRequest) -> impl Future<Output = tide::Response>;
+
+    fn handle_matching_login(
+        &self,
+        req: UIDCRequest,
+        user: schema::UserID,
+        redirect: &str,
+    ) -> tide::Response {
+        let sh = SessionHelper::new(&req);
+
+        let Ok((resp, cookie)) = sh.get_or_build_session(&req) else {
+            return tide::Response::builder(500)
+                .body("error while building session")
+                .build();
+        };
+
+        let realm_id = sh.get_realm().unwrap().id();
+
+        // remove any existing authentication for this realm just in case
+        resp.auth
+            .with(schema::SessionAuth::Realm, realm_id)
+            .first()
+            .delete()
+            .expect("couldn't remove existing authentication");
+        resp.auth
+            .insert(schema::SessionAuth {
+                realm: realm_id,
+                user: Some(user),
+                pending_user: None,
+                pending_challenges: vec![].into_serialized(),
+            })
+            .expect("couldn't insert new authentication");
+
+        let mut resp: tide::Response = tide::Redirect::see_other(redirect).into();
+
+        if let Some(cookie) = cookie {
+            resp.insert_cookie(cookie);
+        }
+
+        return resp;
+    }
+
+    fn handle_no_mapping(&self, req: UIDCRequest, redirect: String) -> tide::Response {
+        let sh = SessionHelper::new(&req);
+
+        return sh.render_login_from_auth(
+            tide::Response::new(200),
+            redirect,
+            None,
+            Some("Github user not associated with any local user.".to_string()),
+        );
+    }
+}
+
+pub use generic_oidc::{OIDCAuthenticator, OIDCConfig};
+pub use github::{GithubAuthenticator, GithubConfig};
+use microrm::{
+    prelude::{Insertable, Queryable},
+    schema::Serializable,
+};

+ 12 - 0
src/ext/generic_oidc.rs

@@ -0,0 +1,12 @@
+use serde::Deserialize;
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct OIDCConfig {
+    pub name: String,
+    pub base_url: String,
+    pub client_id: String,
+    pub client_secret: Option<String>,
+}
+
+pub struct OIDCAuthenticator;

+ 212 - 0
src/ext/github.rs

@@ -0,0 +1,212 @@
+use microrm::{
+    prelude::{Insertable, Queryable},
+    schema::Serializable,
+};
+use serde::Deserialize;
+
+use crate::{
+    config::Config,
+    schema::{self, UIDCDatabase},
+    server::{ServerStateWrapper, SessionHelper, UIDCRequest},
+};
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct GithubConfig {
+    pub login_url: Option<String>,
+    pub token_url: Option<String>,
+    pub api_base: Option<String>,
+
+    pub client_id: String,
+    pub client_secret: String,
+}
+
+const DEFAULT_LOGIN_URL: &'static str = "https://github.com/login/oauth/authorize";
+const DEFAULT_TOKEN_URL: &'static str = "https://github.com/login/oauth/access_token";
+const DEFAULT_API_BASE: &'static str = "https://api.github.com";
+
+#[derive(Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum CallbackRequestType {
+    Login,
+    Register,
+}
+
+pub struct GithubAuthenticator {
+    base_url: String,
+    config: GithubConfig,
+}
+
+impl super::ExternalAuthenticator for GithubAuthenticator {
+    fn build(_db: &UIDCDatabase, config: &Config) -> Option<Self> {
+        config.github.as_ref().map(|ghc| Self {
+            base_url: config.base_url.clone(),
+            config: ghc.clone(),
+        })
+    }
+
+    fn register_routes(&'static self, server: &mut tide::Server<ServerStateWrapper>) {
+        server
+            .at("/:realm/github_return")
+            .get(|req: UIDCRequest| async { Ok(self.extract_login_state(req).await) });
+    }
+
+    fn generate_login_url(&self, realm: &str, redirect: &str) -> String {
+        let return_url = format!(
+            "{base_url}/{realm}/github_return?redirect={redirect}&mode=login",
+            base_url = self.base_url,
+        );
+
+        tide::http::Url::parse_with_params(
+            self.config
+                .login_url
+                .as_deref()
+                .unwrap_or(DEFAULT_LOGIN_URL),
+            &[
+                ("client_id", self.config.client_id.as_str()),
+                ("redirect_uri", return_url.as_str()),
+            ],
+        )
+        .expect("couldn't generate login url for github")
+        .into()
+    }
+
+    fn generate_registration_url(&self, realm: &str, redirect: &str) -> String {
+        let return_url = format!(
+            "{base_url}/{realm}/github_return?redirect={redirect}&mode=register",
+            base_url = self.base_url,
+        );
+
+        tide::http::Url::parse_with_params(
+            self.config
+                .login_url
+                .as_deref()
+                .unwrap_or(DEFAULT_LOGIN_URL),
+            &[
+                ("client_id", self.config.client_id.as_str()),
+                ("redirect_uri", return_url.as_str()),
+            ],
+        )
+        .expect("couldn't generate login url for github")
+        .into()
+    }
+
+    fn extract_login_state(
+        &self,
+        req: UIDCRequest,
+    ) -> impl smol::prelude::Future<Output = tide::Response> {
+        async move {
+            let state = req.state();
+            let realm = req.param("realm").unwrap();
+
+            #[derive(Deserialize)]
+            struct Query {
+                code: String,
+                redirect: String,
+                mode: CallbackRequestType,
+            }
+            let Ok(query) = req.query::<Query>() else {
+                return tide::Response::builder(400)
+                    .body("Query string invalid.")
+                    .build();
+            };
+
+            #[derive(Deserialize)]
+            struct TokenResponse {
+                access_token: String,
+            }
+
+            let auth = surf::http::auth::BasicAuth::new(
+                self.config.client_id.as_str(),
+                self.config.client_secret.as_str(),
+            );
+
+            let resp: TokenResponse = match state
+                .client
+                .post(
+                    tide::http::Url::parse_with_params(
+                        self.config
+                            .token_url
+                            .as_deref()
+                            .unwrap_or(DEFAULT_TOKEN_URL),
+                        &[
+                            ("client_id", self.config.client_id.as_str()),
+                            ("client_secret", self.config.client_secret.as_str()),
+                            ("code", query.code.as_str()),
+                        ],
+                    )
+                    .expect("couldn't generate token url for github"),
+                )
+                .header(auth.name(), auth.value())
+                .content_type(surf::http::mime::FORM)
+                .recv_form()
+                .await
+            {
+                Ok(resp) => resp,
+                Err(err) => {
+                    return tide::Response::builder(500)
+                        .body(format!("could not parse Github response for token: {err}"))
+                        .build()
+                }
+            };
+
+            let atoken = resp.access_token;
+
+            #[derive(Deserialize)]
+            struct UserInfoResponse {
+                id: i64,
+            }
+
+            let resp: UserInfoResponse = match state
+                .client
+                .get(format!(
+                    "{base}/user",
+                    base = self.config.api_base.as_deref().unwrap_or(DEFAULT_API_BASE)
+                ))
+                .header("Authorization", format!("Bearer {atoken}"))
+                .content_type(surf::http::mime::JSON)
+                .recv_json()
+                .await
+            {
+                Ok(resp) => resp,
+                Err(err) => {
+                    return tide::Response::builder(500)
+                        .body(format!("could not parse Github response for token: {err}"))
+                        .build()
+                }
+            };
+
+            let user_id = resp.id.to_string();
+
+            let Some(realm) = state.db.realms.keyed(realm).get().ok().flatten() else {
+                return tide::Response::builder(404).body("no such realm").build();
+            };
+
+            let external_auth_map = realm
+                .external_auth
+                .keyed((
+                    &user_id,
+                    schema::ExternalAuthProvider::Github.into_serialized(),
+                ))
+                .get()
+                .ok()
+                .flatten();
+
+            match (query.mode, external_auth_map) {
+                (CallbackRequestType::Login, Some(map)) => {
+                    return self.handle_matching_login(
+                        req,
+                        map.internal_user_id,
+                        query.redirect.as_str(),
+                    );
+                }
+                (CallbackRequestType::Login, None) => {
+                    return self.handle_no_mapping(req, query.redirect);
+                }
+                (CallbackRequestType::Register, _) => {
+                    todo!()
+                }
+            }
+        }
+    }
+}

+ 1 - 0
src/main.rs

@@ -5,6 +5,7 @@ mod client;
 mod client_management;
 mod client_management;
 mod config;
 mod config;
 mod error;
 mod error;
+mod ext;
 mod key;
 mod key;
 mod realm;
 mod realm;
 mod schema;
 mod schema;

+ 10 - 2
src/schema.rs

@@ -1,5 +1,6 @@
 pub use microrm::prelude::{Database, Entity};
 pub use microrm::prelude::{Database, Entity};
 use serde::{Deserialize, Serialize};
 use serde::{Deserialize, Serialize};
+use strum::EnumString;
 
 
 use crate::key::KeyType;
 use crate::key::KeyType;
 
 
@@ -17,6 +18,7 @@ pub struct Session {
 
 
 #[derive(Entity)]
 #[derive(Entity)]
 pub struct SessionAuth {
 pub struct SessionAuth {
+    #[key]
     pub realm: RealmID,
     pub realm: RealmID,
 
 
     pub user: Option<UserID>,
     pub user: Option<UserID>,
@@ -25,7 +27,7 @@ pub struct SessionAuth {
     pub pending_challenges: microrm::Serialized<Vec<AuthChallengeType>>,
     pub pending_challenges: microrm::Serialized<Vec<AuthChallengeType>>,
 }
 }
 
 
-#[derive(Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize, Debug)]
+#[derive(Clone, PartialEq, PartialOrd, Serialize, Deserialize, Debug)]
 pub enum AuthChallengeType {
 pub enum AuthChallengeType {
     Username,
     Username,
     Password,
     Password,
@@ -94,6 +96,9 @@ pub struct User {
     pub realm: RealmID,
     pub realm: RealmID,
     #[key]
     #[key]
     pub username: String,
     pub username: String,
+
+    pub pending_external_auths: microrm::Serialized<Vec<ExternalAuthProvider>>,
+
     pub auth: microrm::RelationMap<AuthChallenge>,
     pub auth: microrm::RelationMap<AuthChallenge>,
     pub groups: microrm::RelationDomain<UserGroupRelation>,
     pub groups: microrm::RelationDomain<UserGroupRelation>,
 }
 }
@@ -172,9 +177,12 @@ pub struct Scope {
 // External (social) authentication
 // External (social) authentication
 // ----------------------------------------------------------------------
 // ----------------------------------------------------------------------
 
 
-#[derive(Clone, Debug, Serialize, Deserialize)]
+#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Serialize, Deserialize, EnumString)]
+#[strum(serialize_all = "snake_case")]
+#[serde(rename_all = "snake_case")]
 pub enum ExternalAuthProvider {
 pub enum ExternalAuthProvider {
     Github,
     Github,
+    GenericOIDC,
 }
 }
 
 
 #[derive(Clone, Entity)]
 #[derive(Clone, Entity)]

+ 31 - 8
src/server.rs

@@ -1,22 +1,39 @@
-use crate::{config, realm, schema, UIDCError};
+use crate::{
+    config,
+    ext::{ExternalAuthenticator, GithubAuthenticator},
+    realm, schema, UIDCError,
+};
 
 
 mod oidc;
 mod oidc;
 mod session;
 mod session;
 mod um;
 mod um;
 
 
+pub use session::SessionHelper;
+
 pub struct ServerState {
 pub struct ServerState {
-    config: config::Config,
-    db: schema::UIDCDatabase,
-    templates: handlebars::Handlebars<'static>,
-    realms: realm::RealmCache,
+    pub config: config::Config,
+    pub db: schema::UIDCDatabase,
+    pub templates: handlebars::Handlebars<'static>,
+    pub realms: realm::RealmCache,
+    pub client: surf::Client,
+    pub github_auth: Option<GithubAuthenticator>,
 }
 }
 
 
-#[derive(Clone)]
-struct ServerStateWrapper {
+#[derive(Copy, Clone)]
+pub struct ServerStateWrapper {
     core: &'static ServerState,
     core: &'static ServerState,
 }
 }
 
 
-async fn index(req: tide::Request<ServerStateWrapper>) -> tide::Result<tide::Response> {
+impl std::ops::Deref for ServerStateWrapper {
+    type Target = ServerState;
+    fn deref(&self) -> &Self::Target {
+        self.core
+    }
+}
+
+pub type UIDCRequest = tide::Request<ServerStateWrapper>;
+
+async fn index(req: UIDCRequest) -> tide::Result<tide::Response> {
     let shelper = session::SessionHelper::new(&req);
     let shelper = session::SessionHelper::new(&req);
 
 
     let realm = shelper.get_realm()?;
     let realm = shelper.get_realm()?;
@@ -56,10 +73,12 @@ pub async fn run_server(
     port: u16,
     port: u16,
 ) -> Result<(), UIDCError> {
 ) -> Result<(), UIDCError> {
     let core_state = Box::leak(Box::new(ServerState {
     let core_state = Box::leak(Box::new(ServerState {
+        github_auth: GithubAuthenticator::build(&db, &config),
         realms: realm::RealmCache::new(config.clone(), db.clone()),
         realms: realm::RealmCache::new(config.clone(), db.clone()),
         config,
         config,
         db,
         db,
         templates: handlebars::Handlebars::new(),
         templates: handlebars::Handlebars::new(),
+        client: surf::client(),
     }));
     }));
 
 
     core_state.templates.set_dev_mode(true);
     core_state.templates.set_dev_mode(true);
@@ -89,6 +108,10 @@ pub async fn run_server(
     oidc::oidc_server(app.at("/:realm/"));
     oidc::oidc_server(app.at("/:realm/"));
     um::um_server(app.at("/:realm/"));
     um::um_server(app.at("/:realm/"));
 
 
+    if let Some(gh) = &core_state.github_auth {
+        gh.register_routes(&mut app);
+    }
+
     app.listen((bind, port))
     app.listen((bind, port))
         .await
         .await
         .map_err(|_| UIDCError::Abort("couldn't listen on port"))?;
         .map_err(|_| UIDCError::Abort("couldn't listen on port"))?;

+ 12 - 8
src/server/oidc.rs

@@ -134,17 +134,21 @@ async fn jwks(request: Request) -> tide::Result<tide::Response> {
 async fn discovery_config(request: Request) -> tide::Result<tide::Response> {
 async fn discovery_config(request: Request) -> tide::Result<tide::Response> {
     let server_config = &request.state().core.config;
     let server_config = &request.state().core.config;
     let realm_name = request.param("realm").unwrap();
     let realm_name = request.param("realm").unwrap();
-    let base_url = format!(
-        "{}/{}",
-        server_config.base_url,
-        realm_name
-    );
-
-    let Some(realm) = &request.state().core.db.realms.keyed(realm_name).first().get()? else {
+    let base_url = format!("{}/{}", server_config.base_url, realm_name);
+
+    let Some(_realm) = &request
+        .state()
+        .core
+        .db
+        .realms
+        .keyed(realm_name)
+        .first()
+        .get()?
+    else {
         return Ok(tide::Response::builder(404)
         return Ok(tide::Response::builder(404)
             .header(tide::http::headers::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
             .header(tide::http::headers::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
             .body("No such realm")
             .body("No such realm")
-            .build())
+            .build());
     };
     };
 
 
     let config_response = serde_json::json!({
     let config_response = serde_json::json!({

+ 30 - 24
src/server/session.rs

@@ -1,11 +1,12 @@
-use crate::{schema, user::UserExt, UIDCError};
+use crate::{ext::ExternalAuthenticator, schema, user::UserExt, UIDCError};
 use microrm::{prelude::*, schema::Stored};
 use microrm::{prelude::*, schema::Stored};
 use serde::Deserialize;
 use serde::Deserialize;
 use tide::http::Cookie;
 use tide::http::Cookie;
 
 
-pub(super) struct SessionHelper<'l> {
-    db: &'l schema::UIDCDatabase,
-    tmpl: &'l handlebars::Handlebars<'l>,
+use super::ServerState;
+
+pub struct SessionHelper<'l> {
+    state: &'l ServerState,
     realm_str: &'l str,
     realm_str: &'l str,
 }
 }
 
 
@@ -16,14 +17,14 @@ const SESSION_COOKIE_NAME: &str = "uidc_session";
 impl<'l> SessionHelper<'l> {
 impl<'l> SessionHelper<'l> {
     pub fn new(req: &'l Request) -> Self {
     pub fn new(req: &'l Request) -> Self {
         Self {
         Self {
-            db: &req.state().core.db,
-            tmpl: &req.state().core.templates,
+            state: req.state(),
             realm_str: req.param("realm").expect("no realm param?"),
             realm_str: req.param("realm").expect("no realm param?"),
         }
         }
     }
     }
 
 
     pub fn get_realm(&self) -> tide::Result<Stored<schema::Realm>> {
     pub fn get_realm(&self) -> tide::Result<Stored<schema::Realm>> {
-        self.db
+        self.state
+            .db
             .realms
             .realms
             .keyed(self.realm_str)
             .keyed(self.realm_str)
             .get()?
             .get()?
@@ -39,7 +40,7 @@ impl<'l> SessionHelper<'l> {
             .expose();
             .expose();
         let session_id = base64::encode_config(session_id, base64::URL_SAFE_NO_PAD);
         let session_id = base64::encode_config(session_id, base64::URL_SAFE_NO_PAD);
 
 
-        let session = self.db.sessions.insert_and_return(schema::Session {
+        let session = self.state.db.sessions.insert_and_return(schema::Session {
             session_id: session_id.clone(),
             session_id: session_id.clone(),
             auth: Default::default(),
             auth: Default::default(),
             expiry: time::OffsetDateTime::now_utc() + time::Duration::minutes(10),
             expiry: time::OffsetDateTime::now_utc() + time::Duration::minutes(10),
@@ -62,7 +63,8 @@ impl<'l> SessionHelper<'l> {
 
 
     pub fn get_session(&self, req: &Request) -> Option<schema::Session> {
     pub fn get_session(&self, req: &Request) -> Option<schema::Session> {
         req.cookie(SESSION_COOKIE_NAME).and_then(|sid| {
         req.cookie(SESSION_COOKIE_NAME).and_then(|sid| {
-            self.db
+            self.state
+                .db
                 .sessions
                 .sessions
                 .keyed(sid.value())
                 .keyed(sid.value())
                 .get()
                 .get()
@@ -110,7 +112,7 @@ impl<'l> SessionHelper<'l> {
 }
 }
 
 
 impl<'l> SessionHelper<'l> {
 impl<'l> SessionHelper<'l> {
-    fn render_login_from_auth(
+    pub fn render_login_from_auth(
         &self,
         &self,
         mut response: tide::Response,
         mut response: tide::Response,
         redirect: String,
         redirect: String,
@@ -119,7 +121,7 @@ impl<'l> SessionHelper<'l> {
     ) -> tide::Response {
     ) -> tide::Response {
         let to_present: Option<schema::AuthChallengeType> = match auth {
         let to_present: Option<schema::AuthChallengeType> = match auth {
             None => Some(schema::AuthChallengeType::Username),
             None => Some(schema::AuthChallengeType::Username),
-            Some(auth) => auth.pending_challenges.as_ref().first().copied(),
+            Some(auth) => auth.pending_challenges.as_ref().first().cloned(),
         };
         };
 
 
         if let Some(to_present) = to_present {
         if let Some(to_present) = to_present {
@@ -138,22 +140,26 @@ impl<'l> SessionHelper<'l> {
         error_msg: Option<String>,
         error_msg: Option<String>,
     ) -> tide::Response {
     ) -> tide::Response {
         let do_challenge = |ty, ch| {
         let do_challenge = |ty, ch| {
-            self.tmpl
+            let gh = &self.state.github_auth;
+            self.state
+                .templates
                 .render(
                 .render(
                     "id_v1_login",
                     "id_v1_login",
                     &serde_json::json!(
                     &serde_json::json!(
                         {
                         {
                             "challenge":
                             "challenge":
                                 format!(r#"
                                 format!(r#"
-                            <td class="challenge-type">
-                                <input type="hidden" name="challenge_type" value="{:?}" />
-                                {}
-                            </td>
-                            <td class="challenge-content">{}</td>
-                            "#,
+                                    <td class="challenge-type">
+                                        <input type="hidden" name="challenge_type" value="{:?}" />
+                                        {}
+                                    </td>
+                                    <td class="challenge-content">{}</td>
+                                    "#,
                                     to_present, ty, ch),
                                     to_present, ty, ch),
                             "redirect": redirect,
                             "redirect": redirect,
-                            "error_msg": error_msg.iter().collect::<Vec<_>>()
+                            "error_msg": error_msg.iter().collect::<Vec<_>>(),
+                            "show_gh_login": gh.is_some(),
+                            "gh_login_url": gh.as_ref().map(|gh| gh.generate_login_url(self.realm_str, redirect.as_str())).unwrap_or(String::new()),
                         }
                         }
                     ),
                     ),
                 )
                 )
@@ -261,10 +267,10 @@ async fn v1_login_post(mut req: Request) -> tide::Result<tide::Response> {
     // check that the response matches what we're expecting next
     // check that the response matches what we're expecting next
     let to_be_presented: Option<schema::AuthChallengeType> = match &auth {
     let to_be_presented: Option<schema::AuthChallengeType> = match &auth {
         None => Some(schema::AuthChallengeType::Username),
         None => Some(schema::AuthChallengeType::Username),
-        Some(auth) => auth.pending_challenges.as_ref().first().copied(),
+        Some(auth) => auth.pending_challenges.as_ref().first().cloned(),
     };
     };
 
 
-    if to_be_presented != Some(challenge) {
+    if to_be_presented.as_ref() != Some(&challenge) {
         Err(tide::Error::from_str(400, "Unexpected challenge type"))?
         Err(tide::Error::from_str(400, "Unexpected challenge type"))?
     }
     }
 
 
@@ -309,10 +315,9 @@ async fn v1_login_post(mut req: Request) -> tide::Result<tide::Response> {
                         .into(),
                         .into(),
                     })?,
                     })?,
                 );
                 );
-                // auth = Some(session.auth.with(id, id).first().get()?.expect("can't re-get just-added entity"));
             }
             }
         }
         }
-        ct => {
+        ctype => {
             if let Some(auth) = auth.as_mut() {
             if let Some(auth) = auth.as_mut() {
                 if let Some(user_id) = auth.pending_user {
                 if let Some(user_id) = auth.pending_user {
                     let user = realm
                     let user = realm
@@ -321,7 +326,8 @@ async fn v1_login_post(mut req: Request) -> tide::Result<tide::Response> {
                         .get()?
                         .get()?
                         .ok_or(UIDCError::Abort("session auth refers to nonexistent user"))?;
                         .ok_or(UIDCError::Abort("session auth refers to nonexistent user"))?;
 
 
-                    let verification = user.verify_challenge_by_type(ct, body.challenge.as_bytes());
+                    let verification =
+                        user.verify_challenge_by_type(ctype, body.challenge.as_bytes());
 
 
                     match verification {
                     match verification {
                         Ok(true) => {
                         Ok(true) => {

+ 4 - 2
src/user.rs

@@ -44,11 +44,13 @@ pub trait UserExt {
         challenge_type: schema::AuthChallengeType,
         challenge_type: schema::AuthChallengeType,
         response: &[u8],
         response: &[u8],
     ) -> Result<bool, UIDCError> {
     ) -> Result<bool, UIDCError> {
-        let ct = challenge_type.into();
         let challenge = self
         let challenge = self
             .stored_user()
             .stored_user()
             .auth
             .auth
-            .with(schema::AuthChallenge::ChallengeType, &ct)
+            .with(
+                schema::AuthChallenge::ChallengeType,
+                challenge_type.clone().into_serialized(),
+            )
             .first()
             .first()
             .get()?
             .get()?
             .ok_or(UserError::NoSuchChallenge)?;
             .ok_or(UserError::NoSuchChallenge)?;

+ 5 - 0
tmpl/id_v1_login.tmpl

@@ -40,6 +40,11 @@
                         </tr>
                         </tr>
                     </table>
                     </table>
                 </form>
                 </form>
+                <ul>
+                    {{ #if show_gh_login }}
+                        <li><a href="{{ gh_login_url }}">Log in with GitHub</a></li>
+                    {{ /if }}
+                </ul>
             </div>
             </div>
             <div class="footer">
             <div class="footer">
                 Copyright &copy; Kestrel 2024. Released under the terms of the 4-clause BSD license.
                 Copyright &copy; Kestrel 2024. Released under the terms of the 4-clause BSD license.