Compare commits

..

70 Commits

Author SHA1 Message Date
Simon Bihel
3278ff752a
Update demo's oidc crate version (#59)
Close #58
2023-06-02 15:25:36 +01:00
dependabot[bot]
59d4287702
Bump @sideway/formula from 3.0.0 to 3.0.1 in /js/ui (#51)
Bumps [@sideway/formula](https://github.com/sideway/formula) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/sideway/formula/releases)
- [Commits](https://github.com/sideway/formula/compare/v3.0.0...v3.0.1)

---
updated-dependencies:
- dependency-name: "@sideway/formula"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-17 15:26:57 +01:00
dependabot[bot]
55ab104847
Bump http-cache-semantics from 4.1.0 to 4.1.1 in /js/ui (#49)
Bumps [http-cache-semantics](https://github.com/kornelski/http-cache-semantics) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/kornelski/http-cache-semantics/releases)
- [Commits](https://github.com/kornelski/http-cache-semantics/compare/v4.1.0...v4.1.1)

---
updated-dependencies:
- dependency-name: http-cache-semantics
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-17 15:26:33 +01:00
dependabot[bot]
7592805a5a
Bump cookiejar from 2.1.3 to 2.1.4 in /js/ui (#48)
Bumps [cookiejar](https://github.com/bmeck/node-cookiejar) from 2.1.3 to 2.1.4.
- [Release notes](https://github.com/bmeck/node-cookiejar/releases)
- [Commits](https://github.com/bmeck/node-cookiejar/commits)

---
updated-dependencies:
- dependency-name: cookiejar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-17 15:26:23 +01:00
dependabot[bot]
08da6508b6
Bump json5 from 2.2.1 to 2.2.3 in /js/ui (#47)
Bumps [json5](https://github.com/json5/json5) from 2.2.1 to 2.2.3.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v2.2.1...v2.2.3)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-17 15:26:10 +01:00
dependabot[bot]
069d67f3f2
Bump webpack from 5.75.0 to 5.76.0 in /js/ui (#52)
Bumps [webpack](https://github.com/webpack/webpack) from 5.75.0 to 5.76.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.75.0...v5.76.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-17 14:48:08 +01:00
dependabot[bot]
a34e47e704
Bump tokio from 1.23.0 to 1.24.2 (#55)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.23.0 to 1.24.2.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/commits)

---
updated-dependencies:
- dependency-name: tokio
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-17 14:20:50 +01:00
dependabot[bot]
896108c1ff
Bump h2 from 0.3.15 to 0.3.17 (#56)
Bumps [h2](https://github.com/hyperium/h2) from 0.3.15 to 0.3.17.
- [Release notes](https://github.com/hyperium/h2/releases)
- [Changelog](https://github.com/hyperium/h2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/h2/compare/v0.3.15...v0.3.17)

---
updated-dependencies:
- dependency-name: h2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-17 13:55:43 +01:00
Simon Bihel
051d9e4efe
Fix Docker build 2023-04-17 13:25:42 +01:00
Simon Bihel
d4ce5c1616
Update openidconnect-rs (#54)
And add integration test
2023-04-17 13:16:39 +01:00
Simon Bihel
d59c4db602
Enable avatar resolution (#45)
Also updates a variety of dependencies
2022-12-19 18:51:46 +00:00
dependabot[bot]
782143415c
Bump decode-uri-component from 0.2.0 to 0.2.2 in /js/ui (#44)
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-08 08:15:32 +00:00
Juliano
85c68b15e3
Bump siwe-web3modal version (#43)
Close #41
2022-11-28 08:42:33 +00:00
Simon Bihel
9abdb04fbd
Clarify Docker deployment 2022-09-28 11:00:50 +01:00
Simon Bihel
bf41effadc
Fix URL comparison by not using strings
Close #36
2022-09-01 15:14:21 +01:00
Simon Bihel
8d74d51d50
Fix demo deserialisation 2022-08-22 11:38:18 +01:00
Simon Bihel
d8251bdb59
Update dev setup 2022-08-19 11:37:43 +01:00
Simon Bihel
5ae46af107
Remove secure cookie for easier local testing
Still rely on samesite and httponly
2022-08-19 11:37:43 +01:00
Simon Bihel
3c61d2308f
Remove auth for client info retrieval 2022-08-19 11:37:43 +01:00
Simon Bihel
8e2dd0e3b7
Update README.md 2022-08-02 16:14:59 +01:00
Simon Bihel
b72fed63a3
Clarify README for Worker 2022-07-26 09:46:00 +01:00
dependabot[bot]
1be52e5d4f
Bump terser from 5.12.0 to 5.14.2 in /js/ui (#34)
Bumps [terser](https://github.com/terser/terser) from 5.12.0 to 5.14.2.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-21 10:00:12 +01:00
dependabot[bot]
98f55c93f1
Bump svelte from 3.46.4 to 3.49.0 in /js/ui (#33)
Bumps [svelte](https://github.com/sveltejs/svelte) from 3.46.4 to 3.49.0.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/compare/v3.46.4...v3.49.0)

---
updated-dependencies:
- dependency-name: svelte
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-18 09:30:52 +01:00
Simon Bihel
63ecdae730
Remove hardcoded chain ID (#32)
Close #31
2022-07-05 15:30:28 +01:00
dependabot[bot]
297f3c29cf
Bump regex from 1.5.4 to 1.5.5 (#30)
Bumps [regex](https://github.com/rust-lang/regex) from 1.5.4 to 1.5.5.
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.5.4...1.5.5)

---
updated-dependencies:
- dependency-name: regex
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-07 11:32:05 +01:00
dependabot[bot]
467511a362
Bump cross-fetch from 2.2.5 to 2.2.6 in /js/ui (#28)
Bumps [cross-fetch](https://github.com/lquixada/cross-fetch) from 2.2.5 to 2.2.6.
- [Release notes](https://github.com/lquixada/cross-fetch/releases)
- [Commits](https://github.com/lquixada/cross-fetch/compare/v2.2.5...v2.2.6)

---
updated-dependencies:
- dependency-name: cross-fetch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-01 11:22:51 +01:00
Simon Bihel
6cb6dfbedb
Temporary fix for Docker (#29)
Populate env vars with dummy values until a better solution is found. It will probably require to move away from serving the frontend as simply static files.
2022-06-01 10:54:50 +01:00
Simon Bihel
49ba3be44a
Add Docker CI 2022-05-30 11:19:08 +01:00
Simon Bihel
b172f31d40
Translate deserialisation error as 400 (#27)
Also fixes a Clippy warning
2022-04-19 13:46:16 +01:00
Simon Bihel
2e54c4446a
Update web3modal providers (#26)
And use the official config for coinbase wallet
2022-04-01 21:13:31 +01:00
Simon Bihel
bf1b79e299
Fix walletconnet config (#24) 2022-03-29 18:20:28 +01:00
dependabot[bot]
0870d7a6cb
Bump minimist from 1.2.5 to 1.2.6 in /js/ui (#23)
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-29 17:50:08 +01:00
dependabot[bot]
9acd5b7ccf
Bump node-forge from 1.2.1 to 1.3.0 in /js/ui (#22)
Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.2.1 to 1.3.0.
- [Release notes](https://github.com/digitalbazaar/forge/releases)
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.2.1...v1.3.0)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-28 11:15:03 +01:00
Simon Bihel
4fe2d8d5fb
Fix clippy warning 2022-03-28 11:06:20 +01:00
Ryan Li
a80f7134a1
Fix typo in readme for wrangler kv (#19) 2022-03-22 09:32:31 +00:00
Simon Bihel
01ff18e742
Update README 2022-03-08 10:18:40 +00:00
Simon Bihel
5351d3e49d
Use underscore for KV namespace
Close #13
2022-03-08 10:18:07 +00:00
Gregório Granado Magalhães
40eb888e5a
Move TOS and Privacy Policy inside modal (#15)
Also fix lock file

Co-authored-by: Simon Bihel <simon.bihel@spruceid.com>
2022-03-02 16:24:20 +00:00
Simon Bihel
aa73f363bc
Add privacy policy and terms of use 2022-02-28 09:54:25 +00:00
Simon Bihel
7e6de7fb2c
Fix typo in error message 2022-02-24 10:17:41 +00:00
Simon Bihel
66b2c51339
Rework browser sessions 2022-02-21 11:29:37 +00:00
Simon Bihel
950a493dc4
Use cryptographically secure client secrets 2022-02-19 14:45:49 +00:00
Simon Bihel
3bdd57ed56
Add client info in the frontend 2022-02-14 22:58:20 +00:00
Simon Bihel
e0241feb9a
Complete client configuration endpoints 2022-02-14 22:37:10 +00:00
Simon Bihel
27e36e2aa6
Add client metadata GET endpoint 2022-02-14 12:50:57 +00:00
Simon Bihel
c5addaa072
Bump worker v0.0.9 2022-02-14 10:00:50 +00:00
Simon Bihel
0f110d70b4
Add basic demo SPA to display claims 2022-02-11 13:54:20 +00:00
Simon Bihel
15763cd0bb
Add CORS support for the Worker 2022-02-11 12:30:10 +00:00
Simon Bihel
79ffb360fe
Fix message domain check 2022-02-08 12:27:06 +00:00
Simon Bihel
bf48c61007
Update worker-rs 2022-02-08 10:53:28 +00:00
Simon Bihel
9d42c5a99b
Add placeholder for avatar 2022-02-08 10:36:38 +00:00
Simon Bihel
4bce398253
Update siwe-rs 2022-02-08 10:36:26 +00:00
Simon Bihel
a9414d5e21
Change sign-in message 2022-02-08 10:21:11 +00:00
Simon Bihel
0f7adfb5ab
Use caip address for the subject ID 2022-02-07 11:48:14 +00:00
Simon Bihel
2519f85fd7
Trim scopes 2022-02-07 11:14:16 +00:00
wyc
9355598888
Fix wrangler_example.toml name (#12) 2022-01-27 16:56:04 +00:00
Ryan Swart
2227842675
Remove old img dir from Dockerfile (#11) 2022-01-27 09:46:54 +00:00
Simon Bihel
0f99d19561
Add logo images for external users (e.g. auth0) 2022-01-26 20:22:40 +00:00
Simon Bihel
74dfe54711
Fix oidc_nonce handling frontend 2022-01-26 20:22:26 +00:00
Simon Bihel
b34027b096
Add ENS domain information 2022-01-25 16:29:02 +00:00
Simon Bihel
452bd2d9fb
Forbid URIs with fragments 2022-01-19 22:28:38 +00:00
Simon Bihel
5c0b748373
JWT support for userinfo 2022-01-19 17:07:55 +00:00
Simon Bihel
bd3d2e8a1e
Only support RS256 for now 2022-01-19 13:39:27 +00:00
Simon Bihel
0a8e7332fc
Add expires_in for token response 2022-01-19 12:50:40 +00:00
Simon Bihel
d3d3f0163c
Add auth_time 2022-01-19 12:40:48 +00:00
Simon Bihel
4390605586
Fix img location 2022-01-19 10:51:47 +00:00
Simon Bihel
f807571bdf
Add maturity disclaimer 2022-01-11 17:25:03 +00:00
Simon Bihel
bbcacf4232
Cloudflare Worker version (#6)
Refactor/generalise API/DB interactions out of OIDC.
2022-01-11 10:43:06 +00:00
Simon Bihel
9d725552e0
Add CI (#5) 2021-12-20 16:48:29 +00:00
Simon Bihel
c37577f218
Address some issues from the core conformance suite (#4)
Also address Clippy warnings
2021-12-20 16:29:43 +00:00
45 changed files with 20611 additions and 11540 deletions

View File

@ -1,3 +1,3 @@
target/
js/ui/node_modules/
static/build
.github
example
target

41
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: ci
on: push
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-Dwarnings"
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- cargo_target: "x86_64-unknown-linux-gnu"
- cargo_target: "wasm32-unknown-unknown"
steps:
- name: Clone repo
uses: actions/checkout@v3
- name: Add targets
run: rustup target add wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- name: Docker Compose
run: docker-compose -f test/docker-compose.yml up -d redis
- name: Build
env:
CARGO_BUILD_TARGET: ${{ matrix.cargo_target }}
run: cargo build
- name: Clippy
env:
CARGO_BUILD_TARGET: ${{ matrix.cargo_target }}
run: cargo clippy
- name: Fmt
env:
CARGO_BUILD_TARGET: ${{ matrix.cargo_target }}
run: cargo fmt -- --check
- name: Test
if: matrix.cargo_target == 'x86_64-unknown-linux-gnu'
run: cargo test

23
.github/workflows/docker.yml vendored Normal file
View File

@ -0,0 +1,23 @@
name: Publish Docker
on:
push:
branches: [ main ]
release:
types: [published, created, edited]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Build and push image
uses: elgohr/Publish-Docker-Github-Action@master
with:
name: spruceid/siwe_oidc
username: ${{ github.actor }}
password: ${{ secrets.GH_PACKAGE_PUSH_TOKEN }}
registry: ghcr.io
tag_names: true
tag_semver: true
snapshot: true

5
.gitignore vendored
View File

@ -1,2 +1,5 @@
/target
**/target
/static/build
**/wrangler.toml
**/node_module
**/dist

2693
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,38 +5,64 @@ edition = "2021"
authors = ["Spruce Systems, Inc."]
license = "MIT OR Apache-2.0"
repository = "https://github.com/spruceid/siwe-oidc/"
description = "OpenID Connect Identity Provider for Sign-In with Ethereum."
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
anyhow = "1.0.51"
axum = { version = "0.3.4", features = ["headers"] }
chrono = "0.4.19"
headers = "0.3.5"
anyhow = "1.0.53"
headers = "0.3.6"
hex = "0.4.3"
iri-string = { version = "0.4", features = ["serde-std"] }
openidconnect = "2.1.2"
iri-string = { version = "0.6", features = ["serde"] }
openidconnect = "3.0.0"
rand = "0.8.4"
rsa = { version = "0.5.0", features = ["alloc"] }
rust-argon2 = "0.8"
rsa = { version = "0.7.0" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.72"
siwe = "0.1"
async-session = "3.0.0"
serde_json = "1.0.78"
serde_urlencoded = "0.7.0"
siwe = "0.5.0"
thiserror = "1.0.30"
tokio = { version = "1.14.0", features = ["full"] }
tower-http = { version = "0.2.0", features = ["fs", "trace", "cors"] }
tracing = "0.1.29"
tracing-subscriber = { version = "0.3.2", features = ["env-filter"] }
tracing = "0.1.30"
url = { version = "2.2", features = ["serde"] }
urlencoding = "2.1.0"
uuid = { version = "0.8", features = ["serde", "v4"] }
figment = { version = "0.10.6", features = ["toml", "env"] }
sha2 = "0.9.0"
cookie = "0.15.1"
sha2 = "0.10.0"
cookie = "0.16.0"
bincode = "1.3.3"
# bb8-redis = "0.10.1"
bb8 = "0.7"
# async-redis-session = "0.2.2"
redis = { version = "0.21", default-features = false, features = ["tokio-comp", "cluster"] }
async-trait = "0.1"
async-trait = "0.1.52"
ethers-core = "1.0.2"
ethers-providers = "1.0.2"
lazy_static = "1.4"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
axum = { version = "0.4.3", features = ["headers"] }
chrono = "0.4.19"
figment = { version = "0.10.6", features = ["toml", "env"] }
tokio = { version = "1.24.2", features = ["full"] }
tower-http = { version = "0.2.0", features = ["fs", "trace", "cors"] }
tracing-subscriber = { version = "0.3.2", features = ["env-filter"] }
bb8-redis = "0.10.1"
uuid = { version = "0.8", features = ["serde", "v4"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
chrono = { version = "0.4.19", features = ["wasmbind"] }
console_error_panic_hook = { version = "0.1" }
getrandom = { version = "0.2", features = ["js"] }
matchit = "0.4.2"
uuid = { version = "0.8", features = ["serde", "v4", "wasm-bindgen"] }
wee_alloc = { version = "0.4" }
worker = "0.0.12"
time = { version = "0.3.17", features = ["wasm-bindgen"] }
[profile.release]
opt-level = "z"
lto = true
[dev-dependencies]
env_logger = "0.10.0"
test-log = "0.2.11"
tokio = { version = "1.24.2", features = ["macros", "rt"] }
ethers-signers = "1.0.2"
[package.metadata.wasm-pack.profile.profiling]
wasm-opt = ['-g', '-O']

View File

@ -1,4 +1,4 @@
FROM clux/muslrust:1.57.0 as chef
FROM clux/muslrust:stable as chef
WORKDIR /siwe-oidc
RUN cargo install cargo-chef
@ -14,8 +14,10 @@ COPY --from=dep_planner /siwe-oidc/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
FROM node:16-alpine as node_builder
ENV FORTMATIC_KEY=""
ENV INFURA_ID=""
ENV PORTIS_ID=""
ADD --chown=node:node ./static /siwe-oidc/static
ADD --chown=node:node ./js/ui/img /siwe-oidc/static/img
ADD --chown=node:node ./js/ui /siwe-oidc/js/ui
WORKDIR /siwe-oidc/js/ui
RUN npm install
@ -33,6 +35,7 @@ WORKDIR /siwe-oidc
RUN mkdir -p ./static
COPY --from=node_builder /siwe-oidc/static/ ./static/
COPY --from=builder /siwe-oidc/siwe-oidc.toml ./
ENV SIWEOIDC_ADDRESS="0.0.0.0"
EXPOSE 8000
ENTRYPOINT ["siwe-oidc"]
LABEL org.opencontainers.image.source https://github.com/spruceid/siwe-oidc

View File

@ -2,16 +2,69 @@
## Getting Started
### Dependencies
Two versions are available, a stand-alone binary (using Axum and Redis) and a
Cloudflare Worker. They use the same code base and are selected at compile time
(compiling for `wasm32` will make the Worker version).
### Cloudflare Worker
You will need [`wrangler`](https://github.com/cloudflare/wrangler).
First, copy the configuration file template:
```bash
cp wrangler_example.toml wrangler.toml
```
Then replace the following fields:
- `account_id`: your Cloudflare account ID;
- `zone_id`: (Optional) DNS zone ID;
- `kv_namespaces`: a KV namespace ID (created with `wrangler kv:namespace create SIWE_OIDC`); and
- the environment variables under `vars`.
You will also need to add a secret RSA key in PEM format:
```
wrangler secret put RSA_PEM
```
At this point, you should be able to create/publish the worker:
```
wrangler publish
```
The IdP currently only supports having the **frontend under the same subdomain as
the API**. Here is the configuration for Cloudflare Pages:
- `Build command`: `cd js/ui && npm install && npm run build`;
- `Build output directory`: `/static`; and
- `Root directory`: `/`.
And you will need to add some rules to do the routing between the Page and the
Worker. Here are the rules for the Worker (the Page being used as the fallback
on the subdomain):
```
siweoidc.example.com/s*
siweoidc.example.com/u*
siweoidc.example.com/r*
siweoidc.example.com/a*
siweoidc.example.com/t*
siweoidc.example.com/j*
siweoidc.example.com/c*
siweoidc.example.com/.w*
```
### Stand-Alone Binary
> Note that currently the published Docker image doesn't support all wallets due
> to the need of bundling secrets for web3modal at compile-time.
#### Dependencies
Redis, or a Redis compatible database (e.g. MemoryDB in AWS), is required.
### Starting the IdP
#### Starting the IdP
The Docker image is available at `ghcr.io/spruceid/siwe_oidc:0.1.0`. Here is an
example usage:
```bash
docker run -p 8000:8000 -e SIWEOIDC_ADDRESS="0.0.0.0" -e SIWEOIDC_REDIS_URL="redis://redis" ghcr.io/spruceid/siwe_oidc:latest
docker run -p 8000:8000 -e SIWEOIDC_REDIS_URL="redis://redis" ghcr.io/spruceid/siwe_oidc:latest
```
It can be configured either with the `siwe-oidc.toml` configuration file, or
@ -26,23 +79,42 @@ through environment variables:
### OIDC Functionalities
The current flow is very basic -- after the user is authenticated you will
receive an Ethereum address as the subject (`sub` field).
receive:
- an Ethereum address as the subject (`sub` field); and
- an ENS domain as the `preferred_username` (with a fallback to the address).
For the core OIDC information, it is available under
`/.well-known/openid-configuration`.
OIDC Conformance Suite:
- 🟨 (25/29, and 10 skipped) [basic](https://www.certification.openid.net/plan-detail.html?plan=gXe7Ju1O1afZa&public=true) (`email` scope skipped, `profile` scope partially supported, ACR, `prompt=none` and request URIs yet to be supported);
- 🟩 [config](https://www.certification.openid.net/plan-detail.html?plan=SAmBjvtyfTDVn&public=true);
- 🟧 [dynamic code](https://www.certification.openid.net/plan-detail.html?plan=7rexGcCd4SWJa&public=true).
### TODO Items
* Additional information, from native projects (e.g. ENS domains), to more
traditional ones (e.g. email).
* Additional information, from native projects (e.g. ENS domains profile
pictures), to more traditional ones (e.g. email).
## Development
### Cloudflare Worker
```bash
wrangler dev
```
You can now use http://127.0.0.1:8787/.well-known/openid-configuration.
> At the moment it's not possible to use it end-to-end with the frontend as they
> need to share the same host (i.e. port), unless using a local load-balancer.
### Stand Alone Binary
A Docker Compose is available to test the IdP locally with Keycloak.
1. You will first need to run:
```bash
docker-compose up -d
docker-compose -f test/docker-compose.yml up -d
```
2. And then edit your `/etc/hosts` to have `siwe-oidc` point to `127.0.0.1`.
@ -51,3 +123,9 @@ docker-compose up -d
3. In Keycloak, you will need to create a new IdP. You can use
`http://siwe-oidc:8000/.well-known/openid-configuration` to fill the settings
automatically. As for the client ID/secret, you can use `sdf`/`sdf`.
## Disclaimer
Our identity provider for Sign-In with Ethereum has not yet undergone a formal
security audit. We welcome continued feedback on the usability, architecture,
and security of this implementation.

3
example/demo/.env Normal file
View File

@ -0,0 +1,3 @@
CLIENT_ID="8f169184-8815-457c-a32f-85b39fbcfca7"
CLIENT_SECRET="430d8ade-d2d7-48c1-9c68-b97fcd73967d"
REDIRECT_URI="https://demo-oidc.login.xyz/callback"

17
example/demo/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "demo"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
yew = "0.19.3"
yew-router = "0.16.0"
url = "2.2.2"
lazy_static = "1.4.0"
serde = "1.0.136"
openidconnect = "3.1.1"
wasm-bindgen-futures = "0.4.29"
serde_json = "1.0.78"
chrono = { version = "0.4.19", features = ["wasmbind"] }

30
example/demo/README.md Normal file
View File

@ -0,0 +1,30 @@
# Demo Single Page Application for the OIDC IdP
This demo's purpose is to display the claims that are shared with Relying
Parties. It is currently deployed at https://demo-oidc.login.xyz.
## Dependencies
```sh
$ cargo install trunk
$ rustup target add wasm32-unknown-unknown
```
## Development
```sh
trunk serve --open
```
## Deploy
```sh
cp wrangler_example.toml wrangler.toml
```
And fill in `account_id` and `zone_id`.
```sh
$ source .env
$ trunk build
$ wrangler publish
```

10
example/demo/index.html Normal file
View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
</head>
<body>
</body>
</html>

122
example/demo/src/main.rs Normal file
View File

@ -0,0 +1,122 @@
use openidconnect::{
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
reqwest::async_http_client,
AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, Scope, TokenResponse,
};
use serde::Deserialize;
use url::Url;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::{hooks::use_location, prelude::*};
lazy_static::lazy_static! {
static ref CLIENT_ID: String = option_env!("CLIENT_ID").unwrap_or("fb24a7d9-6db9-476b-93c4-e8562e750250").to_string();
static ref CLIENT_SECRET: String = option_env!("CLIENT_SECRET").unwrap_or("6aae6334-148f-464c-a4bf-a204e62e197c").to_string();
static ref REDIRECT_URI: Url = Url::parse(option_env!("REDIRECT_URI").unwrap_or("http://localhost:8080/callback")).unwrap();
}
#[derive(Clone, Routable, PartialEq)]
enum Route {
#[at("/")]
Home,
#[at("/callback")]
OIDCCallback,
#[not_found]
#[at("/404")]
NotFound,
}
#[function_component(SIWE)]
pub fn siwe() -> Html {
html! {
<>
<form action="https://oidc.login.xyz/authorize">
<input type="hidden" name="client_id" value={ CLIENT_ID.clone() } />
<input type="hidden" name="response_type" value="code" />
<input type="hidden" name="nonce" value="nonce" />
<input type="hidden" name="scope" value="openid profile" />
<input type="hidden" name="state" value="state" />
<input type="hidden" name="redirect_uri" value={ REDIRECT_URI.to_string() } />
<input type="submit" value="Sign-In with Ethereum using OpenID Connect" />
</form>
</>
}
}
#[derive(Deserialize)]
struct CallbackParams {
code: String,
// state: String,
}
#[function_component(Callback)]
pub fn callback() -> Html {
let location = use_location().unwrap();
let params: CallbackParams = location.query().unwrap();
let claims = use_state(String::default);
let claims2 = claims.clone();
spawn_local(async move {
let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::new("https://oidc.login.xyz/".to_string()).unwrap(),
async_http_client,
)
.await
.unwrap();
let client = CoreClient::from_provider_metadata(
provider_metadata,
ClientId::new(CLIENT_ID.to_string()),
Some(ClientSecret::new(CLIENT_SECRET.to_string())),
);
let (_auth_url, _csrf_token, nonce) = client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
|| CsrfToken::new("state".to_string()),
|| Nonce::new("nonce".to_string()),
)
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("profile".to_string()))
.url();
let token_response = client
.exchange_code(AuthorizationCode::new(params.code))
.request_async(async_http_client)
.await
.unwrap();
let id_token = token_response.id_token().unwrap();
claims2.set(
serde_json::to_string(
id_token
.claims(&client.id_token_verifier(), &nonce)
.unwrap(),
)
.unwrap(),
);
});
html! {
<>
<p>{ (*claims).clone() }</p>
</>
}
}
fn switch(routes: &Route) -> Html {
match routes {
Route::Home => html! { <SIWE /> },
Route::OIDCCallback => html! { <Callback /> },
Route::NotFound => html! { <Redirect<Route> to={Route::Home}/> },
}
}
#[function_component(App)]
fn app() -> Html {
html! {
<BrowserRouter>
<Switch<Route> render={Switch::render(switch)} />
</BrowserRouter>
}
}
fn main() {
yew::start_app::<App>();
}

3
example/demo/workers-site/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
worker

View File

@ -0,0 +1,39 @@
import { getAssetFromKV, mapRequestToAsset } from '@cloudflare/kv-asset-handler'
const DEBUG = false
addEventListener('fetch', event => {
event.respondWith(handleEvent(event))
})
async function handleEvent(event) {
let options = {}
options.mapRequestToAsset = spaRouting()
options.cacheControl = {
bypassCache: DEBUG,
}
try {
const page = await getAssetFromKV(event, options)
const response = new Response(page.body, page)
response.headers.set('X-XSS-Protection', '1; mode=block')
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('Referrer-Policy', 'unsafe-url')
response.headers.set('Feature-Policy', 'none')
return response
} catch (e) {
return new Response(e.message || e.toString(), { status: 500 })
}
}
function spaRouting() {
return request => {
let defaultAssetKey = mapRequestToAsset(request)
let url = new URL(defaultAssetKey.url)
if (url.pathname.includes(".html")) {
url.pathname = "/index.html"
}
return new Request(url.toString(), defaultAssetKey)
}
}

View File

@ -0,0 +1,10 @@
{
"private": true,
"version": "1.0.0",
"description": "A template for kick starting a Cloudflare Workers project",
"main": "index.js",
"license": "MIT",
"dependencies": {
"@cloudflare/kv-asset-handler": "~0.1.2"
}
}

View File

@ -0,0 +1,19 @@
name = "siwe-oidc-demo"
type = "webpack"
account_id = ""
workers_dev = false
zone_id = ""
routes = ["demo-oidc.login.xyz/*"]
compatibility_date = "2022-02-10"
[build]
command = "cargo install -q trunk && trunk build"
[build.upload]
format = "service-worker"
[site]
bucket = "./dist"

View File

@ -1,4 +0,0 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1024" height="1024" rx="512" fill="#0052FF"/>
<path d="M512.147 692C412.697 692 332.146 611.45 332.146 512C332.146 412.55 412.697 332 512.147 332C601.247 332 675.197 396.95 689.447 482H870.797C855.497 297.2 700.846 152 512.147 152C313.396 152 152.146 313.25 152.146 512C152.146 710.75 313.396 872 512.147 872C700.846 872 855.497 726.8 870.797 542H689.297C675.047 627.05 601.247 692 512.147 692Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 535 B

25694
js/ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,58 +2,59 @@
"name": "svelte-app",
"version": "1.0.0",
"devDependencies": {
"@tsconfig/svelte": "^1.0.10",
"@types/node": "^14.11.1",
"@typescript-eslint/eslint-plugin": "^4.21.0",
"@typescript-eslint/parser": "^4.21.0",
"@tsconfig/svelte": "^3.0.0",
"@types/node": "^17.0.7",
"@typescript-eslint/eslint-plugin": "^5.9.0",
"@typescript-eslint/parser": "^5.9.0",
"assert": "^2.0.0",
"autoprefixer": "^10.2.5",
"base64-loader": "^1.0.0",
"buffer": "^6.0.3",
"cross-env": "^7.0.3",
"crypto-browserify": "^3.12.0",
"css-loader": "^5.0.1",
"css-loader": "^6.5.1",
"cssnano": "^5.0.8",
"dotenv-webpack": "^7.0.3",
"eslint": "^7.23.0",
"eslint": "^8.6.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-svelte3": "^3.1.2",
"https-browserify": "^1.0.0",
"mini-css-extract-plugin": "^1.3.4",
"mini-css-extract-plugin": "^2.4.5",
"os-browserify": "^0.3.0",
"postcss": "^8.2.8",
"postcss-load-config": "^3.0.1",
"postcss-loader": "^5.2.0",
"postcss-loader": "^6.2.1",
"precss": "^4.0.0",
"prettier": "^2.2.1",
"prettier-plugin-svelte": "^2.2.0",
"process": "^0.11.10",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"svelte": "^3.31.2",
"svelte-check": "^1.0.46",
"svelte": "^3.49.0",
"svelte-check": "^2.2.11",
"svelte-loader": "^3.0.0",
"svelte-preprocess": "^4.3.0",
"svg-url-loader": "^7.1.1",
"tailwindcss": "^2.0.4",
"ts-loader": "^8.0.4",
"tailwindcss": "^3.0.9",
"ts-loader": "^9.2.6",
"tslib": "^2.0.1",
"typescript": "^4.0.3",
"webpack": "^5.16.0",
"webpack": "^5.76.0",
"webpack-cli": "^4.4.0",
"webpack-dev-server": "^3.11.2"
"webpack-dev-server": "^4.6.0"
},
"scripts": {
"build": "cross-env NODE_ENV=production webpack",
"dev": "webpack serve --content-base ../../static --port 9080",
"dev": "webpack serve --static-directory ../../static --port 9080",
"validate": "svelte-check"
},
"dependencies": {
"@coinbase/wallet-sdk": "^3.0.5",
"@portis/web3": "^4.0.6",
"@spruceid/siwe-web3modal": "^0.1.5",
"@toruslabs/torus-embed": "^1.18.3",
"@walletconnect/web3-provider": "^1.6.6",
"@spruceid/siwe-web3modal": "^0.1.11",
"@toruslabs/torus-embed": "^1.21.0",
"@walletconnect/web3-provider": "^1.7.7",
"fortmatic": "^2.2.1",
"walletlink": "^2.2.8"
"url": "^0.11.0"
}
}

View File

@ -4,7 +4,8 @@
import Torus from '@toruslabs/torus-embed';
import WalletConnectProvider from '@walletconnect/web3-provider';
import Fortmatic from 'fortmatic';
import WalletLink from 'walletlink';
import { onMount } from 'svelte';
import CoinbaseWalletSDK from "@coinbase/wallet-sdk";
// TODO: REMOVE DEFAULTS:
// main.ts will parse the params from the server
@ -13,8 +14,7 @@
export let redirect: string;
export let state: string;
export let oidc_nonce: string;
let uri: string = window.location.href.split('?')[0];
export let client_id: string;
// Could be exposed in the future.
export let useENS: boolean = true;
@ -23,12 +23,13 @@
let client = new Client({
session: {
domain,
uri,
domain: window.location.host,
uri: window.location.origin,
useENS,
version: '1',
// TODO: Vet this as the default statement.
statement: 'Sign-In With Ethereum OpenID-Connect',
statement: `You are signing-in to ${domain}.`,
resources: [redirect],
},
modal: {
theme: 'dark',
@ -55,42 +56,36 @@
key: process.env.FORTMATIC_KEY,
},
},
'custom-coinbase': {
display: {
logo: 'img/coinbase.svg',
name: 'Coinbase',
description: 'Scan with WalletLink to connect',
},
walletlink: {
package: CoinbaseWalletSDK,
options: {
appName: 'Sign-In with Ethereum',
networkUrl: `https://mainnet.infura.io/v3/${process.env.INFURA_ID}`,
chainId: 1,
darkMode: false,
},
package: WalletLink,
connector: async (_, options) => {
const { appName, networkUrl, chainId, darkMode } = options;
const walletLink = new WalletLink({
appName,
darkMode,
});
const provider = walletLink.makeWeb3Provider(networkUrl, chainId);
await provider.enable();
return provider;
},
appName: "Sign-In with Ethereum",
infuraId: process.env.INFURA_ID
}
},
},
},
});
let client_metadata = {};
onMount(async () => {
try {
client_metadata = fetch(`${window.location.origin}/client/${client_id}`).then((response) => response.json());
} catch (e) {
console.error(e);
}
});
let oidc_nonce_param = '';
if (oidc_nonce != '') {
if (oidc_nonce != null && oidc_nonce != '') {
oidc_nonce_param = `&oidc_nonce=${oidc_nonce}`;
}
client.on('signIn', (result) => {
console.log(result);
window.location.replace(
`/sign_in?redirect_uri=${encodeURI(redirect)}&state=${encodeURI(state)}${encodeURI(oidc_nonce_param)}`,
`/sign_in?redirect_uri=${encodeURI(redirect)}&state=${encodeURI(state)}&client_id=${encodeURI(
client_id,
)}${encodeURI(oidc_nonce_param)}`,
);
});
</script>
@ -99,13 +94,22 @@
class="bg-no-repeat bg-cover bg-center bg-swe-landing font-satoshi bg-gray flex-grow w-full h-screen items-center flex justify-center flex-wrap flex-col"
style="background-image: url('img/swe-landing.svg');"
>
<div class="w-96 text-center bg-white rounded-20 text-grey flex h-96 flex-col p-12 shadow-lg shadow-white">
<img height="72" width="72" class="self-center mb-8" src="img/modal_icon.png" alt="Ethereum logo" />
<div class="w-96 text-center bg-white rounded-20 text-grey flex h-100 flex-col p-12 shadow-lg shadow-white">
{#if client_metadata.logo_uri}
<div class="flex justify-evenly items-stretch">
<img height="72" width="72" class="self-center mb-8" src="img/modal_icon.png" alt="Ethereum logo" />
<img height="72" width="72" class="self-center mb-8" src={client_metadata.logo_uri} alt="Client logo" />
</div>
{:else}
<img height="72" width="72" class="self-center mb-8" src="img/modal_icon.png" alt="Ethereum logo" />
{/if}
<h5>Welcome</h5>
<span class="text-xs">Sign-In with Ethereum to continue to your application</span>
<span class="text-xs">
Sign-In with Ethereum to continue to {client_metadata.client_name ? client_metadata.client_name : domain}
</span>
<button
class="h-12 border hover:scale-105 justify-evenly shadow-xl border-white mt-auto duration-100 ease-in-out transition-all transform flex items-center"
class="h-12 border hover:scale-105 justify-evenly shadow-xl border-white mt-4 duration-100 ease-in-out transition-all transform flex items-center"
on:click={() => {
client.signIn(nonce).catch((e) => {
console.error(e);
@ -138,6 +142,14 @@
</svg>
<p class="font-bold">Sign-In with Ethereum</p>
</button>
<div class="self-center mt-auto text-center font-semibold text-xs">
By using this service you agree to the <a href="/legal/terms-of-use.pdf">Terms of Use</a> and
<a href="/legal/privacy-policy.pdf">Privacy Policy</a>.
</div>
{#if client_metadata.client_uri}
<span class="text-xs mt-4">Request linked to {client_metadata.client_uri}</span>
{/if}
</div>
</div>

View File

@ -11,7 +11,8 @@ const app = new App({
nonce: params.get('nonce'),
redirect: params.get('redirect_uri'),
state: params.get('state'),
oidc_nonce: params.get('oidc_nonce')
oidc_nonce: params.get('oidc_nonce'),
client_id: params.get('client_id')
}
});

View File

@ -1,6 +1,6 @@
module.exports = {
darkMode: 'class',
purge: ['./src/**/*.{html,js,svelte,ts}'],
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
screens: {
@ -177,4 +177,4 @@ module.exports = {
'1/10': '0 10%',
},
},
};
};

View File

@ -1,5 +1,8 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*", "src/node_modules/**/*"],
"exclude": ["node_modules/*", "__sapper__/*", "static/*"]
}
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*", "src/node_modules/**/*"],
"exclude": ["node_modules/*", "__sapper__/*", "static/*"],
"compilerOptions": {
"types": ["node", "svelte"]
}
}

View File

@ -27,6 +27,7 @@ module.exports = {
path: false,
process: require.resolve('process/browser'),
stream: require.resolve('stream-browserify'),
url: require.resolve("url")
// util: false,
}
},
@ -95,6 +96,7 @@ module.exports = {
new MiniCssExtractPlugin({
filename: '[name].css'
}),
new webpack.EnvironmentPlugin(prod ? ['INFURA_ID', 'PORTIS_ID', 'FORTMATIC_KEY'] : []),
],
devtool: prod ? false : 'source-map',
devServer: {

368
src/axum_lib.rs Normal file
View File

@ -0,0 +1,368 @@
use anyhow::{anyhow, Result};
use axum::{
extract::{self, Extension, Form, Path, Query, TypedHeader},
http::{
header::{self, HeaderMap},
StatusCode,
},
response::{self, IntoResponse, Redirect},
routing::{delete, get, get_service, post},
Json, Router,
};
use figment::{
providers::{Env, Format, Serialized, Toml},
Figment,
};
use headers::{
self,
authorization::{Basic, Bearer},
Authorization, ContentType, Header,
};
use openidconnect::core::{
CoreClientMetadata, CoreClientRegistrationResponse, CoreJsonWebKeySet, CoreProviderMetadata,
CoreTokenResponse, CoreUserInfoClaims, CoreUserInfoJsonWebToken,
};
use rand::rngs::OsRng;
use rsa::{
pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey, LineEnding},
RsaPrivateKey,
};
use std::net::SocketAddr;
use tower_http::{
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
use tracing::info;
use super::config;
use super::oidc::{self, CustomError};
use ::siwe_oidc::db::*;
impl IntoResponse for CustomError {
fn into_response(self) -> response::Response {
match self {
CustomError::BadRequest(_) => {
(StatusCode::BAD_REQUEST, self.to_string()).into_response()
}
CustomError::BadRequestRegister(e) => {
(StatusCode::BAD_REQUEST, Json::from(e)).into_response()
}
CustomError::BadRequestToken(e) => {
(StatusCode::BAD_REQUEST, Json::from(e)).into_response()
}
CustomError::Unauthorized(_) => {
(StatusCode::UNAUTHORIZED, self.to_string()).into_response()
}
CustomError::NotFound => (StatusCode::NOT_FOUND, self.to_string()).into_response(),
CustomError::Redirect(uri) => Redirect::to(
uri.parse().unwrap(),
// .map_err(|e| anyhow!("Could not parse URI: {}", e))?,
)
.into_response(),
CustomError::Other(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
}
}
}
}
async fn jwk_set(
Extension(private_key): Extension<RsaPrivateKey>,
) -> Result<Json<CoreJsonWebKeySet>, CustomError> {
let jwks = oidc::jwks(private_key)?;
Ok(jwks.into())
}
async fn provider_metadata(
Extension(config): Extension<config::Config>,
) -> Result<Json<CoreProviderMetadata>, CustomError> {
Ok(oidc::metadata(config.base_url)?.into())
}
async fn token(
Form(form): Form<oidc::TokenForm>,
bearer: Option<TypedHeader<Authorization<Bearer>>>,
basic: Option<TypedHeader<Authorization<Basic>>>,
Extension(private_key): Extension<RsaPrivateKey>,
Extension(config): Extension<config::Config>,
Extension(redis_client): Extension<RedisClient>,
) -> Result<Json<CoreTokenResponse>, CustomError> {
let secret = if let Some(b) = bearer {
Some(b.0 .0.token().to_string())
} else {
basic.map(|b| b.0 .0.password().to_string())
};
let token_response = oidc::token(
form,
secret,
private_key,
config.base_url,
config.require_secret,
config.eth_provider,
&redis_client,
)
.await?;
Ok(token_response.into())
}
async fn authorize(
Query(params): Query<oidc::AuthorizeParams>,
Extension(redis_client): Extension<RedisClient>,
) -> Result<(HeaderMap, Redirect), CustomError> {
let (url, session_cookie) = oidc::authorize(params, &redis_client).await?;
let mut headers = HeaderMap::new();
headers.insert(
header::SET_COOKIE,
session_cookie.to_string().parse().unwrap(),
);
Ok((
headers,
Redirect::to(
url.as_str()
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
))
}
async fn sign_in(
Query(params): Query<oidc::SignInParams>,
TypedHeader(cookies): TypedHeader<headers::Cookie>,
Extension(redis_client): Extension<RedisClient>,
Extension(config): Extension<config::Config>,
) -> Result<Redirect, CustomError> {
let url = oidc::sign_in(&config.base_url, params, cookies, &redis_client).await?;
Ok(Redirect::to(
url.as_str()
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
))
}
async fn register(
extract::Json(payload): extract::Json<CoreClientMetadata>,
Extension(config): Extension<config::Config>,
Extension(redis_client): Extension<RedisClient>,
) -> Result<(StatusCode, Json<CoreClientRegistrationResponse>), CustomError> {
let registration = oidc::register(payload, config.base_url, &redis_client).await?;
Ok((StatusCode::CREATED, registration.into()))
}
struct UserInfoResponseJWT(Json<CoreUserInfoJsonWebToken>);
impl IntoResponse for UserInfoResponseJWT {
fn into_response(self) -> response::Response {
response::Response::builder()
.status(StatusCode::OK)
.header(ContentType::name(), "application/jwt")
.body(
serde_json::to_string(&self.0 .0)
.unwrap()
.replace('"', "")
.into_response()
.into_body(),
)
.unwrap()
}
}
enum UserInfoResponse {
Json(Json<CoreUserInfoClaims>),
Jwt(UserInfoResponseJWT),
}
impl IntoResponse for UserInfoResponse {
fn into_response(self) -> response::Response {
match self {
UserInfoResponse::Json(j) => j.into_response(),
UserInfoResponse::Jwt(j) => j.into_response(),
}
}
}
// TODO CORS
// TODO need validation of the token
async fn userinfo(
Extension(private_key): Extension<RsaPrivateKey>,
Extension(config): Extension<config::Config>,
payload: Option<Form<oidc::UserInfoPayload>>,
bearer: Option<TypedHeader<Authorization<Bearer>>>, // TODO maybe go through FromRequest https://github.com/tokio-rs/axum/blob/main/examples/jwt/src/main.rs
Extension(redis_client): Extension<RedisClient>,
) -> Result<UserInfoResponse, CustomError> {
let payload = if let Some(Form(p)) = payload {
p
} else {
oidc::UserInfoPayload { access_token: None }
};
let claims = oidc::userinfo(
config.base_url,
config.eth_provider,
private_key,
bearer.map(|b| b.0 .0),
payload,
&redis_client,
)
.await?;
Ok(match claims {
oidc::UserInfoResponse::Json(c) => UserInfoResponse::Json(c.into()),
oidc::UserInfoResponse::Jwt(c) => UserInfoResponse::Jwt(UserInfoResponseJWT(c.into())),
})
}
async fn clientinfo(
Path(client_id): Path<String>,
Extension(redis_client): Extension<RedisClient>,
) -> Result<Json<CoreClientMetadata>, CustomError> {
Ok(oidc::clientinfo(client_id, &redis_client).await?.into())
}
async fn client_update(
Path(client_id): Path<String>,
extract::Json(payload): extract::Json<CoreClientMetadata>,
bearer: Option<TypedHeader<Authorization<Bearer>>>,
Extension(redis_client): Extension<RedisClient>,
) -> Result<(), CustomError> {
oidc::client_update(client_id, payload, bearer.map(|b| b.0 .0), &redis_client).await
}
async fn client_delete(
Path(client_id): Path<String>,
bearer: Option<TypedHeader<Authorization<Bearer>>>,
Extension(redis_client): Extension<RedisClient>,
) -> Result<(StatusCode, ()), CustomError> {
Ok((
StatusCode::NO_CONTENT,
oidc::client_delete(client_id, bearer.map(|b| b.0 .0), &redis_client).await?,
))
}
async fn healthcheck() {}
pub async fn main() {
let config = Figment::from(Serialized::defaults(config::Config::default()))
.merge(Toml::file("siwe-oidc.toml").nested())
.merge(Env::prefixed("SIWEOIDC_").split("__").global());
let config = config.extract::<config::Config>().unwrap();
tracing_subscriber::fmt::init();
let redis_client = RedisClient::new(&config.redis_url)
.await
.expect("Could not build Redis client");
for (id, entry) in &config.default_clients.clone() {
let entry: ClientEntry =
serde_json::from_str(entry).expect("Deserialisation of ClientEntry failed");
redis_client
.set_client(id.to_string(), entry.clone())
.await
.unwrap(); // TODO
}
let private_key = if let Some(key) = &config.rsa_pem {
RsaPrivateKey::from_pkcs1_pem(key)
.map_err(|e| anyhow!("Failed to load private key: {}", e))
.unwrap()
} else {
info!("Generating key...");
let mut rng = OsRng;
let bits = 2048;
let private = RsaPrivateKey::new(&mut rng, bits)
.map_err(|e| anyhow!("Failed to generate a key: {}", e))
.unwrap();
info!("Generated key.");
info!("{:?}", private.to_pkcs1_pem(LineEnding::LF).unwrap());
private
};
let app = Router::new()
.nest(
"/build",
get_service(ServeDir::new("./static/build")).handle_error(
|error: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
},
),
)
.nest(
"/legal",
get_service(ServeDir::new("./static/legal")).handle_error(
|error: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
},
),
)
.nest(
"/img",
get_service(ServeDir::new("./static/img")).handle_error(
|error: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
},
),
)
.route(
"/",
get_service(ServeFile::new("./static/index.html")).handle_error(
|error: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
},
),
)
.route(
"/error",
get_service(ServeFile::new("./static/error.html")).handle_error(
|error: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
},
),
)
.route(
"/favicon.png",
get_service(ServeFile::new("./static/favicon.png")).handle_error(
|error: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
},
),
)
.route(oidc::METADATA_PATH, get(provider_metadata))
.route(oidc::JWK_PATH, get(jwk_set))
.route(oidc::TOKEN_PATH, post(token))
.route(oidc::AUTHORIZE_PATH, get(authorize))
.route(oidc::REGISTER_PATH, post(register))
.route(oidc::USERINFO_PATH, get(userinfo).post(userinfo))
.route(&format!("{}/:id", oidc::CLIENT_PATH), get(clientinfo))
.route(&format!("{}/:id", oidc::CLIENT_PATH), delete(client_delete))
.route(&format!("{}/:id", oidc::CLIENT_PATH), post(client_update))
.route(oidc::SIGNIN_PATH, get(sign_in))
.route("/health", get(healthcheck))
.layer(Extension(private_key))
.layer(Extension(config.clone()))
.layer(Extension(redis_client))
.layer(TraceLayer::new_for_http());
let addr = SocketAddr::from((config.address, config.port));
tracing::info!("Listening on {}", addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}

View File

@ -15,6 +15,7 @@ pub struct Config {
pub default_clients: HashMap<String, String>,
// TODO secret is more complicated than that, and needs to be in the well-known config
pub require_secret: bool,
pub eth_provider: Option<Url>,
}
impl Default for Config {
@ -26,7 +27,8 @@ impl Default for Config {
rsa_pem: None,
redis_url: Url::parse("redis://localhost").unwrap(),
default_clients: HashMap::default(),
require_secret: true,
require_secret: false,
eth_provider: None,
}
}
}

231
src/db/cf.rs Normal file
View File

@ -0,0 +1,231 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use matchit::Node;
use std::collections::HashMap;
use worker::*;
use super::*;
const KV_NAMESPACE: &str = "SIWE_OIDC";
const DO_NAMESPACE: &str = "SIWE-OIDC-CODES";
// /!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\
// Heavily relying on:
// A Durable Object is given 30 seconds of additional CPU time for every
// request it processes, including WebSocket messages. In the absence of
// failures, in-memory state should not be reset after less than 30 seconds of
// inactivity.
// /!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\/!\
// Wanted to use TimedCache but it (probably) crashes because it's using std::time::Instant which isn't available on wasm32.
#[durable_object]
pub struct DOCodes {
// codes: TimedCache<String, CodeEntry>,
codes: HashMap<String, (DateTime<Utc>, CodeEntry)>,
// state: State,
// env: Env,
}
#[durable_object]
impl DurableObject for DOCodes {
fn new(state: State, _env: Env) -> Self {
Self {
// codes: TimedCache::with_lifespan(ENTRY_LIFETIME.try_into().unwrap()),
codes: HashMap::new(),
// state,
// env,
}
}
async fn fetch(&mut self, mut req: Request) -> worker::Result<Response> {
// Can't use the Router because we need to reference self (thus move the var to the closure)
if matches!(req.method(), Method::Get) {
let mut matcher = Node::new();
matcher.insert("/:code", ())?;
let path = req.path();
let matched = match matcher.at(&path) {
Ok(m) => m,
Err(_) => return Response::error("Bad request", 400),
};
let code = if let Some(c) = matched.params.get("code") {
c
} else {
return Response::error("Bad request", 400);
};
if let Some(c) = self.codes.get(code) {
if c.0 + Duration::seconds(ENTRY_LIFETIME.try_into().unwrap()) < Utc::now() {
self.codes.remove(code);
Response::error("Not found", 404)
} else {
Response::from_json(&c.1)
}
} else {
Response::error("Not found", 404)
}
} else if matches!(req.method(), Method::Post) {
let mut matcher = Node::new();
matcher.insert("/:code", ())?;
let path = req.path();
let matched = match matcher.at(&path) {
Ok(m) => m,
Err(_) => return Response::error("Bad request", 400),
};
let code = if let Some(c) = matched.params.get("code") {
c
} else {
return Response::error("Bad request", 400);
};
let code_entry = match req.json().await {
Ok(p) => p,
Err(e) => return Response::error(format!("Bad request: {}", e), 400),
};
self.codes
.insert(code.to_string(), (Utc::now(), code_entry));
Response::empty()
} else {
Response::error("Method Not Allowed", 405)
}
}
}
pub struct CFClient {
pub ctx: RouteContext<()>,
pub url: Url,
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl DBClient for CFClient {
async fn set_client(&self, client_id: String, client_entry: ClientEntry) -> Result<()> {
self.ctx
.kv(KV_NAMESPACE)
.map_err(|e| anyhow!("Failed to get KV store: {}", e))?
.put(
&format!("{}/{}", KV_CLIENT_PREFIX, client_id),
serde_json::to_string(&client_entry)
.map_err(|e| anyhow!("Failed to serialize client entry: {}", e))?,
)
.map_err(|e| anyhow!("Failed to build KV put: {}", e))?
.execute()
.await
.map_err(|e| anyhow!("Failed to put KV: {}", e))?;
Ok(())
}
async fn get_client(&self, client_id: String) -> Result<Option<ClientEntry>> {
Ok(self
.ctx
.kv(KV_NAMESPACE)
.map_err(|e| anyhow!("Failed to get KV store: {}", e))?
.get(&format!("{}/{}", KV_CLIENT_PREFIX, client_id))
.json()
.await
.map_err(|e| anyhow!("Failed to get KV: {}", e))?)
}
async fn delete_client(&self, client_id: String) -> Result<()> {
Ok(self
.ctx
.kv(KV_NAMESPACE)
.map_err(|e| anyhow!("Failed to get KV store: {}", e))?
.delete(&format!("{}/{}", KV_CLIENT_PREFIX, client_id))
.await
.map_err(|e| anyhow!("Failed to get KV: {}", e))?)
}
async fn set_code(&self, code: String, code_entry: CodeEntry) -> Result<()> {
let namespace = self
.ctx
.durable_object(DO_NAMESPACE)
.map_err(|e| anyhow!("Failed to retrieve Durable Object: {}", e))?;
let stub = namespace
.id_from_name(&code)
.map_err(|e| anyhow!("Failed to retrieve Durable Object from ID: {}", e))?
.get_stub()
.map_err(|e| anyhow!("Failed to retrieve Durable Object stub: {}", e))?;
let mut headers = Headers::new();
headers.set("Content-Type", "application/json").unwrap();
let mut url = self.url.clone();
url.set_path(&code);
url.set_query(None);
let req = Request::new_with_init(
url.as_str(),
&RequestInit {
body: Some(wasm_bindgen::JsValue::from_str(
&serde_json::to_string(&code_entry)
.map_err(|e| anyhow!("Failed to serialize: {}", e))?,
)),
method: Method::Post,
headers,
..Default::default()
},
)
.map_err(|e| anyhow!("Failed to construct request for Durable Object: {}", e))?;
let res = stub
.fetch_with_request(req)
.await
.map_err(|e| anyhow!("Request to Durable Object failed: {}", e))?;
match res.status_code() {
200 => Ok(()),
code => Err(anyhow!("Error fetching from Durable Object: {}", code)),
}
}
async fn get_code(&self, code: String) -> Result<Option<CodeEntry>> {
let namespace = self
.ctx
.durable_object(DO_NAMESPACE)
.map_err(|e| anyhow!("Failed to retrieve Durable Object: {}", e))?;
let stub = namespace
.id_from_name(&code)
.map_err(|e| anyhow!("Failed to retrieve Durable Object from ID: {}", e))?
.get_stub()
.map_err(|e| anyhow!("Failed to retrieve Durable Object stub: {}", e))?;
let mut url = self.url.clone();
url.set_path(&code);
url.set_query(None);
let mut res = stub
.fetch_with_str(url.as_str())
.await
.map_err(|e| anyhow!("Request to Durable Object failed: {}", e))?;
match res.status_code() {
200 => Ok(Some(res.json().await.map_err(|e| {
anyhow!(
"Response to Durable Object failed to be deserialized: {}",
e
)
})?)),
404 => Ok(None),
code => Err(anyhow!("Error fetching from Durable Object: {}", code)),
}
}
async fn set_session(&self, id: String, entry: SessionEntry) -> Result<()> {
self.ctx
.kv(KV_NAMESPACE)
.map_err(|e| anyhow!("Failed to get KV store: {}", e))?
.put(
&format!("{}/{}", KV_SESSION_PREFIX, id),
serde_json::to_string(&entry)
.map_err(|e| anyhow!("Failed to serialize client entry: {}", e))?,
)
.map_err(|e| anyhow!("Failed to build KV put: {}", e))?
.expiration_ttl(SESSION_LIFETIME)
.execute()
.await
.map_err(|e| anyhow!("Failed to put KV: {}", e))?;
Ok(())
}
async fn get_session(&self, id: String) -> Result<Option<SessionEntry>> {
Ok(self
.ctx
.kv(KV_NAMESPACE)
.map_err(|e| anyhow!("Failed to get KV store: {}", e))?
.get(&format!("{}/{}", KV_SESSION_PREFIX, id))
.json()
.await
.map_err(|e| anyhow!("Failed to get KV: {}", e))?)
}
}

59
src/db/mod.rs Normal file
View File

@ -0,0 +1,59 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::{offset::Utc, DateTime};
use ethers_core::types::H160;
use openidconnect::{core::CoreClientMetadata, Nonce, RegistrationAccessToken};
use serde::{Deserialize, Serialize};
#[cfg(not(target_arch = "wasm32"))]
mod redis;
#[cfg(not(target_arch = "wasm32"))]
pub use redis::RedisClient;
#[cfg(target_arch = "wasm32")]
mod cf;
#[cfg(target_arch = "wasm32")]
pub use cf::CFClient;
const KV_CLIENT_PREFIX: &str = "clients";
const KV_SESSION_PREFIX: &str = "sessions";
pub const ENTRY_LIFETIME: usize = 30;
pub const SESSION_LIFETIME: u64 = 300; // 5min
pub const SESSION_COOKIE_NAME: &str = "session";
#[derive(Clone, Serialize, Deserialize)]
pub struct CodeEntry {
pub exchange_count: usize,
pub address: H160,
pub nonce: Option<Nonce>,
pub client_id: String,
pub auth_time: DateTime<Utc>,
pub chain_id: Option<u64>, // TODO temporary, for transition purposes
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ClientEntry {
pub secret: String,
pub metadata: CoreClientMetadata,
pub access_token: Option<RegistrationAccessToken>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct SessionEntry {
pub siwe_nonce: String,
pub oidc_nonce: Option<Nonce>,
pub secret: String,
pub signin_count: u64,
}
// Using a trait to easily pass async functions with async_trait
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait DBClient {
async fn set_client(&self, client_id: String, client_entry: ClientEntry) -> Result<()>;
async fn get_client(&self, client_id: String) -> Result<Option<ClientEntry>>;
async fn delete_client(&self, client_id: String) -> Result<()>;
async fn set_code(&self, code: String, code_entry: CodeEntry) -> Result<()>;
async fn get_code(&self, code: String) -> Result<Option<CodeEntry>>;
async fn set_session(&self, id: String, entry: SessionEntry) -> Result<()>;
async fn get_session(&self, id: String) -> Result<Option<SessionEntry>>;
}

154
src/db/redis.rs Normal file
View File

@ -0,0 +1,154 @@
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use bb8_redis::{
bb8::{self, Pool},
redis::AsyncCommands,
RedisConnectionManager,
};
use url::Url;
use super::*;
#[derive(Clone)]
pub struct RedisClient {
pool: Pool<RedisConnectionManager>,
}
impl RedisClient {
pub async fn new(url: &Url) -> Result<Self> {
let manager = RedisConnectionManager::new(url.clone())
.context("Could not build Redis connection manager")?;
let pool = bb8::Pool::builder()
.build(manager.clone())
.await
.context("Coud not build Redis pool")?;
Ok(Self { pool })
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl DBClient for RedisClient {
async fn set_client(&self, client_id: String, client_entry: ClientEntry) -> Result<()> {
let mut conn = self
.pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
conn.set(
format!("{}/{}", KV_CLIENT_PREFIX, client_id),
serde_json::to_string(&client_entry)
.map_err(|e| anyhow!("Failed to serialize client entry: {}", e))?,
)
.await
.map_err(|e| anyhow!("Failed to set kv: {}", e))?;
Ok(())
}
async fn get_client(&self, client_id: String) -> Result<Option<ClientEntry>> {
let mut conn = self
.pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
let entry: Option<String> = conn
.get(format!("{}/{}", KV_CLIENT_PREFIX, client_id))
.await
.map_err(|e| anyhow!("Failed to get kv: {}", e))?;
if let Some(e) = entry {
Ok(serde_json::from_str(&e)
.map_err(|e| anyhow!("Failed to deserialize client entry: {}", e))?)
} else {
Ok(None)
}
}
async fn delete_client(&self, client_id: String) -> Result<()> {
let mut conn = self
.pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
conn.del(format!("{}/{}", KV_CLIENT_PREFIX, client_id))
.await
.map_err(|e| anyhow!("Failed to get kv: {}", e))?;
Ok(())
}
async fn set_code(&self, code: String, code_entry: CodeEntry) -> Result<()> {
let mut conn = self
.pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
conn.set_ex(
code.to_string(),
hex::encode(
bincode::serialize(&code_entry)
.map_err(|e| anyhow!("Failed to serialise code: {}", e))?,
),
ENTRY_LIFETIME,
)
.await
.map_err(|e| anyhow!("Failed to set kv: {}", e))?;
Ok(())
}
async fn get_code(&self, code: String) -> Result<Option<CodeEntry>> {
let mut conn = self
.pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
let serialized_entry: Option<Vec<u8>> = conn
.get(code)
.await
.map_err(|e| anyhow!("Failed to get kv: {}", e))?;
if serialized_entry.is_none() {
return Ok(None);
}
let code_entry: CodeEntry = bincode::deserialize(
&hex::decode(serialized_entry.unwrap())
.map_err(|e| anyhow!("Failed to decode code entry: {}", e))?,
)
.map_err(|e| anyhow!("Failed to deserialize code: {}", e))?;
Ok(Some(code_entry))
}
async fn set_session(&self, id: String, entry: SessionEntry) -> Result<()> {
let mut conn = self
.pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
conn.set_ex(
format!("{}/{}", KV_SESSION_PREFIX, id),
serde_json::to_string(&entry)
.map_err(|e| anyhow!("Failed to serialize session entry: {}", e))?,
SESSION_LIFETIME.try_into().unwrap(),
)
.await
.map_err(|e| anyhow!("Failed to set kv: {}", e))?;
Ok(())
}
async fn get_session(&self, id: String) -> Result<Option<SessionEntry>> {
let mut conn = self
.pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
let entry: Option<String> = conn
.get(format!("{}/{}", KV_SESSION_PREFIX, id))
.await
.map_err(|e| anyhow!("Failed to get kv: {}", e))?;
if let Some(e) = entry {
Ok(serde_json::from_str(&e)
.map_err(|e| anyhow!("Failed to deserialize session entry: {}", e))?)
} else {
Ok(None)
}
}
}

17
src/lib.rs Normal file
View File

@ -0,0 +1,17 @@
#[cfg(target_arch = "wasm32")]
use worker::*;
pub mod db;
#[cfg(target_arch = "wasm32")]
pub mod oidc;
#[cfg(target_arch = "wasm32")]
mod worker_lib;
#[cfg(target_arch = "wasm32")]
use worker_lib::main as worker_main;
#[cfg(target_arch = "wasm32")]
#[event(fetch)]
pub async fn main(req: Request, env: Env, _ctx: worker::Context) -> Result<Response> {
worker_main(req, env).await
}

View File

@ -1,720 +1,17 @@
use anyhow::{anyhow, Result};
// use async_redis_session::RedisSessionStore;
use async_session::MemoryStore;
use axum::{
body::{Bytes, Full},
error_handling::HandleErrorExt,
extract::{self, Extension, Form, Query, TypedHeader},
http::{
header::{self, HeaderMap},
Response, StatusCode,
},
response::{IntoResponse, Redirect},
routing::{get, post, service_method_routing},
AddExtensionLayer, Json, Router,
};
use bb8::Pool;
// use bb8_redis::{bb8, bb8::Pool, redis::AsyncCommands, RedisConnectionManager};
use chrono::{Duration, Utc};
use figment::{
providers::{Env, Format, Serialized, Toml},
Figment,
};
use headers::{self, authorization::Bearer, Authorization};
use hex::FromHex;
use iri_string::types::{UriAbsoluteString, UriString};
use openidconnect::{
core::{
CoreClaimName, CoreClientAuthMethod, CoreClientMetadata, CoreClientRegistrationResponse,
CoreGrantType, CoreIdToken, CoreIdTokenClaims, CoreIdTokenFields, CoreJsonWebKeySet,
CoreJwsSigningAlgorithm, CoreProviderMetadata, CoreResponseType, CoreRsaPrivateSigningKey,
CoreSubjectIdentifierType, CoreTokenResponse, CoreTokenType, CoreUserInfoClaims,
},
registration::{EmptyAdditionalClientMetadata, EmptyAdditionalClientRegistrationResponse},
AccessToken, Audience, AuthUrl, ClientId, ClientSecret, EmptyAdditionalClaims,
EmptyAdditionalProviderMetadata, EmptyExtraTokenFields, IssuerUrl, JsonWebKeyId,
JsonWebKeySetUrl, Nonce, PrivateSigningKey, RedirectUrl, RegistrationUrl, ResponseTypes, Scope,
StandardClaims, SubjectIdentifier, TokenUrl, UserInfoUrl,
};
use rand::rngs::OsRng;
use redis::Commands;
use rsa::{
pkcs1::{FromRsaPrivateKey, ToRsaPrivateKey},
RsaPrivateKey,
};
use serde::{Deserialize, Serialize};
use siwe::eip4361::{Message, Version};
use std::{convert::Infallible, net::SocketAddr, str::FromStr};
use thiserror::Error;
use tower_http::{
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
use tracing::info;
use urlencoding::decode;
use uuid::Uuid;
#[cfg(not(target_arch = "wasm32"))]
mod axum_lib;
#[cfg(not(target_arch = "wasm32"))]
mod config;
mod redis_conn;
mod session;
use redis_conn::*;
use session::*;
const KID: &str = "key1";
const KV_CLIENT_PREFIX: &str = "clients";
const ENTRY_LIFETIME: usize = 60 * 60 * 24 * 2;
type ConnectionPool = Pool<RedisConnectionManager>;
#[derive(Debug, Error)]
pub enum CustomError {
#[error("{0}")]
BadRequest(String),
#[error("{0}")]
Unauthorized(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl IntoResponse for CustomError {
type Body = Full<Bytes>;
type BodyError = Infallible;
fn into_response(self) -> Response<Self::Body> {
match self {
CustomError::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
CustomError::Unauthorized(_) => (StatusCode::UNAUTHORIZED, self.to_string()),
CustomError::Other(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
}
.into_response()
}
}
async fn jwk_set(
Extension(private_key): Extension<RsaPrivateKey>,
) -> Result<Json<CoreJsonWebKeySet>, CustomError> {
let pem = private_key
.to_pkcs1_pem()
.map_err(|e| anyhow!("Failed to serialise key as PEM: {}", e))?;
let jwks = CoreJsonWebKeySet::new(vec![CoreRsaPrivateSigningKey::from_pem(
&pem,
Some(JsonWebKeyId::new(KID.to_string())),
)
.map_err(|e| anyhow!("Invalid RSA private key: {}", e))?
.as_verification_key()]);
Ok(jwks.into())
}
async fn provider_metadata(
Extension(config): Extension<config::Config>,
) -> Result<Json<CoreProviderMetadata>, CustomError> {
let pm = CoreProviderMetadata::new(
IssuerUrl::from_url(config.base_url.clone()),
AuthUrl::from_url(
config
.base_url
.join("authorize")
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
),
JsonWebKeySetUrl::from_url(
config
.base_url
.join("jwk")
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
),
vec![
ResponseTypes::new(vec![CoreResponseType::Code]),
ResponseTypes::new(vec![CoreResponseType::Token, CoreResponseType::IdToken]),
],
vec![CoreSubjectIdentifierType::Pairwise],
vec![CoreJwsSigningAlgorithm::RsaSsaPssSha256],
EmptyAdditionalProviderMetadata {},
)
.set_token_endpoint(Some(TokenUrl::from_url(
config
.base_url
.join("token")
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
)))
.set_userinfo_endpoint(Some(UserInfoUrl::from_url(
config
.base_url
.join("userinfo")
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
)))
.set_scopes_supported(Some(vec![
Scope::new("openid".to_string()),
// Scope::new("email".to_string()),
// Scope::new("profile".to_string()),
]))
.set_claims_supported(Some(vec![
CoreClaimName::new("sub".to_string()),
CoreClaimName::new("aud".to_string()),
// CoreClaimName::new("email".to_string()),
// CoreClaimName::new("email_verified".to_string()),
CoreClaimName::new("exp".to_string()),
CoreClaimName::new("iat".to_string()),
CoreClaimName::new("iss".to_string()),
// CoreClaimName::new("name".to_string()),
// CoreClaimName::new("given_name".to_string()),
// CoreClaimName::new("family_name".to_string()),
// CoreClaimName::new("picture".to_string()),
// CoreClaimName::new("locale".to_string()),
]))
.set_registration_endpoint(Some(RegistrationUrl::from_url(
config
.base_url
.join("register")
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
)))
.set_token_endpoint_auth_methods_supported(Some(vec![CoreClientAuthMethod::ClientSecretPost]));
Ok(pm.into())
}
#[derive(Deserialize)]
struct TokenForm {
code: Uuid,
client_id: String,
client_secret: Option<String>,
grant_type: CoreGrantType, // TODO should just be authorization_code apparently?
}
// TODO should check Authorization header
// Actually, client secret can be
// 1. in the POST (currently supported)
// 2. Authorization header
// 3. JWT
// 4. signed JWT
// according to Keycloak
async fn token(
form: Form<TokenForm>,
Extension(private_key): Extension<RsaPrivateKey>,
Extension(config): Extension<config::Config>,
Extension(pool): Extension<ConnectionPool>,
) -> Result<Json<CoreTokenResponse>, CustomError> {
let mut conn = pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
if let Some(secret) = form.client_secret.clone() {
let stored_secret: Option<String> = conn
.get(format!("{}/{}", KV_CLIENT_PREFIX, form.client_id))
.map_err(|e| anyhow!("Failed to get kv: {}", e))?;
if stored_secret.is_none() {
Err(CustomError::Unauthorized(
"Unrecognised client id.".to_string(),
))?;
}
if secret != stored_secret.unwrap() {
Err(CustomError::Unauthorized("Bad secret.".to_string()))?;
}
} else if config.require_secret {
Err(CustomError::Unauthorized("Secret required.".to_string()))?;
}
let serialized_entry: Option<Vec<u8>> = conn
.get(form.code.to_string())
.map_err(|e| anyhow!("Failed to get kv: {}", e))?;
if serialized_entry.is_none() {
Err(CustomError::BadRequest("Unknown code.".to_string()))?;
}
let code_entry: CodeEntry = bincode::deserialize(
&hex::decode(serialized_entry.unwrap())
.map_err(|e| anyhow!("Failed to decode code entry: {}", e))?,
)
.map_err(|e| anyhow!("Failed to deserialize code: {}", e))?;
if code_entry.exchange_count > 0 {
// TODO use Oauth error response
Err(anyhow!("Code was previously exchanged."))?;
}
conn.set_ex(
form.code.to_string(),
hex::encode(
bincode::serialize(&code_entry)
.map_err(|e| anyhow!("Failed to serialise code: {}", e))?,
),
ENTRY_LIFETIME,
)
.map_err(|e| anyhow!("Failed to set kv: {}", e))?;
let access_token = AccessToken::new(form.code.to_string().clone());
let core_id_token = CoreIdTokenClaims::new(
IssuerUrl::from_url(config.base_url),
vec![Audience::new(form.client_id.clone())],
Utc::now() + Duration::seconds(60),
Utc::now(),
StandardClaims::new(SubjectIdentifier::new(code_entry.address)),
EmptyAdditionalClaims {},
)
.set_nonce(code_entry.nonce);
let pem = private_key
.to_pkcs1_pem()
.map_err(|e| anyhow!("Failed to serialise key as PEM: {}", e))?;
let id_token = CoreIdToken::new(
core_id_token,
&CoreRsaPrivateSigningKey::from_pem(&pem, Some(JsonWebKeyId::new(KID.to_string())))
.map_err(|e| anyhow!("Invalid RSA private key: {}", e))?,
CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256,
Some(&access_token),
None,
)
.map_err(|e| anyhow!("{}", e))?;
Ok(CoreTokenResponse::new(
access_token,
CoreTokenType::Bearer,
CoreIdTokenFields::new(Some(id_token), EmptyExtraTokenFields {}),
)
.into())
}
#[derive(Deserialize)]
struct AuthorizeParams {
client_id: String,
redirect_uri: RedirectUrl,
scope: Scope,
response_type: CoreResponseType,
state: String, // TODO not required
nonce: Option<Nonce>,
}
// TODO handle `registration` parameter
async fn authorize(
session: UserSessionFromSession,
params: Query<AuthorizeParams>,
// Extension(private_key): Extension<RsaPrivateKey>,
) -> Result<(HeaderMap, Redirect), CustomError> {
// TODO: Enforce Client Registration
// let d = std::str::from_utf8(
// &jwk.decrypt(
// PaddingScheme::new_pkcs1v15_encrypt(),
// &params.client_id.as_bytes(),
// )
// .map_err(|e| anyhow!("Failed to decrypt client id: {}", e))?,
// )
// .map_err(|e| anyhow!("Failed to decrypt client id: {}", e))?
// if d != params.redirect_uri.as_str() {
// return Err(anyhow!("Client id not composed of redirect url"));
// };
if params.scope != Scope::new("openid".to_string()) {
Err(anyhow!("Scope not supported"))?;
}
let (nonce, headers) = match session {
UserSessionFromSession::FoundUserSession(nonce) => (nonce, HeaderMap::new()),
UserSessionFromSession::InvalidUserSession(cookie) => {
let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, cookie);
return Ok((
headers,
Redirect::to(
format!(
"/authorize?client_id={}&redirect_uri={}&scope={}&response_type={}&state={}{}",
&params.0.client_id,
&params.0.redirect_uri.to_string(),
&params.0.scope.to_string(),
&params.0.response_type.as_ref(),
&params.0.state,
&params.0.nonce.map(|n| format!("&nonce={}", n.secret())).unwrap_or(String::new())
)
.to_string()
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
));
}
UserSessionFromSession::CreatedFreshUserSession { header, nonce } => {
let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, header);
(nonce, headers)
}
};
let domain = params.redirect_uri.url().host().unwrap();
let oidc_nonce_param = if let Some(n) = &params.nonce {
format!("&oidc_nonce={}", n.secret())
} else {
"".to_string()
};
Ok((
headers,
Redirect::to(
format!(
"/?nonce={}&domain={}&redirect_uri={}&state={}{}",
nonce,
domain,
params.redirect_uri.to_string(),
params.state,
oidc_nonce_param
)
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
))
}
#[derive(Serialize, Deserialize)]
struct SiweCookie {
message: Web3ModalMessage,
signature: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Web3ModalMessage {
pub domain: String,
pub address: String,
pub statement: String,
pub uri: String,
pub version: String,
pub chain_id: String,
pub nonce: String,
pub issued_at: String,
pub expiration_time: Option<String>,
pub not_before: Option<String>,
pub request_id: Option<String>,
pub resources: Option<Vec<String>>,
}
impl Web3ModalMessage {
pub fn to_eip4361_message(&self) -> Result<Message> {
let mut next_resources: Vec<UriString> = Vec::new();
match &self.resources {
Some(resources) => {
for resource in resources {
let x = UriString::from_str(resource)?;
next_resources.push(x)
}
}
None => {}
}
Ok(Message {
domain: self.domain.clone().try_into()?,
address: <[u8; 20]>::from_hex(self.address.chars().skip(2).collect::<String>())?,
statement: self.statement.to_string(),
uri: UriAbsoluteString::from_str(&self.uri)?,
version: Version::from_str(&self.version)?,
chain_id: self.chain_id.to_string(),
nonce: self.nonce.to_string(),
issued_at: self.issued_at.to_string(),
expiration_time: self.expiration_time.clone(),
not_before: self.not_before.clone(),
request_id: self.request_id.clone(),
resources: next_resources,
})
}
}
#[derive(Serialize, Deserialize)]
struct CodeEntry {
exchange_count: usize,
address: String,
nonce: Option<Nonce>,
}
#[derive(Deserialize)]
struct SignInParams {
redirect_uri: RedirectUrl,
state: String,
oidc_nonce: Option<Nonce>,
}
async fn sign_in(
session: UserSessionFromSession,
params: Query<SignInParams>,
TypedHeader(cookies): TypedHeader<headers::Cookie>,
Extension(pool): Extension<ConnectionPool>,
) -> Result<(HeaderMap, Redirect), CustomError> {
let mut headers = HeaderMap::new();
let siwe_cookie: SiweCookie = match cookies.get("siwe") {
Some(c) => serde_json::from_str(
&decode(c).map_err(|e| anyhow!("Could not decode siwe cookie: {}", e))?,
)
.map_err(|e| anyhow!("Could not deserialize siwe cookie: {}", e))?,
None => Err(anyhow!("No `siwe` cookie"))?,
};
let (nonce, headers) = match session {
UserSessionFromSession::FoundUserSession(nonce) => (nonce, HeaderMap::new()),
UserSessionFromSession::InvalidUserSession(header) => {
headers.insert(header::SET_COOKIE, header);
return Ok((
headers,
Redirect::to(
format!(
"/authorize?client_id={}&redirect_uri={}&scope=openid&response_type=code&state={}",
&params.0.redirect_uri.to_string(),
&params.0.redirect_uri.to_string(),
&params.0.state,
)
.to_string()
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
));
}
UserSessionFromSession::CreatedFreshUserSession { .. } => {
return Ok((
headers,
Redirect::to(
format!(
"/authorize?client_id={}&redirect_uri={}&scope=openid&response_type=code&state={}",
&params.0.redirect_uri.to_string(),
&params.0.redirect_uri.to_string(),
&params.0.state,
)
.to_string()
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
))
}
};
let signature = match <[u8; 65]>::from_hex(
siwe_cookie
.signature
.chars()
.skip(2)
.take(130)
.collect::<String>()
.clone(),
) {
Ok(s) => s,
Err(e) => Err(CustomError::BadRequest(format!("Bad signature: {}", e)))?,
};
let message = siwe_cookie
.message
.to_eip4361_message()
.map_err(|e| anyhow!("Failed to serialise message: {}", e))?;
info!("{}", message);
message
.verify_eip191(signature)
.map_err(|e| anyhow!("Failed signature validation: {}", e))?;
let domain = params.redirect_uri.url().host().unwrap();
if domain.to_string() != siwe_cookie.message.domain {
Err(anyhow!("Conflicting domains in message and redirect"))?
}
if nonce != siwe_cookie.message.nonce {
Err(anyhow!("Conflicting nonces in message and session"))?
}
let code_entry = CodeEntry {
address: siwe_cookie.message.address,
nonce: params.oidc_nonce.clone(),
exchange_count: 0,
};
let code = Uuid::new_v4();
let mut conn = pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
conn.set_ex(
code.to_string(),
hex::encode(
bincode::serialize(&code_entry)
.map_err(|e| anyhow!("Failed to serialise code: {}", e))?,
),
ENTRY_LIFETIME,
)
.map_err(|e| anyhow!("Failed to set kv: {}", e))?;
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut().append_pair("code", &code.to_string());
url.query_pairs_mut().append_pair("state", &params.state);
Ok((
headers,
Redirect::to(
url.as_str()
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
))
// TODO clear session
}
async fn register(
extract::Json(payload): extract::Json<CoreClientMetadata>,
Extension(pool): Extension<ConnectionPool>,
) -> Result<Json<CoreClientRegistrationResponse>, CustomError> {
let id = Uuid::new_v4();
let secret = Uuid::new_v4();
let mut conn = pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
conn.set(format!("{}/{}", KV_CLIENT_PREFIX, id), secret.to_string())
.map_err(|e| anyhow!("Failed to set kv: {}", e))?;
Ok(CoreClientRegistrationResponse::new(
ClientId::new(id.to_string()),
payload.redirect_uris().to_vec(),
EmptyAdditionalClientMetadata::default(),
EmptyAdditionalClientRegistrationResponse::default(),
)
.set_client_secret(Some(ClientSecret::new(secret.to_string())))
.into())
}
// TODO CORS
// TODO need validation of the token
// TODO restrict access token use to only once?
async fn userinfo(
// access_token: AccessTokenUserInfo, // TODO maybe go through FromRequest https://github.com/tokio-rs/axum/blob/main/examples/jwt/src/main.rs
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>, // TODO maybe go through FromRequest https://github.com/tokio-rs/axum/blob/main/examples/jwt/src/main.rs
Extension(pool): Extension<ConnectionPool>,
) -> Result<Json<CoreUserInfoClaims>, CustomError> {
let code = bearer.token().to_string();
let mut conn = pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
let serialized_entry: Option<Vec<u8>> = conn
.get(code)
.map_err(|e| anyhow!("Failed to get kv: {}", e))?;
if serialized_entry.is_none() {
Err(CustomError::BadRequest("Unknown code.".to_string()))?;
}
let code_entry: CodeEntry = bincode::deserialize(
&hex::decode(serialized_entry.unwrap())
.map_err(|e| anyhow!("Failed to decode code entry: {}", e))?,
)
.map_err(|e| anyhow!("Failed to deserialize code: {}", e))?;
Ok(CoreUserInfoClaims::new(
StandardClaims::new(SubjectIdentifier::new(code_entry.address)),
EmptyAdditionalClaims::default(),
)
.into())
}
// TODO ping Redis
async fn healthcheck() {}
#[cfg(not(target_arch = "wasm32"))]
mod oidc;
#[cfg(not(target_arch = "wasm32"))]
use axum_lib::main as axum_main;
#[cfg(not(target_arch = "wasm32"))]
#[tokio::main]
async fn main() {
let config = Figment::from(Serialized::defaults(config::Config::default()))
.merge(Toml::file("siwe-oidc.toml").nested())
.merge(Env::prefixed("SIWEOIDC_").split("__").global());
let config = config.extract::<config::Config>().unwrap();
tracing_subscriber::fmt::init();
let manager = RedisConnectionManager::new(config.redis_url.clone()).unwrap();
let pool = bb8::Pool::builder().build(manager.clone()).await.unwrap();
// let pool2 = bb8::Pool::builder().build(manager).await.unwrap();
// let mut conn = pool2
// .get()
// .await
// .map_err(|e| anyhow!("Failed to get connection to database: {}", e))
// .unwrap();
// for (id, secret) in &config.default_clients.clone() {
// let _: () = conn
// .set(format!("{}/{}", KV_CLIENT_PREFIX, id), secret)
// .await
// .map_err(|e| anyhow!("Failed to set kv: {}", e))
// .unwrap();
// }
let private_key = if let Some(key) = &config.rsa_pem {
RsaPrivateKey::from_pkcs1_pem(&key)
.map_err(|e| anyhow!("Failed to load private key: {}", e))
.unwrap()
} else {
info!("Generating key...");
let mut rng = OsRng;
let bits = 2048;
let private = RsaPrivateKey::new(&mut rng, bits)
.map_err(|e| anyhow!("Failed to generate a key: {}", e))
.unwrap();
info!("Generated key.");
info!("{:?}", private.to_pkcs1_pem().unwrap());
private
};
let app = Router::new()
.nest(
"/build",
service_method_routing::get(ServeDir::new("./static/build")).handle_error(
|error: std::io::Error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
},
),
)
.nest(
"/img",
service_method_routing::get(ServeDir::new("./static/img")).handle_error(
|error: std::io::Error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
},
),
)
.route(
"/",
service_method_routing::get(ServeFile::new("./static/index.html")).handle_error(
|error: std::io::Error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
},
),
)
.route(
"/favicon.png",
service_method_routing::get(ServeFile::new("./static/favicon.png")).handle_error(
|error: std::io::Error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
},
),
)
.route("/.well-known/openid-configuration", get(provider_metadata))
.route("/jwk", get(jwk_set))
.route("/token", post(token))
.route("/authorize", get(authorize))
.route("/register", post(register))
.route("/userinfo", get(userinfo).post(userinfo))
.route("/sign_in", get(sign_in))
.route("/health", get(healthcheck))
.layer(AddExtensionLayer::new(private_key))
.layer(AddExtensionLayer::new(config.clone()))
.layer(AddExtensionLayer::new(pool))
// .layer(AddExtensionLayer::new(
// RedisSessionStore::new(config.redis_url.clone())
// .unwrap()
// .with_prefix("async-sessions/"),
// ))
.layer(AddExtensionLayer::new(MemoryStore::new()))
.layer(TraceLayer::new_for_http());
let addr = SocketAddr::from((config.address, config.port));
tracing::info!("Listening on {}", addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
axum_main().await
}
#[cfg(target_arch = "wasm32")]
fn main() {}

960
src/oidc.rs Normal file
View File

@ -0,0 +1,960 @@
use anyhow::{anyhow, Result};
use chrono::{Duration, Utc};
use cookie::{Cookie, SameSite};
use ethers_core::{types::H160, utils::to_checksum};
use ethers_providers::{Http, Middleware, Provider};
use headers::{self, authorization::Bearer};
use hex::FromHex;
use iri_string::types::UriString;
use openidconnect::{
core::{
CoreAuthErrorResponseType, CoreAuthPrompt, CoreClaimName, CoreClientAuthMethod,
CoreClientMetadata, CoreClientRegistrationResponse, CoreErrorResponseType, CoreGenderClaim,
CoreGrantType, CoreIdToken, CoreIdTokenClaims, CoreIdTokenFields, CoreJsonWebKeySet,
CoreJwsSigningAlgorithm, CoreProviderMetadata, CoreRegisterErrorResponseType,
CoreResponseType, CoreRsaPrivateSigningKey, CoreSubjectIdentifierType, CoreTokenResponse,
CoreTokenType, CoreUserInfoClaims, CoreUserInfoJsonWebToken,
},
registration::{EmptyAdditionalClientMetadata, EmptyAdditionalClientRegistrationResponse},
url::Url,
AccessToken, Audience, AuthUrl, ClientConfigUrl, ClientId, ClientSecret, EmptyAdditionalClaims,
EmptyAdditionalProviderMetadata, EmptyExtraTokenFields, EndUserPictureUrl, EndUserUsername,
IssuerUrl, JsonWebKeyId, JsonWebKeySetUrl, LocalizedClaim, Nonce, OpPolicyUrl, OpTosUrl,
PrivateSigningKey, RedirectUrl, RegistrationAccessToken, RegistrationUrl, RequestUrl,
ResponseTypes, Scope, StandardClaims, SubjectIdentifier, TokenUrl, UserInfoUrl,
};
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use rsa::{
pkcs1::{EncodeRsaPrivateKey, LineEnding},
RsaPrivateKey,
};
use serde::{Deserialize, Serialize};
use siwe::{Message, TimeStamp, VerificationOpts, Version};
use std::{str::FromStr, time};
use thiserror::Error;
use tracing::{error, info};
use urlencoding::decode;
use uuid::Uuid;
#[cfg(target_arch = "wasm32")]
use super::db::*;
#[cfg(not(target_arch = "wasm32"))]
use siwe_oidc::db::*;
lazy_static::lazy_static! {
static ref SCOPES: [Scope; 2] = [
Scope::new("openid".to_string()),
Scope::new("profile".to_string()),
];
}
const SIGNING_ALG: [CoreJwsSigningAlgorithm; 1] = [CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256];
const KID: &str = "key1";
pub const METADATA_PATH: &str = "/.well-known/openid-configuration";
pub const JWK_PATH: &str = "/jwk";
pub const TOKEN_PATH: &str = "/token";
pub const AUTHORIZE_PATH: &str = "/authorize";
pub const REGISTER_PATH: &str = "/register";
pub const CLIENT_PATH: &str = "/client";
pub const USERINFO_PATH: &str = "/userinfo";
pub const SIGNIN_PATH: &str = "/sign_in";
pub const SIWE_COOKIE_KEY: &str = "siwe";
pub const TOU_PATH: &str = "/legal/terms-of-use.pdf";
pub const PP_PATH: &str = "/legal/privacy-policy.pdf";
#[cfg(not(target_arch = "wasm32"))]
type DBClientType = (dyn DBClient + Sync);
#[cfg(target_arch = "wasm32")]
type DBClientType = dyn DBClient;
#[derive(Serialize, Debug)]
pub struct TokenError {
pub error: CoreErrorResponseType,
pub error_description: String,
}
#[derive(Debug, Error)]
pub enum CustomError {
#[error("{0}")]
BadRequest(String),
#[error("{0:?}")]
BadRequestRegister(RegisterError),
#[error("{0:?}")]
BadRequestToken(TokenError),
#[error("{0}")]
Unauthorized(String),
#[error("Not found")]
NotFound,
#[error("{0:?}")]
Redirect(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
fn jwk(private_key: RsaPrivateKey) -> Result<CoreRsaPrivateSigningKey> {
let pem = private_key
.to_pkcs1_pem(LineEnding::LF)
.map_err(|e| anyhow!("Failed to serialise key as PEM: {}", e))?;
CoreRsaPrivateSigningKey::from_pem(&pem, Some(JsonWebKeyId::new(KID.to_string())))
.map_err(|e| anyhow!("Invalid RSA private key: {}", e))
}
pub fn jwks(private_key: RsaPrivateKey) -> Result<CoreJsonWebKeySet, CustomError> {
let signing_key = jwk(private_key)?;
let jwks = CoreJsonWebKeySet::new(vec![signing_key.as_verification_key()]);
Ok(jwks)
}
pub fn metadata(base_url: Url) -> Result<CoreProviderMetadata, CustomError> {
let pm = CoreProviderMetadata::new(
IssuerUrl::from_url(base_url.clone()),
AuthUrl::from_url(
base_url
.join(AUTHORIZE_PATH)
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
),
JsonWebKeySetUrl::from_url(
base_url
.join(JWK_PATH)
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
),
vec![
ResponseTypes::new(vec![CoreResponseType::Code]),
ResponseTypes::new(vec![CoreResponseType::IdToken]),
ResponseTypes::new(vec![CoreResponseType::Token, CoreResponseType::IdToken]),
],
vec![CoreSubjectIdentifierType::Pairwise],
SIGNING_ALG.to_vec(),
EmptyAdditionalProviderMetadata {},
)
.set_token_endpoint(Some(TokenUrl::from_url(
base_url
.join(TOKEN_PATH)
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
)))
.set_userinfo_endpoint(Some(UserInfoUrl::from_url(
base_url
.join(USERINFO_PATH)
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
)))
.set_userinfo_signing_alg_values_supported(Some(SIGNING_ALG.to_vec()))
.set_scopes_supported(Some(SCOPES.to_vec()))
.set_claims_supported(Some(vec![
CoreClaimName::new("sub".to_string()),
CoreClaimName::new("aud".to_string()),
CoreClaimName::new("exp".to_string()),
CoreClaimName::new("iat".to_string()),
CoreClaimName::new("iss".to_string()),
CoreClaimName::new("preferred_username".to_string()),
CoreClaimName::new("picture".to_string()),
]))
.set_registration_endpoint(Some(RegistrationUrl::from_url(
base_url
.join(REGISTER_PATH)
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
)))
.set_token_endpoint_auth_methods_supported(Some(vec![
CoreClientAuthMethod::ClientSecretBasic,
CoreClientAuthMethod::ClientSecretPost,
CoreClientAuthMethod::PrivateKeyJwt,
]))
.set_op_policy_uri(Some(OpPolicyUrl::from_url(
base_url
.join(PP_PATH)
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
)))
.set_op_tos_uri(Some(OpTosUrl::from_url(
base_url
.join(TOU_PATH)
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
)));
Ok(pm)
}
fn build_provider(eth_provider: Url) -> Result<Provider<Http>> {
match Provider::<Http>::try_from(eth_provider.to_string()) {
Ok(p) => Ok(p),
Err(e) => {
error!("Failed to initialise Eth provider: {}", e);
Err(e)?
}
}
}
async fn resolve_name(eth_provider: Option<Url>, address: H160) -> Result<String, String> {
let address_string = to_checksum(&address, None);
let eth_provider = if let Some(p) = eth_provider {
p
} else {
return Err(address_string);
};
let provider = if let Ok(p) = build_provider(eth_provider) {
p
} else {
return Err(address_string);
};
match provider.lookup_address(address).await {
Ok(n) => Ok(n),
Err(e) => {
error!("Failed to resolve Eth domain: {}", e);
Err(address_string)
}
}
}
async fn resolve_avatar(eth_provider: Option<Url>, ens_name: &str) -> Option<Url> {
if let Some(provider) = eth_provider {
if let Ok(p) = build_provider(provider) {
match p.resolve_avatar(ens_name).await {
Ok(a) => Some(a),
Err(e) => {
error!("Could not resolve avatar: {}", e);
None
}
}
} else {
None
}
} else {
None
}
}
async fn resolve_claims(
eth_provider: Option<Url>,
address: H160,
chain_id: u64,
) -> StandardClaims<CoreGenderClaim> {
let subject_id = SubjectIdentifier::new(format!(
"eip155:{}:{}",
chain_id,
to_checksum(&address, None)
));
let ens_name = resolve_name(eth_provider.clone(), address).await;
let username = match ens_name.clone() {
Ok(n) | Err(n) => n,
};
let avatar = match ens_name {
Ok(n) => resolve_avatar(eth_provider.clone(), &n).await,
Err(_) => None,
};
StandardClaims::new(subject_id)
.set_preferred_username(Some(EndUserUsername::new(username)))
.set_picture(avatar.map(|a| {
let mut avatar_localized = LocalizedClaim::new();
avatar_localized.insert(None, EndUserPictureUrl::new(a.to_string()));
avatar_localized
}))
}
#[derive(Serialize, Deserialize)]
pub struct TokenForm {
pub code: String,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub grant_type: CoreGrantType, // TODO should just be authorization_code apparently?
}
pub async fn token(
form: TokenForm,
// From the request's Authorization header
secret: Option<String>,
private_key: RsaPrivateKey,
base_url: Url,
require_secret: bool,
eth_provider: Option<Url>,
db_client: &DBClientType,
) -> Result<CoreTokenResponse, CustomError> {
let code_entry = if let Some(c) = db_client.get_code(form.code.to_string()).await? {
c
} else {
return Err(CustomError::BadRequestToken(TokenError {
error: CoreErrorResponseType::InvalidGrant,
error_description: "Unknown code.".to_string(),
}));
};
let client_id = if let Some(c) = form.client_id.clone() {
c
} else {
code_entry.client_id.clone()
};
if let Some(secret) = if let Some(b) = secret {
Some(b)
} else {
form.client_secret.clone()
} {
let client_entry = db_client.get_client(client_id.clone()).await?;
if client_entry.is_none() {
return Err(CustomError::Unauthorized(
"Unrecognised client id.".to_string(),
));
}
if secret != client_entry.unwrap().secret {
return Err(CustomError::Unauthorized("Bad secret.".to_string()));
}
} else if require_secret {
return Err(CustomError::Unauthorized("Secret required.".to_string()));
}
if code_entry.exchange_count > 0 {
// TODO use Oauth error response
return Err(CustomError::BadRequestToken(TokenError {
error: CoreErrorResponseType::InvalidGrant,
error_description: "Code was previously exchanged.".to_string(),
}));
}
let mut code_entry2 = code_entry.clone();
code_entry2.exchange_count += 1;
db_client
.set_code(form.code.to_string(), code_entry2)
.await?;
let access_token = AccessToken::new(form.code);
let core_id_token = CoreIdTokenClaims::new(
IssuerUrl::from_url(base_url),
vec![Audience::new(client_id.clone())],
Utc::now() + Duration::seconds(60),
Utc::now(),
resolve_claims(
eth_provider,
code_entry.address,
code_entry.chain_id.unwrap_or(1),
)
.await,
EmptyAdditionalClaims {},
)
.set_nonce(code_entry.nonce)
.set_auth_time(Some(code_entry.auth_time));
let pem = private_key
.to_pkcs1_pem(LineEnding::LF)
.map_err(|e| anyhow!("Failed to serialise key as PEM: {}", e))?;
let id_token = CoreIdToken::new(
core_id_token,
&CoreRsaPrivateSigningKey::from_pem(&pem, Some(JsonWebKeyId::new(KID.to_string())))
.map_err(|e| anyhow!("Invalid RSA private key: {}", e))?,
CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256,
Some(&access_token),
None,
)
.map_err(|e| anyhow!("{}", e))?;
let mut response = CoreTokenResponse::new(
access_token,
CoreTokenType::Bearer,
CoreIdTokenFields::new(Some(id_token), EmptyExtraTokenFields {}),
);
response.set_expires_in(Some(&time::Duration::from_secs(
ENTRY_LIFETIME.try_into().unwrap(),
)));
Ok(response)
}
#[derive(Deserialize)]
pub struct AuthorizeParams {
pub client_id: String,
pub redirect_uri: RedirectUrl,
pub scope: Scope,
pub response_type: Option<CoreResponseType>,
pub state: Option<String>,
pub nonce: Option<Nonce>,
pub prompt: Option<CoreAuthPrompt>,
pub request_uri: Option<RequestUrl>,
pub request: Option<String>,
}
pub async fn authorize(
params: AuthorizeParams,
db_client: &DBClientType,
) -> Result<(String, Box<Cookie<'_>>), CustomError> {
let client_entry = db_client
.get_client(params.client_id.clone())
.await
.map_err(|e| anyhow!("Failed to get kv: {}", e))?;
if client_entry.is_none() {
return Err(CustomError::Unauthorized(
"Unrecognised client id.".to_string(),
));
}
let nonce: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
let mut r_u = params.redirect_uri.clone().url().clone();
r_u.set_query(None);
let mut r_us: Vec<Url> = client_entry
.unwrap()
.metadata
.redirect_uris()
.clone()
.iter_mut()
.map(|u| u.url().clone())
.collect();
r_us.iter_mut().for_each(|u| u.set_query(None));
if !r_us.contains(&r_u) {
return Err(CustomError::Redirect(
"/error?message=unregistered_redirect_uri".to_string(),
));
}
let state = if let Some(s) = params.state.clone() {
s
} else if params.request_uri.is_some() {
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut().append_pair(
"error",
CoreAuthErrorResponseType::RequestUriNotSupported.as_ref(),
);
return Err(CustomError::Redirect(url.to_string()));
} else if params.request.is_some() {
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut().append_pair(
"error",
CoreAuthErrorResponseType::RequestNotSupported.as_ref(),
);
return Err(CustomError::Redirect(url.to_string()));
} else {
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut()
.append_pair("error", CoreAuthErrorResponseType::InvalidRequest.as_ref());
url.query_pairs_mut()
.append_pair("error_description", "Missing state");
return Err(CustomError::Redirect(url.to_string()));
};
if let Some(CoreAuthPrompt::None) = params.prompt {
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut().append_pair("state", &state);
url.query_pairs_mut().append_pair(
"error",
CoreAuthErrorResponseType::InteractionRequired.as_ref(),
);
return Err(CustomError::Redirect(url.to_string()));
}
if params.response_type.is_none() {
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut().append_pair("state", &state);
url.query_pairs_mut()
.append_pair("error", CoreAuthErrorResponseType::InvalidRequest.as_ref());
url.query_pairs_mut()
.append_pair("error_description", "Missing response_type");
return Err(CustomError::Redirect(url.to_string()));
}
let _response_type = params.response_type.as_ref().unwrap();
for scope in params.scope.as_str().trim().split(' ') {
if !SCOPES.contains(&Scope::new(scope.to_string())) {
return Err(anyhow!("Scope not supported: {}", scope).into());
}
}
let session_id = Uuid::new_v4();
let session_secret: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
db_client
.set_session(
session_id.to_string(),
SessionEntry {
siwe_nonce: nonce.clone(),
oidc_nonce: params.nonce.clone(),
secret: session_secret.clone(),
signin_count: 0,
},
)
.await?;
let session_cookie = Cookie::build(SESSION_COOKIE_NAME, session_id.to_string())
.same_site(SameSite::Strict)
.http_only(true)
.max_age(cookie::time::Duration::seconds(
SESSION_LIFETIME.try_into().unwrap(),
))
.finish();
let domain = params.redirect_uri.url().host().unwrap();
let oidc_nonce_param = if let Some(n) = &params.nonce {
format!("&oidc_nonce={}", n.secret())
} else {
"".to_string()
};
Ok((
format!(
"/?nonce={}&domain={}&redirect_uri={}&state={}&client_id={}{}",
nonce, domain, *params.redirect_uri, state, params.client_id, oidc_nonce_param
),
Box::new(session_cookie),
))
}
#[derive(Serialize, Deserialize)]
pub struct SiweCookie {
message: Web3ModalMessage,
signature: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Web3ModalMessage {
pub domain: String,
pub address: H160,
pub statement: String,
pub uri: String,
pub version: String,
pub chain_id: u64,
pub nonce: String,
pub issued_at: String,
pub expiration_time: Option<String>,
pub not_before: Option<String>,
pub request_id: Option<String>,
pub resources: Vec<UriString>,
}
impl Web3ModalMessage {
fn to_eip4361_message(&self) -> Result<Message> {
Ok(Message {
domain: self.domain.clone().try_into()?,
address: self.address.0,
statement: Some(self.statement.to_string()),
uri: UriString::from_str(&self.uri)?,
version: Version::from_str(&self.version)?,
chain_id: self.chain_id,
nonce: self.nonce.to_string(),
issued_at: TimeStamp::from_str(&self.issued_at)?,
expiration_time: match &self.expiration_time {
Some(t) => Some(TimeStamp::from_str(t)?),
None => None,
},
not_before: match &self.not_before {
Some(t) => Some(TimeStamp::from_str(t)?),
None => None,
},
request_id: self.request_id.clone(),
resources: self.resources.clone(),
})
}
}
#[derive(Deserialize)]
pub struct SignInParams {
pub redirect_uri: RedirectUrl,
pub state: String,
pub oidc_nonce: Option<Nonce>,
pub client_id: String,
}
pub async fn sign_in(
base_url: &Url,
params: SignInParams,
// cookies_header: String,
cookies: headers::Cookie,
db_client: &DBClientType,
) -> Result<Url, CustomError> {
// TODO redirect on session errors
let session_id = if let Some(c) = cookies.get(SESSION_COOKIE_NAME) {
c
} else {
return Err(CustomError::BadRequest(
"Session cookie not found".to_string(),
));
};
let session_entry = if let Some(e) = db_client.get_session(session_id.to_string()).await? {
e
} else {
return Err(CustomError::BadRequest("Session not found".to_string()));
};
if session_entry.signin_count > 0 {
return Err(CustomError::BadRequest(
"Session has already logged in".to_string(),
));
}
let siwe_cookie: SiweCookie = match cookies.get(SIWE_COOKIE_KEY) {
Some(c) => serde_json::from_str(
&decode(c).map_err(|e| anyhow!("Could not decode siwe cookie: {}", e))?,
)
.map_err(|e| anyhow!("Could not deserialize siwe cookie: {}", e))?,
None => {
return Err(anyhow!("No `siwe` cookie").into());
}
};
let signature = match <[u8; 65]>::from_hex(
siwe_cookie
.signature
.chars()
.skip(2)
.take(130)
.collect::<String>(),
) {
Ok(s) => s,
Err(e) => {
return Err(CustomError::BadRequest(format!("Bad signature: {}", e)));
}
};
let message = siwe_cookie
.message
.to_eip4361_message()
.map_err(|e| anyhow!("Failed to serialise message: {}", e))?;
info!("{}", message);
let domain = if let Some(d) = base_url.domain() {
match d.try_into() {
Ok(dd) => Some(dd),
Err(e) => {
error!("Failed to translate domain into authority: {}", e);
None
}
}
} else {
None
};
message
.verify(
&signature,
&VerificationOpts {
domain,
nonce: Some(session_entry.siwe_nonce.clone()),
timestamp: None,
},
)
.await
.map_err(|e| anyhow!("Failed message verification: {}", e))?;
let domain = params.redirect_uri.url();
if let Some(r) = siwe_cookie.message.resources.get(0) {
if *domain != Url::from_str(r.as_ref()).unwrap() {
return Err(anyhow!("Conflicting domains in message and redirect").into());
}
} else {
return Err(anyhow!("Missing resource in SIWE message").into());
}
let code_entry = CodeEntry {
address: siwe_cookie.message.address,
nonce: params.oidc_nonce.clone(),
exchange_count: 0,
client_id: params.client_id.clone(),
auth_time: Utc::now(),
chain_id: Some(siwe_cookie.message.chain_id),
};
let mut new_session_entry = session_entry.clone();
new_session_entry.signin_count += 1;
db_client
.set_session(session_id.to_string(), new_session_entry)
.await?;
let code = Uuid::new_v4();
db_client.set_code(code.to_string(), code_entry).await?;
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut().append_pair("code", &code.to_string());
url.query_pairs_mut().append_pair("state", &params.state);
Ok(url)
}
#[derive(Debug, Serialize)]
pub struct RegisterError {
error: CoreRegisterErrorResponseType,
}
pub async fn register(
payload: CoreClientMetadata,
base_url: Url,
db_client: &DBClientType,
) -> Result<CoreClientRegistrationResponse, CustomError> {
let id = Uuid::new_v4();
let secret: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
let redirect_uris = payload.redirect_uris().to_vec();
for uri in redirect_uris.iter() {
if uri.url().fragment().is_some() {
return Err(CustomError::BadRequestRegister(RegisterError {
error: CoreRegisterErrorResponseType::InvalidRedirectUri,
}));
}
}
let access_token = RegistrationAccessToken::new(
thread_rng()
.sample_iter(&Alphanumeric)
.take(11)
.map(char::from)
.collect(),
);
let entry = ClientEntry {
secret: secret.clone(),
metadata: payload,
access_token: Some(access_token.clone()),
};
db_client.set_client(id.to_string(), entry).await?;
Ok(CoreClientRegistrationResponse::new(
ClientId::new(id.to_string()),
redirect_uris,
EmptyAdditionalClientMetadata::default(),
EmptyAdditionalClientRegistrationResponse::default(),
)
.set_client_secret(Some(ClientSecret::new(secret)))
.set_registration_client_uri(Some(ClientConfigUrl::from_url(
base_url
.join(&format!("{}/{}", CLIENT_PATH, id))
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
)))
.set_registration_access_token(Some(access_token)))
}
async fn client_access(
client_id: String,
bearer: Option<Bearer>,
db_client: &DBClientType,
) -> Result<ClientEntry, CustomError> {
let access_token = if let Some(b) = bearer {
b.token().to_string()
} else {
return Err(CustomError::BadRequest("Missing access token.".to_string()));
};
let client_entry = db_client
.get_client(client_id)
.await?
.ok_or(CustomError::NotFound)?;
let stored_access_token = client_entry.access_token.clone();
if stored_access_token.is_none() || *stored_access_token.unwrap().secret() != access_token {
return Err(CustomError::Unauthorized("Bad access token.".to_string()));
}
Ok(client_entry)
}
pub async fn clientinfo(
client_id: String,
db_client: &DBClientType,
) -> Result<CoreClientMetadata, CustomError> {
Ok(db_client
.get_client(client_id)
.await?
.ok_or(CustomError::NotFound)?
.metadata)
}
pub async fn client_delete(
client_id: String,
bearer: Option<Bearer>,
db_client: &DBClientType,
) -> Result<(), CustomError> {
client_access(client_id.clone(), bearer, db_client).await?;
Ok(db_client.delete_client(client_id).await?)
}
pub async fn client_update(
client_id: String,
payload: CoreClientMetadata,
bearer: Option<Bearer>,
db_client: &DBClientType,
) -> Result<(), CustomError> {
let mut client_entry = client_access(client_id.clone(), bearer, db_client).await?;
client_entry.metadata = payload;
Ok(db_client.set_client(client_id, client_entry).await?)
}
#[derive(Deserialize)]
pub struct UserInfoPayload {
pub access_token: Option<String>,
}
pub enum UserInfoResponse {
Json(CoreUserInfoClaims),
Jwt(CoreUserInfoJsonWebToken),
}
pub async fn userinfo(
base_url: Url,
eth_provider: Option<Url>,
private_key: RsaPrivateKey,
bearer: Option<Bearer>,
payload: UserInfoPayload,
db_client: &DBClientType,
) -> Result<UserInfoResponse, CustomError> {
let code = if let Some(b) = bearer {
b.token().to_string()
} else if let Some(c) = payload.access_token {
c
} else {
return Err(CustomError::BadRequest("Missing access token.".to_string()));
};
let code_entry = if let Some(c) = db_client.get_code(code).await? {
c
} else {
return Err(CustomError::BadRequest("Unknown code.".to_string()));
};
let client_entry = if let Some(c) = db_client.get_client(code_entry.client_id.clone()).await? {
c
} else {
return Err(CustomError::BadRequest("Unknown client.".to_string()));
};
let response = CoreUserInfoClaims::new(
resolve_claims(
eth_provider,
code_entry.address,
code_entry.chain_id.unwrap_or(1),
)
.await,
EmptyAdditionalClaims::default(),
)
.set_issuer(Some(IssuerUrl::from_url(base_url.clone())))
.set_audiences(Some(vec![Audience::new(code_entry.client_id)]));
match client_entry.metadata.userinfo_signed_response_alg() {
None => Ok(UserInfoResponse::Json(response)),
Some(alg) => {
let signing_key = jwk(private_key)?;
Ok(UserInfoResponse::Jwt(
CoreUserInfoJsonWebToken::new(response, &signing_key, alg.clone())
.map_err(|_| anyhow!("Error signing response."))?,
))
}
}
}
#[cfg(test)]
mod tests {
use crate::config::Config;
use super::*;
use ethers_signers::{LocalWallet, Signer};
use headers::{HeaderMap, HeaderMapExt, HeaderValue};
use rand::rngs::OsRng;
use test_log::test;
async fn default_config() -> (Config, RedisClient) {
let config = Config::default();
let db_client = RedisClient::new(&config.redis_url).await.unwrap();
db_client
.set_client(
"client".into(),
ClientEntry {
secret: "secret".into(),
metadata: CoreClientMetadata::new(
vec![RedirectUrl::new("https://example.com".into()).unwrap()],
EmptyAdditionalClientMetadata {},
),
access_token: None,
},
)
.await
.unwrap();
(config, db_client)
}
#[test(tokio::test)]
async fn test_claims() {
let res = resolve_claims(
Some("https://cloudflare-eth.com".try_into().unwrap()),
<[u8; 20]>::from_hex("d8da6bf26964af9d7eed9e03e53415d37aa96045")
.unwrap()
.into(),
1,
)
.await;
assert_eq!(
res.preferred_username().map(|u| u.to_string()),
Some("vitalik.eth".to_string())
);
assert_eq!(
res.picture().map(|u| u.get(None).unwrap().as_str()),
Some("https://ipfs.io/ipfs/QmSP4nq9fnN9dAiCj42ug9Wa79rqmQerZXZch82VqpiH7U/image.gif")
);
}
#[derive(Deserialize)]
struct AuthorizeQueryParams {
nonce: String,
}
#[derive(Deserialize)]
struct SignInQueryParams {
code: String,
}
#[tokio::test]
async fn e2e_flow() {
let (_config, db_client) = default_config().await;
let wallet = "dcf2cbdd171a21c480aa7f53d77f31bb102282b3ff099c78e3118b37348c72f7"
.parse::<LocalWallet>()
.unwrap();
let base_url = Url::parse("https://example.com").unwrap();
let params = AuthorizeParams {
client_id: "client".into(),
redirect_uri: RedirectUrl::from_url(base_url.clone()),
scope: Scope::new("openid".to_string()),
response_type: Some(CoreResponseType::IdToken),
state: Some("state".into()),
nonce: None,
prompt: None,
request_uri: None,
request: None,
};
let (redirect_url, cookie) = authorize(params, &db_client).await.unwrap();
let authorize_params: AuthorizeQueryParams =
serde_urlencoded::from_str(redirect_url.split("/?").collect::<Vec<&str>>()[1]).unwrap();
let params: SignInParams = serde_urlencoded::from_str(&redirect_url).unwrap();
let message = Web3ModalMessage {
domain: "example.com".into(),
address: wallet.address(),
statement: "statement".to_string(),
uri: base_url.to_string(),
version: "1".into(),
chain_id: 1,
nonce: authorize_params.nonce,
issued_at: "2023-04-17T11:01:24.862Z".into(),
expiration_time: None,
not_before: None,
request_id: None,
resources: vec!["https://example.com".try_into().unwrap()],
};
let signature = wallet
.sign_message(message.to_eip4361_message().unwrap().to_string())
.await
.unwrap();
let signature = format!("0x{signature}");
let siwe_cookie = serde_json::to_string(&SiweCookie { message, signature }).unwrap();
let mut headers = HeaderMap::new();
headers.insert(
"cookie",
HeaderValue::from_str(&format!("{cookie}; {SIWE_COOKIE_KEY}={siwe_cookie}")).unwrap(),
);
let cookie = headers.typed_get::<headers::Cookie>().unwrap();
let redirect_url = sign_in(&base_url, params, cookie, &db_client)
.await
.unwrap();
let signin_params: SignInQueryParams =
serde_urlencoded::from_str(redirect_url.query().unwrap()).unwrap();
let _ = userinfo(
base_url,
None,
RsaPrivateKey::new(&mut OsRng, 1024).unwrap(),
None,
UserInfoPayload {
access_token: Some(signin_params.code),
},
&db_client,
)
.await
.unwrap();
}
}

View File

@ -1,52 +0,0 @@
use std::ops::DerefMut;
use bb8;
use redis;
use async_trait::async_trait;
use redis::{aio::Connection, ErrorKind};
use redis::{
cluster::{ClusterClient, ClusterConnection},
IntoConnectionInfo, RedisError,
};
/// A `bb8::ManageConnection` for `redis::Client::get_async_connection`.
#[derive(Clone)]
pub struct RedisConnectionManager {
client: ClusterClient,
}
impl RedisConnectionManager {
/// Create a new `RedisConnectionManager`.
/// See `redis::Client::open` for a description of the parameter types.
pub fn new<T: IntoConnectionInfo>(info: T) -> Result<RedisConnectionManager, RedisError> {
Ok(RedisConnectionManager {
client: ClusterClient::open(vec![info.into_connection_info()?])?,
})
}
}
#[async_trait]
impl bb8::ManageConnection for RedisConnectionManager {
type Connection = ClusterConnection;
type Error = RedisError;
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
self.client.get_connection()
}
async fn is_valid(
&self,
conn: &mut bb8::PooledConnection<'_, Self>,
) -> Result<(), Self::Error> {
let pong: String = redis::cmd("PING").query(conn.deref_mut())?;
match pong.as_str() {
"PONG" => Ok(()),
_ => Err((ErrorKind::ResponseError, "ping request").into()),
}
}
fn has_broken(&self, _: &mut Self::Connection) -> bool {
false
}
}

View File

@ -1,134 +0,0 @@
// use async_redis_session::RedisSessionStore;
use async_session::MemoryStore;
use async_session::{Session, SessionStore as _};
use axum::{
async_trait,
extract::{Extension, FromRequest, RequestParts},
http::{self, header::HeaderValue, StatusCode},
};
use cookie::Cookie;
use rand::{distributions::Alphanumeric, Rng};
use serde::{Deserialize, Serialize};
use tracing::debug;
use uuid::Uuid;
const SESSION_COOKIE_NAME: &str = "session";
const SESSION_KEY: &str = "user_session";
pub enum UserSessionFromSession {
FoundUserSession(String),
CreatedFreshUserSession { header: HeaderValue, nonce: String },
InvalidUserSession(HeaderValue),
}
#[async_trait]
impl<B> FromRequest<B> for UserSessionFromSession
where
B: Send,
{
type Rejection = (StatusCode, String);
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
// TODO sessions are set without expiry
let Extension(store) = match Extension::<MemoryStore>::from_request(req).await {
Ok(s) => s,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("`MemoryStore` extension missing: {}", e),
))
}
};
let headers = if let Some(h) = req.headers() {
h
} else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"other extractor taken headers".to_string(),
));
};
let session_cookie: Cookie = if let Some(session_cookie) = headers
.get(http::header::COOKIE)
.and_then(|value| value.to_str().ok())
.map(|header| {
header
.split(";")
.map(|cookie| Cookie::parse(cookie).ok())
.filter(|cookie| {
cookie.is_some() && cookie.as_ref().unwrap().name() == SESSION_COOKIE_NAME
})
.next()
})
.flatten()
.flatten()
{
session_cookie
} else {
let user_session = UserSession::new();
let mut session = Session::new();
session.insert(SESSION_KEY, user_session.clone()).unwrap();
let cookie = store.store_session(session).await.unwrap().unwrap();
return Ok(Self::CreatedFreshUserSession {
header: Cookie::new(SESSION_COOKIE_NAME, cookie)
.to_string()
.parse()
.unwrap(),
nonce: user_session.nonce,
});
};
let session = match store.load_session(session_cookie.value().to_string()).await {
Ok(Some(s)) => s,
Err(e) => {
debug!("Could not load session: {}", e);
let mut cookie = session_cookie.clone();
cookie.make_removal();
return Ok(Self::InvalidUserSession(
cookie.to_string().parse().unwrap(),
));
}
_ => {
debug!("Could not load session");
let mut cookie = session_cookie.clone();
cookie.make_removal();
return Ok(Self::InvalidUserSession(
cookie.to_string().parse().unwrap(),
));
}
};
let user_session = if let Some(user_session) = session.get::<UserSession>(SESSION_KEY) {
user_session
} else {
debug!("No `user_session` found in session");
let mut cookie = session_cookie.clone();
cookie.make_removal();
return Ok(Self::InvalidUserSession(
cookie.to_string().parse().unwrap(),
));
};
Ok(Self::FoundUserSession(user_session.nonce))
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct UserSession {
id: Uuid,
nonce: String,
}
impl UserSession {
fn new() -> Self {
Self {
id: Uuid::new_v4(),
nonce: rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect(),
}
}
}

336
src/worker_lib.rs Normal file
View File

@ -0,0 +1,336 @@
use anyhow::anyhow;
use headers::{
self,
authorization::{Basic, Bearer, Credentials},
Authorization, ContentType, Header, HeaderValue,
};
use rsa::{pkcs1::DecodeRsaPrivateKey, RsaPrivateKey};
use worker::*;
use super::db::CFClient;
use super::oidc::{self, CustomError, TokenForm, UserInfoPayload};
const BASE_URL_KEY: &str = "BASE_URL";
const ETH_PROVIDER_KEY: &str = "ETH_PROVIDER";
const RSA_PEM_KEY: &str = "RSA_PEM";
// https://github.com/cloudflare/workers-rs/issues/64
// #[global_allocator]
// static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
/// To be used in conjunction of Request::json
macro_rules! json_bad_request {
($expression:expr) => {
match $expression {
Err(Error::SerdeJsonError(e)) => return Response::error(&e.to_string(), 400),
r => r,
}
};
}
impl From<CustomError> for Result<Response> {
fn from(error: CustomError) -> Self {
match error {
CustomError::BadRequest(_) => Response::error(error.to_string(), 400),
CustomError::BadRequestRegister(e) => {
Response::from_json(&e).map(|r| r.with_status(400))
}
CustomError::BadRequestToken(e) => Response::from_json(&e).map(|r| r.with_status(400)),
CustomError::Unauthorized(_) => Response::error(error.to_string(), 401),
CustomError::NotFound => Response::error(error.to_string(), 404),
CustomError::Redirect(uri) => Response::redirect(uri.parse().unwrap()),
CustomError::Other(_) => Response::error(error.to_string(), 500),
}
}
}
fn get_cors() -> Cors {
Cors::new()
.with_origins(vec!["*".to_string()])
.with_allowed_headers(vec!["authorization".to_string()])
}
pub async fn main(req: Request, env: Env) -> Result<Response> {
console_error_panic_hook::set_once();
// tracing_subscriber::fmt::init();
// console_log::init_with_level(log::Level::Info).expect("error initializing log");
let userinfo = |mut req: Request, ctx: RouteContext<()>| async move {
let bearer = req
.headers()
.get(Authorization::<Bearer>::name().as_str())?
.and_then(|b| HeaderValue::from_str(b.as_ref()).ok())
.as_ref()
.and_then(Bearer::decode);
let payload = if bearer.is_none() {
match req.form_data().await {
Ok(f) => {
let access_token = if let Some(FormEntry::Field(a)) = f.get("access_token") {
Some(a)
} else {
return Response::error("Missing code", 400);
};
UserInfoPayload { access_token }
}
Err(_) => return Response::error("Bad request", 400),
}
} else {
UserInfoPayload { access_token: None }
};
let base_url = ctx.var(BASE_URL_KEY)?.to_string().parse().unwrap();
let eth_provider = ctx
.var(ETH_PROVIDER_KEY)
.map(|p| p.to_string().parse().unwrap())
.ok();
let private_key = RsaPrivateKey::from_pkcs1_pem(&ctx.secret(RSA_PEM_KEY)?.to_string())
.map_err(|e| anyhow!("Failed to load private key: {}", e))
.unwrap();
let url = req.url()?;
let db_client = CFClient { ctx, url };
match oidc::userinfo(
base_url,
eth_provider,
private_key,
bearer,
payload,
&db_client,
)
.await
{
Ok(oidc::UserInfoResponse::Json(r)) => Ok(Response::from_json(&r)?),
Ok(oidc::UserInfoResponse::Jwt(r)) => {
let mut headers = Headers::new();
headers.append(ContentType::name().as_ref(), "application/jwt")?;
Ok(Response::from_bytes(
serde_json::to_string(&r)
.unwrap()
.replace('"', "")
.as_bytes()
.to_vec(),
)?
.with_headers(headers))
}
Err(e) => e.into(),
}
.and_then(|r| r.with_cors(&get_cors()))
};
let router = Router::new();
router
.get_async(oidc::METADATA_PATH, |_req, ctx| async move {
match oidc::metadata(ctx.var(BASE_URL_KEY)?.to_string().parse().unwrap()) {
Ok(m) => Response::from_json(&m),
Err(e) => e.into(),
}
.and_then(|r| r.with_cors(&get_cors()))
})
.get_async(oidc::JWK_PATH, |_req, ctx| async move {
let private_key = RsaPrivateKey::from_pkcs1_pem(&ctx.secret(RSA_PEM_KEY)?.to_string())
.map_err(|e| anyhow!("Failed to load private key: {}", e))
.unwrap();
match oidc::jwks(private_key) {
Ok(m) => Response::from_json(&m),
Err(e) => e.into(),
}
.and_then(|r| r.with_cors(&get_cors()))
})
.options_async(oidc::TOKEN_PATH, |mut _req, _ctx| async move {
Response::empty()?.with_cors(&get_cors())
})
.post_async(oidc::TOKEN_PATH, |mut req, ctx| async move {
let form_data = req.form_data().await?;
let code = if let Some(FormEntry::Field(c)) = form_data.get("code") {
c
} else {
return Response::error("Missing code", 400);
};
let client_id = match form_data.get("client_id") {
Some(FormEntry::Field(c)) => Some(c),
None => None,
_ => return Response::error("Client ID not a field", 400),
};
let client_secret = match form_data.get("client_secret") {
Some(FormEntry::Field(c)) => Some(c),
None => None,
_ => return Response::error("Client secret not a field", 400),
};
let grant_type = if let Some(FormEntry::Field(c)) = form_data.get("code") {
if let Ok(cc) = serde_json::from_str(&format!("\"{}\"", c)) {
cc
} else {
return Response::error("Invalid grant type", 400);
}
} else {
return Response::error("Missing grant type", 400);
};
let secret = req
.headers()
.get(Authorization::<Bearer>::name().as_str())?
.and_then(|b| HeaderValue::from_str(b.as_ref()).ok())
.as_ref()
.and_then(|b| {
if b.to_str().unwrap().starts_with("Bearer") {
Bearer::decode(b).map(|bb| bb.token().to_string())
} else {
Basic::decode(b).map(|bb| bb.password().to_string())
}
});
let private_key = RsaPrivateKey::from_pkcs1_pem(&ctx.secret(RSA_PEM_KEY)?.to_string())
.map_err(|e| anyhow!("Failed to load private key: {}", e))
.unwrap();
let base_url = ctx.var(BASE_URL_KEY)?.to_string().parse().unwrap();
let url = req.url()?;
let eth_provider = ctx
.var(ETH_PROVIDER_KEY)
.map(|p| p.to_string().parse().unwrap())
.ok();
let db_client = CFClient { ctx, url };
let token_response = oidc::token(
TokenForm {
code,
client_id,
client_secret,
grant_type,
},
secret,
private_key,
base_url,
false,
eth_provider,
&db_client,
)
.await;
match token_response {
Ok(m) => Response::from_json(&m),
Err(e) => e.into(),
}
.and_then(|r| r.with_cors(&get_cors()))
})
.get_async(oidc::AUTHORIZE_PATH, |req, ctx| async move {
let base_url: Url = ctx.var(BASE_URL_KEY)?.to_string().parse().unwrap();
let url = req.url()?;
let query = url.query().unwrap_or_default();
let params = match serde_urlencoded::from_str(query) {
Ok(p) => p,
Err(_) => return CustomError::BadRequest("Bad query params".to_string()).into(),
};
let url = req.url()?;
let db_client = CFClient { ctx, url };
match oidc::authorize(params, &db_client).await {
Ok((url, session_cookie)) => {
Response::redirect(base_url.join(&url).unwrap()).map(|r| {
let mut headers = r.headers().clone();
headers
.set("set-cookie", &session_cookie.to_string())
.unwrap();
r.with_headers(headers)
})
}
Err(e) => match e {
CustomError::Redirect(url) => {
CustomError::Redirect(base_url.join(&url).unwrap().to_string())
}
c => c,
}
.into(),
}
})
.post_async(oidc::REGISTER_PATH, |mut req, ctx| async move {
let payload = json_bad_request!(req.json().await)?;
let base_url = ctx.var(BASE_URL_KEY)?.to_string().parse().unwrap();
let url = req.url()?;
let db_client = CFClient { ctx, url };
match oidc::register(payload, base_url, &db_client).await {
Ok(r) => Ok(Response::from_json(&r)?.with_status(201)),
Err(e) => e.into(),
}
})
.post_async(oidc::USERINFO_PATH, userinfo)
.get_async(oidc::USERINFO_PATH, userinfo)
.get_async(
&format!("{}/:id", oidc::CLIENT_PATH),
|req, ctx| async move {
let client_id = if let Some(id) = ctx.param("id") {
id.clone()
} else {
return Response::error("Bad Request", 400);
};
let url = req.url()?;
let db_client = CFClient { ctx, url };
match oidc::clientinfo(client_id, &db_client).await {
Ok(r) => Ok(Response::from_json(&r)?),
Err(e) => e.into(),
}
},
)
.delete_async(
&format!("{}/:id", oidc::CLIENT_PATH),
|req, ctx| async move {
let client_id = if let Some(id) = ctx.param("id") {
id.clone()
} else {
return Response::error("Bad Request", 400);
};
let bearer = req
.headers()
.get(Authorization::<Bearer>::name().as_str())?
.and_then(|b| HeaderValue::from_str(b.as_ref()).ok())
.as_ref()
.and_then(Bearer::decode);
let url = req.url()?;
let db_client = CFClient { ctx, url };
match oidc::client_delete(client_id, bearer, &db_client).await {
Ok(()) => Ok(Response::empty()?.with_status(204)),
Err(e) => e.into(),
}
},
)
.post_async(
&format!("{}/:id", oidc::CLIENT_PATH),
|mut req, ctx| async move {
let client_id = if let Some(id) = ctx.param("id") {
id.clone()
} else {
return Response::error("Bad Request", 400);
};
let bearer = req
.headers()
.get(Authorization::<Bearer>::name().as_str())?
.and_then(|b| HeaderValue::from_str(b.as_ref()).ok())
.as_ref()
.and_then(Bearer::decode);
let payload = json_bad_request!(req.json().await)?;
let url = req.url()?;
let db_client = CFClient { ctx, url };
match oidc::client_update(client_id, payload, bearer, &db_client).await {
Ok(()) => Ok(Response::empty()?),
Err(e) => e.into(),
}
},
)
.get_async(oidc::SIGNIN_PATH, |req, ctx| async move {
let url = req.url()?;
let query = url.query().unwrap_or_default();
let params = match serde_urlencoded::from_str(query) {
Ok(p) => p,
Err(_) => return CustomError::BadRequest("Bad query params".to_string()).into(),
};
let cookies = req
.headers()
.get(headers::Cookie::name().as_str())?
.and_then(|c| HeaderValue::from_str(&c).ok())
.and_then(|c| headers::Cookie::decode(&mut [c].iter()).ok());
if cookies.is_none() {
return Response::error("Missing cookies", 400);
}
let url = req.url()?;
let base_url = ctx.var(BASE_URL_KEY)?.to_string().parse().unwrap();
let db_client = CFClient { ctx, url };
match oidc::sign_in(&base_url, params, cookies.unwrap(), &db_client).await {
Ok(url) => Response::redirect(url),
Err(e) => e.into(),
}
})
.run(req, env)
.await
}

20
static/error.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>SIWE Open ID Connect</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link
href="https://api.fontshare.com/css?f[]=satoshi@300,301,400,401,500,501,700,701,900,901,1,2&display=swap"
rel="stylesheet"
/>
</head>
<body>
<h1>Invalid request</h1>
</body>
</html>

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
static/img/siwe_square.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +1,7 @@
version: "3"
services:
siwe-oidc:
build: .
build: ..
image: ghcr.io/spruceid/siwe_oidc:latest
ports:
- "8000:8000"
@ -10,19 +10,25 @@ services:
# Need siwe-oidc in /etc/hosts for localhost to allow both the host and Keycloak to reach the IdP
SIWEOIDC_BASE_URL: "http://siwe-oidc:8000/"
SIWEOIDC_REDIS_URL: "redis://redis"
SIWEOIDC_DEFAULT_CLIENTS: '{sdf="sdf"}'
SIWEOIDC_DEFAULT_CLIENTS: '{sdf="{\"secret\":\"sdf\", \"metadata\": {\"redirect_uris\": [\"http://localhost:8080/realms/master/broker/oidc/endpoint\"]}}"}'
RUST_LOG: "siwe_oidc=debug,tower_http=debug"
keycloak:
image: quay.io/keycloak/keycloak:latest
image: quay.io/keycloak/keycloak:19.0.1
ports:
- "8080:8080"
command: "start-dev"
# network_mode: "host"
environment:
DB_VENDOR: H2
KEYCLOAK_USER: admin
KEYCLOAK_PASSWORD: admin
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
redis:
image: redis:6-alpine
image: redis
healthcheck:
test: ["CMD", "redis-cli","ping"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "6379:6379"

36
wrangler_example.toml Normal file
View File

@ -0,0 +1,36 @@
name = "siwe_oidc"
type = "javascript"
account_id = ""
# zone_id = ""
workers_dev = false
compatibility_date = "2021-12-20"
kv_namespaces = [
{ binding = "SIWE_OIDC", id = "", preview_id = "" }
]
[vars]
WORKERS_RS_VERSION = "0.0.9"
BASE_URL = "https://siweoidc.spruceid.xyz"
# ETH_PROVIDER = ""
[durable_objects]
bindings = [
{ name = "SIWE-OIDC-CODES", class_name = "DOCodes" }
]
[[migrations]]
tag = "v1"
new_classes = ["DOCodes"]
[build]
command = "cargo install -q worker-build && worker-build --release"
[build.upload]
dir = "build/worker"
format = "modules"
main = "./shim.mjs"
[[build.upload.rules]]
globs = ["**/*.wasm"]
type = "CompiledWasm"