Compare commits
1 Commits
main
...
feat/clust
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ba8a31386d |
@ -1,3 +1,3 @@
|
||||
.github
|
||||
example
|
||||
target
|
||||
target/
|
||||
js/ui/node_modules/
|
||||
static/build
|
||||
|
41
.github/workflows/ci.yml
vendored
@ -1,41 +0,0 @@
|
||||
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
@ -1,23 +0,0 @@
|
||||
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
@ -1,5 +1,2 @@
|
||||
**/target
|
||||
/target
|
||||
/static/build
|
||||
**/wrangler.toml
|
||||
**/node_module
|
||||
**/dist
|
||||
|
2685
Cargo.lock
generated
76
Cargo.toml
@ -5,64 +5,38 @@ 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."
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.53"
|
||||
headers = "0.3.6"
|
||||
anyhow = "1.0.51"
|
||||
axum = { version = "0.3.4", features = ["headers"] }
|
||||
chrono = "0.4.19"
|
||||
headers = "0.3.5"
|
||||
hex = "0.4.3"
|
||||
iri-string = { version = "0.6", features = ["serde"] }
|
||||
openidconnect = "3.0.0"
|
||||
iri-string = { version = "0.4", features = ["serde-std"] }
|
||||
openidconnect = "2.1.2"
|
||||
rand = "0.8.4"
|
||||
rsa = { version = "0.7.0" }
|
||||
rsa = { version = "0.5.0", features = ["alloc"] }
|
||||
rust-argon2 = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.78"
|
||||
serde_urlencoded = "0.7.0"
|
||||
siwe = "0.5.0"
|
||||
serde_json = "1.0.72"
|
||||
siwe = "0.1"
|
||||
async-session = "3.0.0"
|
||||
thiserror = "1.0.30"
|
||||
tracing = "0.1.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"] }
|
||||
url = { version = "2.2", features = ["serde"] }
|
||||
urlencoding = "2.1.0"
|
||||
sha2 = "0.10.0"
|
||||
cookie = "0.16.0"
|
||||
bincode = "1.3.3"
|
||||
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']
|
||||
figment = { version = "0.10.6", features = ["toml", "env"] }
|
||||
sha2 = "0.9.0"
|
||||
cookie = "0.15.1"
|
||||
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"
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM clux/muslrust:stable as chef
|
||||
FROM clux/muslrust:1.57.0 as chef
|
||||
WORKDIR /siwe-oidc
|
||||
RUN cargo install cargo-chef
|
||||
|
||||
@ -14,10 +14,8 @@ 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
|
||||
@ -35,7 +33,6 @@ 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
|
||||
|
92
README.md
@ -2,69 +2,16 @@
|
||||
|
||||
## Getting Started
|
||||
|
||||
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
|
||||
### 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_REDIS_URL="redis://redis" ghcr.io/spruceid/siwe_oidc:latest
|
||||
docker run -p 8000:8000 -e SIWEOIDC_ADDRESS="0.0.0.0" -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
|
||||
@ -79,42 +26,23 @@ 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); and
|
||||
- an ENS domain as the `preferred_username` (with a fallback to the address).
|
||||
receive an Ethereum address as the subject (`sub` field).
|
||||
|
||||
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 profile
|
||||
pictures), to more traditional ones (e.g. email).
|
||||
* Additional information, from native projects (e.g. ENS domains), 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 -f test/docker-compose.yml up -d
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
2. And then edit your `/etc/hosts` to have `siwe-oidc` point to `127.0.0.1`.
|
||||
@ -123,9 +51,3 @@ docker-compose -f test/docker-compose.yml 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.
|
||||
|
@ -1,7 +1,7 @@
|
||||
version: "3"
|
||||
services:
|
||||
siwe-oidc:
|
||||
build: ..
|
||||
build: .
|
||||
image: ghcr.io/spruceid/siwe_oidc:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@ -10,25 +10,19 @@ 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="{\"secret\":\"sdf\", \"metadata\": {\"redirect_uris\": [\"http://localhost:8080/realms/master/broker/oidc/endpoint\"]}}"}'
|
||||
SIWEOIDC_DEFAULT_CLIENTS: '{sdf="sdf"}'
|
||||
RUST_LOG: "siwe_oidc=debug,tower_http=debug"
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:19.0.1
|
||||
image: quay.io/keycloak/keycloak:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
command: "start-dev"
|
||||
# network_mode: "host"
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
DB_VENDOR: H2
|
||||
KEYCLOAK_USER: admin
|
||||
KEYCLOAK_PASSWORD: admin
|
||||
|
||||
redis:
|
||||
image: redis
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli","ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
image: redis:6-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
@ -1,3 +0,0 @@
|
||||
CLIENT_ID="8f169184-8815-457c-a32f-85b39fbcfca7"
|
||||
CLIENT_SECRET="430d8ade-d2d7-48c1-9c68-b97fcd73967d"
|
||||
REDIRECT_URI="https://demo-oidc.login.xyz/callback"
|
@ -1,17 +0,0 @@
|
||||
[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"] }
|
@ -1,30 +0,0 @@
|
||||
# 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
|
||||
```
|
@ -1,10 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
</body>
|
||||
</html>
|
@ -1,122 +0,0 @@
|
||||
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
@ -1,3 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
worker
|
@ -1,39 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
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"
|
4
js/ui/img/coinbase.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<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>
|
After Width: | Height: | Size: 535 B |
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.9 KiB |
25690
js/ui/package-lock.json
generated
@ -2,59 +2,58 @@
|
||||
"name": "svelte-app",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@tsconfig/svelte": "^3.0.0",
|
||||
"@types/node": "^17.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^5.9.0",
|
||||
"@typescript-eslint/parser": "^5.9.0",
|
||||
"@tsconfig/svelte": "^1.0.10",
|
||||
"@types/node": "^14.11.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.21.0",
|
||||
"@typescript-eslint/parser": "^4.21.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": "^6.5.1",
|
||||
"css-loader": "^5.0.1",
|
||||
"cssnano": "^5.0.8",
|
||||
"dotenv-webpack": "^7.0.3",
|
||||
"eslint": "^8.6.0",
|
||||
"eslint": "^7.23.0",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-svelte3": "^3.1.2",
|
||||
"https-browserify": "^1.0.0",
|
||||
"mini-css-extract-plugin": "^2.4.5",
|
||||
"mini-css-extract-plugin": "^1.3.4",
|
||||
"os-browserify": "^0.3.0",
|
||||
"postcss": "^8.2.8",
|
||||
"postcss-load-config": "^3.0.1",
|
||||
"postcss-loader": "^6.2.1",
|
||||
"postcss-loader": "^5.2.0",
|
||||
"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.49.0",
|
||||
"svelte-check": "^2.2.11",
|
||||
"svelte": "^3.31.2",
|
||||
"svelte-check": "^1.0.46",
|
||||
"svelte-loader": "^3.0.0",
|
||||
"svelte-preprocess": "^4.3.0",
|
||||
"svg-url-loader": "^7.1.1",
|
||||
"tailwindcss": "^3.0.9",
|
||||
"ts-loader": "^9.2.6",
|
||||
"tailwindcss": "^2.0.4",
|
||||
"ts-loader": "^8.0.4",
|
||||
"tslib": "^2.0.1",
|
||||
"typescript": "^4.0.3",
|
||||
"webpack": "^5.76.0",
|
||||
"webpack": "^5.16.0",
|
||||
"webpack-cli": "^4.4.0",
|
||||
"webpack-dev-server": "^4.6.0"
|
||||
"webpack-dev-server": "^3.11.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_ENV=production webpack",
|
||||
"dev": "webpack serve --static-directory ../../static --port 9080",
|
||||
"dev": "webpack serve --content-base ../../static --port 9080",
|
||||
"validate": "svelte-check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coinbase/wallet-sdk": "^3.0.5",
|
||||
"@portis/web3": "^4.0.6",
|
||||
"@spruceid/siwe-web3modal": "^0.1.11",
|
||||
"@toruslabs/torus-embed": "^1.21.0",
|
||||
"@walletconnect/web3-provider": "^1.7.7",
|
||||
"@spruceid/siwe-web3modal": "^0.1.5",
|
||||
"@toruslabs/torus-embed": "^1.18.3",
|
||||
"@walletconnect/web3-provider": "^1.6.6",
|
||||
"fortmatic": "^2.2.1",
|
||||
"url": "^0.11.0"
|
||||
"walletlink": "^2.2.8"
|
||||
}
|
||||
}
|
||||
|
@ -4,8 +4,7 @@
|
||||
import Torus from '@toruslabs/torus-embed';
|
||||
import WalletConnectProvider from '@walletconnect/web3-provider';
|
||||
import Fortmatic from 'fortmatic';
|
||||
import { onMount } from 'svelte';
|
||||
import CoinbaseWalletSDK from "@coinbase/wallet-sdk";
|
||||
import WalletLink from 'walletlink';
|
||||
|
||||
// TODO: REMOVE DEFAULTS:
|
||||
// main.ts will parse the params from the server
|
||||
@ -14,7 +13,8 @@
|
||||
export let redirect: string;
|
||||
export let state: string;
|
||||
export let oidc_nonce: string;
|
||||
export let client_id: string;
|
||||
|
||||
let uri: string = window.location.href.split('?')[0];
|
||||
|
||||
// Could be exposed in the future.
|
||||
export let useENS: boolean = true;
|
||||
@ -23,13 +23,12 @@
|
||||
|
||||
let client = new Client({
|
||||
session: {
|
||||
domain: window.location.host,
|
||||
uri: window.location.origin,
|
||||
domain,
|
||||
uri,
|
||||
useENS,
|
||||
version: '1',
|
||||
// TODO: Vet this as the default statement.
|
||||
statement: `You are signing-in to ${domain}.`,
|
||||
resources: [redirect],
|
||||
statement: 'Sign-In With Ethereum OpenID-Connect',
|
||||
},
|
||||
modal: {
|
||||
theme: 'dark',
|
||||
@ -56,36 +55,42 @@
|
||||
key: process.env.FORTMATIC_KEY,
|
||||
},
|
||||
},
|
||||
walletlink: {
|
||||
package: CoinbaseWalletSDK,
|
||||
'custom-coinbase': {
|
||||
display: {
|
||||
logo: 'img/coinbase.svg',
|
||||
name: 'Coinbase',
|
||||
description: 'Scan with WalletLink to connect',
|
||||
},
|
||||
options: {
|
||||
appName: "Sign-In with Ethereum",
|
||||
infuraId: process.env.INFURA_ID
|
||||
}
|
||||
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;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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 != null && oidc_nonce != '') {
|
||||
if (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)}&client_id=${encodeURI(
|
||||
client_id,
|
||||
)}${encodeURI(oidc_nonce_param)}`,
|
||||
`/sign_in?redirect_uri=${encodeURI(redirect)}&state=${encodeURI(state)}${encodeURI(oidc_nonce_param)}`,
|
||||
);
|
||||
});
|
||||
</script>
|
||||
@ -94,22 +99,13 @@
|
||||
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-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}
|
||||
<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" />
|
||||
<h5>Welcome</h5>
|
||||
<span class="text-xs">
|
||||
Sign-In with Ethereum to continue to {client_metadata.client_name ? client_metadata.client_name : domain}
|
||||
</span>
|
||||
<span class="text-xs">Sign-In with Ethereum to continue to your application</span>
|
||||
|
||||
<button
|
||||
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"
|
||||
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"
|
||||
on:click={() => {
|
||||
client.signIn(nonce).catch((e) => {
|
||||
console.error(e);
|
||||
@ -142,14 +138,6 @@
|
||||
</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>
|
||||
|
||||
|
@ -11,8 +11,7 @@ const app = new App({
|
||||
nonce: params.get('nonce'),
|
||||
redirect: params.get('redirect_uri'),
|
||||
state: params.get('state'),
|
||||
oidc_nonce: params.get('oidc_nonce'),
|
||||
client_id: params.get('client_id')
|
||||
oidc_nonce: params.get('oidc_nonce')
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
purge: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
screens: {
|
||||
@ -177,4 +177,4 @@ module.exports = {
|
||||
'1/10': '0 10%',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
@ -1,8 +1,5 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"include": ["src/**/*", "src/node_modules/**/*"],
|
||||
"exclude": ["node_modules/*", "__sapper__/*", "static/*"],
|
||||
"compilerOptions": {
|
||||
"types": ["node", "svelte"]
|
||||
}
|
||||
}
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"include": ["src/**/*", "src/node_modules/**/*"],
|
||||
"exclude": ["node_modules/*", "__sapper__/*", "static/*"]
|
||||
}
|
@ -27,7 +27,6 @@ module.exports = {
|
||||
path: false,
|
||||
process: require.resolve('process/browser'),
|
||||
stream: require.resolve('stream-browserify'),
|
||||
url: require.resolve("url")
|
||||
// util: false,
|
||||
}
|
||||
},
|
||||
@ -96,7 +95,6 @@ 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
@ -1,368 +0,0 @@
|
||||
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();
|
||||
}
|
@ -15,7 +15,6 @@ 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 {
|
||||
@ -27,8 +26,7 @@ impl Default for Config {
|
||||
rsa_pem: None,
|
||||
redis_url: Url::parse("redis://localhost").unwrap(),
|
||||
default_clients: HashMap::default(),
|
||||
require_secret: false,
|
||||
eth_provider: None,
|
||||
require_secret: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
231
src/db/cf.rs
@ -1,231 +0,0 @@
|
||||
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))?)
|
||||
}
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
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
@ -1,154 +0,0 @@
|
||||
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
@ -1,17 +0,0 @@
|
||||
#[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
|
||||
}
|
731
src/main.rs
@ -1,17 +1,720 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod axum_lib;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod config;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod oidc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use axum_lib::main as axum_main;
|
||||
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"))]
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
axum_main().await
|
||||
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),
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {}
|
||||
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(),
|
||||
// ¶ms.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={}{}",
|
||||
¶ms.0.client_id,
|
||||
¶ms.0.redirect_uri.to_string(),
|
||||
¶ms.0.scope.to_string(),
|
||||
¶ms.0.response_type.as_ref(),
|
||||
¶ms.0.state,
|
||||
¶ms.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) = ¶ms.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={}",
|
||||
¶ms.0.redirect_uri.to_string(),
|
||||
¶ms.0.redirect_uri.to_string(),
|
||||
¶ms.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={}",
|
||||
¶ms.0.redirect_uri.to_string(),
|
||||
¶ms.0.redirect_uri.to_string(),
|
||||
¶ms.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", ¶ms.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() {}
|
||||
|
||||
#[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();
|
||||
}
|
||||
|
960
src/oidc.rs
@ -1,960 +0,0 @@
|
||||
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) = ¶ms.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", ¶ms.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();
|
||||
}
|
||||
}
|
52
src/redis_conn.rs
Normal file
@ -0,0 +1,52 @@
|
||||
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
|
||||
}
|
||||
}
|
134
src/session.rs
Normal file
@ -0,0 +1,134 @@
|
||||
// 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(),
|
||||
}
|
||||
}
|
||||
}
|
@ -1,336 +0,0 @@
|
||||
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
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
<!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>
|
Before Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 4.1 KiB |
@ -1,36 +0,0 @@
|
||||
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"
|