Compare commits

..

1 Commits

Author SHA1 Message Date
Simon Bihel
ba8a31386d
WIP use Redis cluster client for memorydb 2021-12-17 16:34:45 +00:00
45 changed files with 11536 additions and 20607 deletions

View File

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

View File

@ -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

View File

@ -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
View File

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

2685
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -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

View File

@ -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.

View File

@ -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"

View File

@ -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"

View File

@ -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"] }

View File

@ -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
```

View File

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

View File

@ -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>();
}

View File

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

View File

@ -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)
}
}

View File

@ -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"
}
}

View File

@ -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
View 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

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

25690
js/ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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>

View File

@ -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')
}
});

View File

@ -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%',
},
},
};
};

View File

@ -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/*"]
}

View File

@ -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: {

View File

@ -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();
}

View File

@ -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,
}
}
}

View File

@ -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))?)
}
}

View File

@ -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>>;
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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(),
// &params.client_id.as_bytes(),
// )
// .map_err(|e| anyhow!("Failed to decrypt client id: {}", e))?,
// )
// .map_err(|e| anyhow!("Failed to decrypt client id: {}", e))?
// if d != params.redirect_uri.as_str() {
// return Err(anyhow!("Client id not composed of redirect url"));
// };
if params.scope != Scope::new("openid".to_string()) {
Err(anyhow!("Scope not supported"))?;
}
let (nonce, headers) = match session {
UserSessionFromSession::FoundUserSession(nonce) => (nonce, HeaderMap::new()),
UserSessionFromSession::InvalidUserSession(cookie) => {
let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, cookie);
return Ok((
headers,
Redirect::to(
format!(
"/authorize?client_id={}&redirect_uri={}&scope={}&response_type={}&state={}{}",
&params.0.client_id,
&params.0.redirect_uri.to_string(),
&params.0.scope.to_string(),
&params.0.response_type.as_ref(),
&params.0.state,
&params.0.nonce.map(|n| format!("&nonce={}", n.secret())).unwrap_or(String::new())
)
.to_string()
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
));
}
UserSessionFromSession::CreatedFreshUserSession { header, nonce } => {
let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, header);
(nonce, headers)
}
};
let domain = params.redirect_uri.url().host().unwrap();
let oidc_nonce_param = if let Some(n) = &params.nonce {
format!("&oidc_nonce={}", n.secret())
} else {
"".to_string()
};
Ok((
headers,
Redirect::to(
format!(
"/?nonce={}&domain={}&redirect_uri={}&state={}{}",
nonce,
domain,
params.redirect_uri.to_string(),
params.state,
oidc_nonce_param
)
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
))
}
#[derive(Serialize, Deserialize)]
struct SiweCookie {
message: Web3ModalMessage,
signature: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Web3ModalMessage {
pub domain: String,
pub address: String,
pub statement: String,
pub uri: String,
pub version: String,
pub chain_id: String,
pub nonce: String,
pub issued_at: String,
pub expiration_time: Option<String>,
pub not_before: Option<String>,
pub request_id: Option<String>,
pub resources: Option<Vec<String>>,
}
impl Web3ModalMessage {
pub fn to_eip4361_message(&self) -> Result<Message> {
let mut next_resources: Vec<UriString> = Vec::new();
match &self.resources {
Some(resources) => {
for resource in resources {
let x = UriString::from_str(resource)?;
next_resources.push(x)
}
}
None => {}
}
Ok(Message {
domain: self.domain.clone().try_into()?,
address: <[u8; 20]>::from_hex(self.address.chars().skip(2).collect::<String>())?,
statement: self.statement.to_string(),
uri: UriAbsoluteString::from_str(&self.uri)?,
version: Version::from_str(&self.version)?,
chain_id: self.chain_id.to_string(),
nonce: self.nonce.to_string(),
issued_at: self.issued_at.to_string(),
expiration_time: self.expiration_time.clone(),
not_before: self.not_before.clone(),
request_id: self.request_id.clone(),
resources: next_resources,
})
}
}
#[derive(Serialize, Deserialize)]
struct CodeEntry {
exchange_count: usize,
address: String,
nonce: Option<Nonce>,
}
#[derive(Deserialize)]
struct SignInParams {
redirect_uri: RedirectUrl,
state: String,
oidc_nonce: Option<Nonce>,
}
async fn sign_in(
session: UserSessionFromSession,
params: Query<SignInParams>,
TypedHeader(cookies): TypedHeader<headers::Cookie>,
Extension(pool): Extension<ConnectionPool>,
) -> Result<(HeaderMap, Redirect), CustomError> {
let mut headers = HeaderMap::new();
let siwe_cookie: SiweCookie = match cookies.get("siwe") {
Some(c) => serde_json::from_str(
&decode(c).map_err(|e| anyhow!("Could not decode siwe cookie: {}", e))?,
)
.map_err(|e| anyhow!("Could not deserialize siwe cookie: {}", e))?,
None => Err(anyhow!("No `siwe` cookie"))?,
};
let (nonce, headers) = match session {
UserSessionFromSession::FoundUserSession(nonce) => (nonce, HeaderMap::new()),
UserSessionFromSession::InvalidUserSession(header) => {
headers.insert(header::SET_COOKIE, header);
return Ok((
headers,
Redirect::to(
format!(
"/authorize?client_id={}&redirect_uri={}&scope=openid&response_type=code&state={}",
&params.0.redirect_uri.to_string(),
&params.0.redirect_uri.to_string(),
&params.0.state,
)
.to_string()
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
));
}
UserSessionFromSession::CreatedFreshUserSession { .. } => {
return Ok((
headers,
Redirect::to(
format!(
"/authorize?client_id={}&redirect_uri={}&scope=openid&response_type=code&state={}",
&params.0.redirect_uri.to_string(),
&params.0.redirect_uri.to_string(),
&params.0.state,
)
.to_string()
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
))
}
};
let signature = match <[u8; 65]>::from_hex(
siwe_cookie
.signature
.chars()
.skip(2)
.take(130)
.collect::<String>()
.clone(),
) {
Ok(s) => s,
Err(e) => Err(CustomError::BadRequest(format!("Bad signature: {}", e)))?,
};
let message = siwe_cookie
.message
.to_eip4361_message()
.map_err(|e| anyhow!("Failed to serialise message: {}", e))?;
info!("{}", message);
message
.verify_eip191(signature)
.map_err(|e| anyhow!("Failed signature validation: {}", e))?;
let domain = params.redirect_uri.url().host().unwrap();
if domain.to_string() != siwe_cookie.message.domain {
Err(anyhow!("Conflicting domains in message and redirect"))?
}
if nonce != siwe_cookie.message.nonce {
Err(anyhow!("Conflicting nonces in message and session"))?
}
let code_entry = CodeEntry {
address: siwe_cookie.message.address,
nonce: params.oidc_nonce.clone(),
exchange_count: 0,
};
let code = Uuid::new_v4();
let mut conn = pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
conn.set_ex(
code.to_string(),
hex::encode(
bincode::serialize(&code_entry)
.map_err(|e| anyhow!("Failed to serialise code: {}", e))?,
),
ENTRY_LIFETIME,
)
.map_err(|e| anyhow!("Failed to set kv: {}", e))?;
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut().append_pair("code", &code.to_string());
url.query_pairs_mut().append_pair("state", &params.state);
Ok((
headers,
Redirect::to(
url.as_str()
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
))
// TODO clear session
}
async fn register(
extract::Json(payload): extract::Json<CoreClientMetadata>,
Extension(pool): Extension<ConnectionPool>,
) -> Result<Json<CoreClientRegistrationResponse>, CustomError> {
let id = Uuid::new_v4();
let secret = Uuid::new_v4();
let mut conn = pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
conn.set(format!("{}/{}", KV_CLIENT_PREFIX, id), secret.to_string())
.map_err(|e| anyhow!("Failed to set kv: {}", e))?;
Ok(CoreClientRegistrationResponse::new(
ClientId::new(id.to_string()),
payload.redirect_uris().to_vec(),
EmptyAdditionalClientMetadata::default(),
EmptyAdditionalClientRegistrationResponse::default(),
)
.set_client_secret(Some(ClientSecret::new(secret.to_string())))
.into())
}
// TODO CORS
// TODO need validation of the token
// TODO restrict access token use to only once?
async fn userinfo(
// access_token: AccessTokenUserInfo, // TODO maybe go through FromRequest https://github.com/tokio-rs/axum/blob/main/examples/jwt/src/main.rs
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>, // TODO maybe go through FromRequest https://github.com/tokio-rs/axum/blob/main/examples/jwt/src/main.rs
Extension(pool): Extension<ConnectionPool>,
) -> Result<Json<CoreUserInfoClaims>, CustomError> {
let code = bearer.token().to_string();
let mut conn = pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
let serialized_entry: Option<Vec<u8>> = conn
.get(code)
.map_err(|e| anyhow!("Failed to get kv: {}", e))?;
if serialized_entry.is_none() {
Err(CustomError::BadRequest("Unknown code.".to_string()))?;
}
let code_entry: CodeEntry = bincode::deserialize(
&hex::decode(serialized_entry.unwrap())
.map_err(|e| anyhow!("Failed to decode code entry: {}", e))?,
)
.map_err(|e| anyhow!("Failed to deserialize code: {}", e))?;
Ok(CoreUserInfoClaims::new(
StandardClaims::new(SubjectIdentifier::new(code_entry.address)),
EmptyAdditionalClaims::default(),
)
.into())
}
// TODO ping Redis
async fn healthcheck() {}
#[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();
}

View File

@ -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) = &params.nonce {
format!("&oidc_nonce={}", n.secret())
} else {
"".to_string()
};
Ok((
format!(
"/?nonce={}&domain={}&redirect_uri={}&state={}&client_id={}{}",
nonce, domain, *params.redirect_uri, state, params.client_id, oidc_nonce_param
),
Box::new(session_cookie),
))
}
#[derive(Serialize, Deserialize)]
pub struct SiweCookie {
message: Web3ModalMessage,
signature: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Web3ModalMessage {
pub domain: String,
pub address: H160,
pub statement: String,
pub uri: String,
pub version: String,
pub chain_id: u64,
pub nonce: String,
pub issued_at: String,
pub expiration_time: Option<String>,
pub not_before: Option<String>,
pub request_id: Option<String>,
pub resources: Vec<UriString>,
}
impl Web3ModalMessage {
fn to_eip4361_message(&self) -> Result<Message> {
Ok(Message {
domain: self.domain.clone().try_into()?,
address: self.address.0,
statement: Some(self.statement.to_string()),
uri: UriString::from_str(&self.uri)?,
version: Version::from_str(&self.version)?,
chain_id: self.chain_id,
nonce: self.nonce.to_string(),
issued_at: TimeStamp::from_str(&self.issued_at)?,
expiration_time: match &self.expiration_time {
Some(t) => Some(TimeStamp::from_str(t)?),
None => None,
},
not_before: match &self.not_before {
Some(t) => Some(TimeStamp::from_str(t)?),
None => None,
},
request_id: self.request_id.clone(),
resources: self.resources.clone(),
})
}
}
#[derive(Deserialize)]
pub struct SignInParams {
pub redirect_uri: RedirectUrl,
pub state: String,
pub oidc_nonce: Option<Nonce>,
pub client_id: String,
}
pub async fn sign_in(
base_url: &Url,
params: SignInParams,
// cookies_header: String,
cookies: headers::Cookie,
db_client: &DBClientType,
) -> Result<Url, CustomError> {
// TODO redirect on session errors
let session_id = if let Some(c) = cookies.get(SESSION_COOKIE_NAME) {
c
} else {
return Err(CustomError::BadRequest(
"Session cookie not found".to_string(),
));
};
let session_entry = if let Some(e) = db_client.get_session(session_id.to_string()).await? {
e
} else {
return Err(CustomError::BadRequest("Session not found".to_string()));
};
if session_entry.signin_count > 0 {
return Err(CustomError::BadRequest(
"Session has already logged in".to_string(),
));
}
let siwe_cookie: SiweCookie = match cookies.get(SIWE_COOKIE_KEY) {
Some(c) => serde_json::from_str(
&decode(c).map_err(|e| anyhow!("Could not decode siwe cookie: {}", e))?,
)
.map_err(|e| anyhow!("Could not deserialize siwe cookie: {}", e))?,
None => {
return Err(anyhow!("No `siwe` cookie").into());
}
};
let signature = match <[u8; 65]>::from_hex(
siwe_cookie
.signature
.chars()
.skip(2)
.take(130)
.collect::<String>(),
) {
Ok(s) => s,
Err(e) => {
return Err(CustomError::BadRequest(format!("Bad signature: {}", e)));
}
};
let message = siwe_cookie
.message
.to_eip4361_message()
.map_err(|e| anyhow!("Failed to serialise message: {}", e))?;
info!("{}", message);
let domain = if let Some(d) = base_url.domain() {
match d.try_into() {
Ok(dd) => Some(dd),
Err(e) => {
error!("Failed to translate domain into authority: {}", e);
None
}
}
} else {
None
};
message
.verify(
&signature,
&VerificationOpts {
domain,
nonce: Some(session_entry.siwe_nonce.clone()),
timestamp: None,
},
)
.await
.map_err(|e| anyhow!("Failed message verification: {}", e))?;
let domain = params.redirect_uri.url();
if let Some(r) = siwe_cookie.message.resources.get(0) {
if *domain != Url::from_str(r.as_ref()).unwrap() {
return Err(anyhow!("Conflicting domains in message and redirect").into());
}
} else {
return Err(anyhow!("Missing resource in SIWE message").into());
}
let code_entry = CodeEntry {
address: siwe_cookie.message.address,
nonce: params.oidc_nonce.clone(),
exchange_count: 0,
client_id: params.client_id.clone(),
auth_time: Utc::now(),
chain_id: Some(siwe_cookie.message.chain_id),
};
let mut new_session_entry = session_entry.clone();
new_session_entry.signin_count += 1;
db_client
.set_session(session_id.to_string(), new_session_entry)
.await?;
let code = Uuid::new_v4();
db_client.set_code(code.to_string(), code_entry).await?;
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut().append_pair("code", &code.to_string());
url.query_pairs_mut().append_pair("state", &params.state);
Ok(url)
}
#[derive(Debug, Serialize)]
pub struct RegisterError {
error: CoreRegisterErrorResponseType,
}
pub async fn register(
payload: CoreClientMetadata,
base_url: Url,
db_client: &DBClientType,
) -> Result<CoreClientRegistrationResponse, CustomError> {
let id = Uuid::new_v4();
let secret: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
let redirect_uris = payload.redirect_uris().to_vec();
for uri in redirect_uris.iter() {
if uri.url().fragment().is_some() {
return Err(CustomError::BadRequestRegister(RegisterError {
error: CoreRegisterErrorResponseType::InvalidRedirectUri,
}));
}
}
let access_token = RegistrationAccessToken::new(
thread_rng()
.sample_iter(&Alphanumeric)
.take(11)
.map(char::from)
.collect(),
);
let entry = ClientEntry {
secret: secret.clone(),
metadata: payload,
access_token: Some(access_token.clone()),
};
db_client.set_client(id.to_string(), entry).await?;
Ok(CoreClientRegistrationResponse::new(
ClientId::new(id.to_string()),
redirect_uris,
EmptyAdditionalClientMetadata::default(),
EmptyAdditionalClientRegistrationResponse::default(),
)
.set_client_secret(Some(ClientSecret::new(secret)))
.set_registration_client_uri(Some(ClientConfigUrl::from_url(
base_url
.join(&format!("{}/{}", CLIENT_PATH, id))
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
)))
.set_registration_access_token(Some(access_token)))
}
async fn client_access(
client_id: String,
bearer: Option<Bearer>,
db_client: &DBClientType,
) -> Result<ClientEntry, CustomError> {
let access_token = if let Some(b) = bearer {
b.token().to_string()
} else {
return Err(CustomError::BadRequest("Missing access token.".to_string()));
};
let client_entry = db_client
.get_client(client_id)
.await?
.ok_or(CustomError::NotFound)?;
let stored_access_token = client_entry.access_token.clone();
if stored_access_token.is_none() || *stored_access_token.unwrap().secret() != access_token {
return Err(CustomError::Unauthorized("Bad access token.".to_string()));
}
Ok(client_entry)
}
pub async fn clientinfo(
client_id: String,
db_client: &DBClientType,
) -> Result<CoreClientMetadata, CustomError> {
Ok(db_client
.get_client(client_id)
.await?
.ok_or(CustomError::NotFound)?
.metadata)
}
pub async fn client_delete(
client_id: String,
bearer: Option<Bearer>,
db_client: &DBClientType,
) -> Result<(), CustomError> {
client_access(client_id.clone(), bearer, db_client).await?;
Ok(db_client.delete_client(client_id).await?)
}
pub async fn client_update(
client_id: String,
payload: CoreClientMetadata,
bearer: Option<Bearer>,
db_client: &DBClientType,
) -> Result<(), CustomError> {
let mut client_entry = client_access(client_id.clone(), bearer, db_client).await?;
client_entry.metadata = payload;
Ok(db_client.set_client(client_id, client_entry).await?)
}
#[derive(Deserialize)]
pub struct UserInfoPayload {
pub access_token: Option<String>,
}
pub enum UserInfoResponse {
Json(CoreUserInfoClaims),
Jwt(CoreUserInfoJsonWebToken),
}
pub async fn userinfo(
base_url: Url,
eth_provider: Option<Url>,
private_key: RsaPrivateKey,
bearer: Option<Bearer>,
payload: UserInfoPayload,
db_client: &DBClientType,
) -> Result<UserInfoResponse, CustomError> {
let code = if let Some(b) = bearer {
b.token().to_string()
} else if let Some(c) = payload.access_token {
c
} else {
return Err(CustomError::BadRequest("Missing access token.".to_string()));
};
let code_entry = if let Some(c) = db_client.get_code(code).await? {
c
} else {
return Err(CustomError::BadRequest("Unknown code.".to_string()));
};
let client_entry = if let Some(c) = db_client.get_client(code_entry.client_id.clone()).await? {
c
} else {
return Err(CustomError::BadRequest("Unknown client.".to_string()));
};
let response = CoreUserInfoClaims::new(
resolve_claims(
eth_provider,
code_entry.address,
code_entry.chain_id.unwrap_or(1),
)
.await,
EmptyAdditionalClaims::default(),
)
.set_issuer(Some(IssuerUrl::from_url(base_url.clone())))
.set_audiences(Some(vec![Audience::new(code_entry.client_id)]));
match client_entry.metadata.userinfo_signed_response_alg() {
None => Ok(UserInfoResponse::Json(response)),
Some(alg) => {
let signing_key = jwk(private_key)?;
Ok(UserInfoResponse::Jwt(
CoreUserInfoJsonWebToken::new(response, &signing_key, alg.clone())
.map_err(|_| anyhow!("Error signing response."))?,
))
}
}
}
#[cfg(test)]
mod tests {
use crate::config::Config;
use super::*;
use ethers_signers::{LocalWallet, Signer};
use headers::{HeaderMap, HeaderMapExt, HeaderValue};
use rand::rngs::OsRng;
use test_log::test;
async fn default_config() -> (Config, RedisClient) {
let config = Config::default();
let db_client = RedisClient::new(&config.redis_url).await.unwrap();
db_client
.set_client(
"client".into(),
ClientEntry {
secret: "secret".into(),
metadata: CoreClientMetadata::new(
vec![RedirectUrl::new("https://example.com".into()).unwrap()],
EmptyAdditionalClientMetadata {},
),
access_token: None,
},
)
.await
.unwrap();
(config, db_client)
}
#[test(tokio::test)]
async fn test_claims() {
let res = resolve_claims(
Some("https://cloudflare-eth.com".try_into().unwrap()),
<[u8; 20]>::from_hex("d8da6bf26964af9d7eed9e03e53415d37aa96045")
.unwrap()
.into(),
1,
)
.await;
assert_eq!(
res.preferred_username().map(|u| u.to_string()),
Some("vitalik.eth".to_string())
);
assert_eq!(
res.picture().map(|u| u.get(None).unwrap().as_str()),
Some("https://ipfs.io/ipfs/QmSP4nq9fnN9dAiCj42ug9Wa79rqmQerZXZch82VqpiH7U/image.gif")
);
}
#[derive(Deserialize)]
struct AuthorizeQueryParams {
nonce: String,
}
#[derive(Deserialize)]
struct SignInQueryParams {
code: String,
}
#[tokio::test]
async fn e2e_flow() {
let (_config, db_client) = default_config().await;
let wallet = "dcf2cbdd171a21c480aa7f53d77f31bb102282b3ff099c78e3118b37348c72f7"
.parse::<LocalWallet>()
.unwrap();
let base_url = Url::parse("https://example.com").unwrap();
let params = AuthorizeParams {
client_id: "client".into(),
redirect_uri: RedirectUrl::from_url(base_url.clone()),
scope: Scope::new("openid".to_string()),
response_type: Some(CoreResponseType::IdToken),
state: Some("state".into()),
nonce: None,
prompt: None,
request_uri: None,
request: None,
};
let (redirect_url, cookie) = authorize(params, &db_client).await.unwrap();
let authorize_params: AuthorizeQueryParams =
serde_urlencoded::from_str(redirect_url.split("/?").collect::<Vec<&str>>()[1]).unwrap();
let params: SignInParams = serde_urlencoded::from_str(&redirect_url).unwrap();
let message = Web3ModalMessage {
domain: "example.com".into(),
address: wallet.address(),
statement: "statement".to_string(),
uri: base_url.to_string(),
version: "1".into(),
chain_id: 1,
nonce: authorize_params.nonce,
issued_at: "2023-04-17T11:01:24.862Z".into(),
expiration_time: None,
not_before: None,
request_id: None,
resources: vec!["https://example.com".try_into().unwrap()],
};
let signature = wallet
.sign_message(message.to_eip4361_message().unwrap().to_string())
.await
.unwrap();
let signature = format!("0x{signature}");
let siwe_cookie = serde_json::to_string(&SiweCookie { message, signature }).unwrap();
let mut headers = HeaderMap::new();
headers.insert(
"cookie",
HeaderValue::from_str(&format!("{cookie}; {SIWE_COOKIE_KEY}={siwe_cookie}")).unwrap(),
);
let cookie = headers.typed_get::<headers::Cookie>().unwrap();
let redirect_url = sign_in(&base_url, params, cookie, &db_client)
.await
.unwrap();
let signin_params: SignInQueryParams =
serde_urlencoded::from_str(redirect_url.query().unwrap()).unwrap();
let _ = userinfo(
base_url,
None,
RsaPrivateKey::new(&mut OsRng, 1024).unwrap(),
None,
UserInfoPayload {
access_token: Some(signin_params.code),
},
&db_client,
)
.await
.unwrap();
}
}

52
src/redis_conn.rs Normal file
View 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
View 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(),
}
}
}

View File

@ -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
}

View File

@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Binary file not shown.

View File

@ -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"