Initial commit
Co-authored-by: K <kelseydrhoda@gmail.com>
This commit is contained in:
commit
5f5f6af531
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
target/
|
||||||
|
js/ui/node_modules/
|
||||||
|
static/build
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
/static/build
|
2958
Cargo.lock
generated
Normal file
2958
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
Cargo.toml
Normal file
39
Cargo.toml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
[package]
|
||||||
|
name = "siwe-oidc"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Spruce Systems, Inc."]
|
||||||
|
license = "Apache-2.0"
|
||||||
|
repository = "https://github.com/spruceid/siwe-oidc/"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
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.4", features = ["serde-std"] }
|
||||||
|
openidconnect = "2.1.2"
|
||||||
|
rand = "0.8.4"
|
||||||
|
rsa = { version = "0.5.0", features = ["alloc"] }
|
||||||
|
rust-argon2 = "0.8"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0.72"
|
||||||
|
siwe = "0.1"
|
||||||
|
async-session = "3.0.0"
|
||||||
|
thiserror = "1.0.30"
|
||||||
|
tokio = { version = "1.14.0", features = ["full"] }
|
||||||
|
tower-http = { version = "0.2.0", features = ["fs", "trace", "cors"] }
|
||||||
|
tracing = "0.1.29"
|
||||||
|
tracing-subscriber = { version = "0.3.2", features = ["env-filter"] }
|
||||||
|
url = { version = "2.2", features = ["serde"] }
|
||||||
|
urlencoding = "2.1.0"
|
||||||
|
uuid = { version = "0.8", features = ["serde", "v4"] }
|
||||||
|
figment = { version = "0.10.6", features = ["toml", "env"] }
|
||||||
|
sha2 = "0.9.0"
|
||||||
|
cookie = "0.15.1"
|
||||||
|
bincode = "1.3.3"
|
||||||
|
bb8-redis = "0.10.1"
|
||||||
|
async-redis-session = "0.2.2"
|
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
FROM clux/muslrust:1.57.0 as chef
|
||||||
|
WORKDIR /siwe-oidc
|
||||||
|
RUN cargo install cargo-chef
|
||||||
|
|
||||||
|
FROM chef as dep_planner
|
||||||
|
COPY ./src/ ./src/
|
||||||
|
COPY ./Cargo.lock ./
|
||||||
|
COPY ./Cargo.toml ./
|
||||||
|
COPY ./siwe-oidc.toml ./
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
|
FROM chef as dep_cacher
|
||||||
|
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
|
||||||
|
ADD --chown=node:node ./static /siwe-oidc/static
|
||||||
|
ADD --chown=node:node ./js/ui /siwe-oidc/js/ui
|
||||||
|
WORKDIR /siwe-oidc/js/ui
|
||||||
|
RUN npm install
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM chef as builder
|
||||||
|
COPY --from=dep_cacher /siwe-oidc/target/ ./target/
|
||||||
|
COPY --from=dep_cacher $CARGO_HOME $CARGO_HOME
|
||||||
|
COPY --from=dep_planner /siwe-oidc/ ./
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
FROM alpine
|
||||||
|
COPY --from=builder /siwe-oidc/target/x86_64-unknown-linux-musl/release/siwe-oidc /usr/local/bin/
|
||||||
|
WORKDIR /siwe-oidc
|
||||||
|
RUN mkdir -p ./static
|
||||||
|
COPY --from=node_builder /siwe-oidc/static/ ./static/
|
||||||
|
COPY --from=builder /siwe-oidc/siwe-oidc.toml ./
|
||||||
|
EXPOSE 8000
|
||||||
|
ENTRYPOINT ["siwe-oidc"]
|
||||||
|
LABEL org.opencontainers.image.source https://github.com/spruceid/siwe-oidc
|
||||||
|
LABEL org.opencontainers.image.description "OpenID Connect Identity Provider for Sign-In with Ethereum"
|
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
siwe-oidc:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
SIWEOIDC_ADDRESS: "0.0.0.0"
|
||||||
|
# Need siwe-oidc in /etc/hosts for localhost to allow both the host and Keycloak to reach the IdP
|
||||||
|
SIWEOIDC_BASE_URL: "http://siwe-oidc:8000/"
|
||||||
|
SIWEOIDC_REDIS_URL: "redis://redis"
|
||||||
|
SIWEOIDC_DEFAULT_CLIENTS: '{sdf="sdf"}'
|
||||||
|
RUST_LOG: "siwe_oidc=debug,tower_http=debug"
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
DB_VENDOR: H2
|
||||||
|
KEYCLOAK_USER: admin
|
||||||
|
KEYCLOAK_PASSWORD: admin
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:6-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
3
js/ui/.gitignore
vendored
Normal file
3
js/ui/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
public/build/bundle.*
|
1
js/ui/.vscode/extensions.json
vendored
Normal file
1
js/ui/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"recommendations": ["svelte.svelte-vscode"]}
|
64
js/ui/README.md
Normal file
64
js/ui/README.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# svelte app
|
||||||
|
|
||||||
|
This is a project template for [Svelte](https://svelte.dev) apps. It lives at https://github.com/sveltejs/template-webpack.
|
||||||
|
|
||||||
|
To create a new project based on this template using [degit](https://github.com/Rich-Harris/degit):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx degit sveltejs/template-webpack svelte-app
|
||||||
|
cd svelte-app
|
||||||
|
```
|
||||||
|
|
||||||
|
*Note that you will need to have [Node.js](https://nodejs.org) installed.*
|
||||||
|
|
||||||
|
|
||||||
|
## Get started
|
||||||
|
|
||||||
|
Install the dependencies...
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd svelte-app
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
...then start webpack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Navigate to [localhost:8080](http://localhost:8080). You should see your app running. Edit a component file in `src`, save it, and the page should reload with your changes.
|
||||||
|
|
||||||
|
|
||||||
|
## Deploying to the web
|
||||||
|
|
||||||
|
### With [now](https://zeit.co/now)
|
||||||
|
|
||||||
|
Install `now` if you haven't already:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g now
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, from within your project folder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
now
|
||||||
|
```
|
||||||
|
|
||||||
|
As an alternative, use the [Now desktop client](https://zeit.co/download) and simply drag the unzipped project folder to the taskbar icon.
|
||||||
|
|
||||||
|
### With [surge](https://surge.sh/)
|
||||||
|
|
||||||
|
Install `surge` if you haven't already:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g surge
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, from within your project folder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
surge public
|
||||||
|
```
|
23436
js/ui/package-lock.json
generated
Normal file
23436
js/ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
js/ui/package.json
Normal file
43
js/ui/package.json
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "svelte-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/svelte": "^1.0.10",
|
||||||
|
"@types/node": "^14.11.1",
|
||||||
|
"assert": "^2.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"crypto-browserify": "^3.12.0",
|
||||||
|
"css-loader": "^5.0.1",
|
||||||
|
"dotenv-webpack": "^7.0.3",
|
||||||
|
"https-browserify": "^1.0.0",
|
||||||
|
"mini-css-extract-plugin": "^1.3.4",
|
||||||
|
"os-browserify": "^0.3.0",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"stream-browserify": "^3.0.0",
|
||||||
|
"stream-http": "^3.2.0",
|
||||||
|
"svelte": "^3.31.2",
|
||||||
|
"svelte-check": "^1.0.46",
|
||||||
|
"svelte-loader": "^3.0.0",
|
||||||
|
"svelte-preprocess": "^4.3.0",
|
||||||
|
"ts-loader": "^8.0.4",
|
||||||
|
"tslib": "^2.0.1",
|
||||||
|
"typescript": "^4.0.3",
|
||||||
|
"webpack": "^5.16.0",
|
||||||
|
"webpack-cli": "^4.4.0",
|
||||||
|
"webpack-dev-server": "^3.11.2"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "cross-env NODE_ENV=production webpack",
|
||||||
|
"dev": "webpack serve --content-base public",
|
||||||
|
"validate": "svelte-check"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@portis/web3": "^4.0.6",
|
||||||
|
"@spruceid/siwe-web3modal": "^0.1.5",
|
||||||
|
"@toruslabs/torus-embed": "^1.18.3",
|
||||||
|
"@walletconnect/web3-provider": "^1.6.6",
|
||||||
|
"fortmatic": "^2.2.1",
|
||||||
|
"walletlink": "^2.2.8"
|
||||||
|
}
|
||||||
|
}
|
118
js/ui/src/App.svelte
Normal file
118
js/ui/src/App.svelte
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Portis from "@portis/web3";
|
||||||
|
import { Client, SiweSession } from "@spruceid/siwe-web3modal";
|
||||||
|
import Torus from "@toruslabs/torus-embed";
|
||||||
|
import WalletConnectProvider from "@walletconnect/web3-provider";
|
||||||
|
import Fortmatic from "fortmatic";
|
||||||
|
import WalletLink from "walletlink";
|
||||||
|
|
||||||
|
// TODO: REMOVE DEFAULTS:
|
||||||
|
// main.ts will parse the params from the server
|
||||||
|
export let domain: string;
|
||||||
|
export let nonce: string;
|
||||||
|
export let redirect: string;
|
||||||
|
export let state: string;
|
||||||
|
export let oidc_nonce: string;
|
||||||
|
|
||||||
|
let uri: string = window.location.href.split("?")[0];
|
||||||
|
|
||||||
|
// Could be exposed in the future.
|
||||||
|
export let useENS: boolean = true;
|
||||||
|
|
||||||
|
$: status = "Not Logged In";
|
||||||
|
|
||||||
|
let client = new Client({
|
||||||
|
session: {
|
||||||
|
domain,
|
||||||
|
uri,
|
||||||
|
useENS,
|
||||||
|
version: "1",
|
||||||
|
// TODO: Vet this as the default statement.
|
||||||
|
statement: "Sign-In With Ethereum OpenID-Connect",
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
theme: "dark",
|
||||||
|
providerOptions: {
|
||||||
|
walletconnect: {
|
||||||
|
package: WalletConnectProvider,
|
||||||
|
options: {
|
||||||
|
infuraId: process.env.INFURA_ID,
|
||||||
|
pollingInterval: 100000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
torus: {
|
||||||
|
package: Torus,
|
||||||
|
},
|
||||||
|
portis: {
|
||||||
|
package: Portis,
|
||||||
|
options: {
|
||||||
|
id: process.env.PORTIS_ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fortmatic: {
|
||||||
|
package: Fortmatic,
|
||||||
|
options: {
|
||||||
|
key: process.env.FORTMATIC_KEY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"custom-coinbase": {
|
||||||
|
display: {
|
||||||
|
logo: "img/coinbase.svg",
|
||||||
|
name: "Coinbase",
|
||||||
|
description: "Scan with WalletLink to connect",
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
appName: "Sign-In with Ethereum",
|
||||||
|
networkUrl: `https://mainnet.infura.io/v3/${process.env.INFURA_ID}`,
|
||||||
|
chainId: 1,
|
||||||
|
darkMode: false,
|
||||||
|
},
|
||||||
|
package: WalletLink,
|
||||||
|
connector: async (_, options) => {
|
||||||
|
const { appName, networkUrl, chainId, darkMode } =
|
||||||
|
options;
|
||||||
|
const walletLink = new WalletLink({
|
||||||
|
appName,
|
||||||
|
darkMode,
|
||||||
|
});
|
||||||
|
const provider = walletLink.makeWeb3Provider(
|
||||||
|
networkUrl,
|
||||||
|
chainId
|
||||||
|
);
|
||||||
|
await provider.enable();
|
||||||
|
return provider;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let oidc_nonce_param = "";
|
||||||
|
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)}${encodeURI(oidc_nonce_param)}`);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div>
|
||||||
|
<h2>Sign-In With Ethereum</h2>
|
||||||
|
<p>{status}</p>
|
||||||
|
<!-- TODO: Add copy / info about who is requesting here. -->
|
||||||
|
<button
|
||||||
|
on:click={() => {
|
||||||
|
client.signIn(nonce).catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
63
js/ui/src/global.css
Normal file
63
js/ui/src/global.css
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
html, body {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgb(0,100,200);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: rgb(0,80,160);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, button, select, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
-webkit-padding: 0.4em 0;
|
||||||
|
padding: 0.4em;
|
||||||
|
margin: 0 0 0.5em 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: #333;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:not(:disabled):active {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus {
|
||||||
|
border-color: #666;
|
||||||
|
}
|
18
js/ui/src/main.ts
Normal file
18
js/ui/src/main.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import './global.css';
|
||||||
|
|
||||||
|
import App from './App.svelte';
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
domain: params.get('domain'),
|
||||||
|
nonce: params.get('nonce'),
|
||||||
|
redirect: params.get('redirect_uri'),
|
||||||
|
state: params.get('state'),
|
||||||
|
oidc_nonce: params.get('oidc_nonce')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
5
js/ui/tsconfig.json
Normal file
5
js/ui/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
|
"include": ["src/**/*", "src/node_modules/**/*"],
|
||||||
|
"exclude": ["node_modules/*", "__sapper__/*", "static/*"]
|
||||||
|
}
|
91
js/ui/webpack.config.js
Normal file
91
js/ui/webpack.config.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||||
|
const path = require('path');
|
||||||
|
const sveltePreprocess = require('svelte-preprocess');
|
||||||
|
const Dotenv = require('dotenv-webpack');
|
||||||
|
const webpack = require('webpack');
|
||||||
|
|
||||||
|
const mode = process.env.NODE_ENV || 'development';
|
||||||
|
const prod = mode === 'production';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: {
|
||||||
|
'build/bundle': ['./src/main.ts']
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
svelte: path.dirname(require.resolve('svelte/package.json'))
|
||||||
|
},
|
||||||
|
extensions: ['.mjs', '.js', '.ts', '.svelte'],
|
||||||
|
mainFields: ['svelte', 'browser', 'module', 'main'],
|
||||||
|
fallback: {
|
||||||
|
assert: require.resolve("assert"),
|
||||||
|
buffer: require.resolve('buffer/'),
|
||||||
|
crypto: require.resolve('crypto-browserify'),
|
||||||
|
fs: false,
|
||||||
|
http: require.resolve('stream-http'),
|
||||||
|
https: require.resolve('https-browserify'),
|
||||||
|
os: require.resolve('os-browserify/browser'),
|
||||||
|
path: false,
|
||||||
|
process: require.resolve('process/browser'),
|
||||||
|
stream: require.resolve('stream-browserify'),
|
||||||
|
// util: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
path: path.join(__dirname, '../../static'),
|
||||||
|
filename: '[name].js',
|
||||||
|
chunkFilename: '[name].[id].js'
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.ts$/,
|
||||||
|
loader: 'ts-loader',
|
||||||
|
exclude: /node_modules/
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.svelte$/,
|
||||||
|
use: {
|
||||||
|
loader: 'svelte-loader',
|
||||||
|
options: {
|
||||||
|
compilerOptions: {
|
||||||
|
dev: !prod
|
||||||
|
},
|
||||||
|
emitCss: prod,
|
||||||
|
hotReload: !prod,
|
||||||
|
preprocess: sveltePreprocess({ sourceMap: !prod })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: [
|
||||||
|
MiniCssExtractPlugin.loader,
|
||||||
|
'css-loader'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// required to prevent errors from Svelte on Webpack 5+
|
||||||
|
test: /node_modules\/svelte\/.*\.mjs$/,
|
||||||
|
resolve: {
|
||||||
|
fullySpecified: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
plugins: [
|
||||||
|
new webpack.ProvidePlugin({
|
||||||
|
Buffer: ["buffer", "Buffer"],
|
||||||
|
process: path.resolve(path.join(__dirname, "node_modules/process/browser")),
|
||||||
|
}),
|
||||||
|
new MiniCssExtractPlugin({
|
||||||
|
filename: '[name].css'
|
||||||
|
}),
|
||||||
|
new Dotenv(),
|
||||||
|
],
|
||||||
|
devtool: prod ? false : 'source-map',
|
||||||
|
devServer: {
|
||||||
|
hot: true
|
||||||
|
}
|
||||||
|
};
|
0
siwe-oidc.toml
Normal file
0
siwe-oidc.toml
Normal file
32
src/config.rs
Normal file
32
src/config.rs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
net::{IpAddr, Ipv4Addr},
|
||||||
|
};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub address: IpAddr,
|
||||||
|
pub port: u16,
|
||||||
|
pub base_url: Url,
|
||||||
|
pub rsa_pem: Option<String>,
|
||||||
|
pub redis_url: Url,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
address: Ipv4Addr::new(127, 0, 0, 1).into(),
|
||||||
|
port: 8000,
|
||||||
|
base_url: Url::parse("http://127.0.0.1:8000").unwrap(),
|
||||||
|
rsa_pem: None,
|
||||||
|
redis_url: Url::parse("redis://localhost").unwrap(),
|
||||||
|
default_clients: HashMap::default(),
|
||||||
|
require_secret: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
707
src/main.rs
Normal file
707
src/main.rs
Normal file
@ -0,0 +1,707 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use async_redis_session::RedisSessionStore;
|
||||||
|
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_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, EmptyAdditionalClaims,
|
||||||
|
EmptyAdditionalProviderMetadata, EmptyExtraTokenFields, IssuerUrl, JsonWebKeyId,
|
||||||
|
JsonWebKeySetUrl, Nonce, PrivateSigningKey, RedirectUrl, RegistrationUrl, ResponseTypes, Scope,
|
||||||
|
StandardClaims, SubjectIdentifier, TokenUrl, UserInfoUrl,
|
||||||
|
};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
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;
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod session;
|
||||||
|
|
||||||
|
use session::*;
|
||||||
|
|
||||||
|
const KID: &str = "key1";
|
||||||
|
const KV_CLIENT_PREFIX: &str = "clients";
|
||||||
|
const ENTRY_LIFETIME: usize = 60 * 60 * 24 * 2;
|
||||||
|
|
||||||
|
type ConnectionPool = Pool<RedisConnectionManager>;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CustomError {
|
||||||
|
#[error("{0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
#[error("{0}")]
|
||||||
|
Unauthorized(String),
|
||||||
|
#[error(transparent)]
|
||||||
|
Other(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for CustomError {
|
||||||
|
type Body = Full<Bytes>;
|
||||||
|
type BodyError = Infallible;
|
||||||
|
|
||||||
|
fn into_response(self) -> Response<Self::Body> {
|
||||||
|
match self {
|
||||||
|
CustomError::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||||
|
CustomError::Unauthorized(_) => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||||
|
CustomError::Other(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||||
|
}
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn jwk_set(
|
||||||
|
Extension(private_key): Extension<RsaPrivateKey>,
|
||||||
|
) -> Result<Json<CoreJsonWebKeySet>, CustomError> {
|
||||||
|
let pem = private_key
|
||||||
|
.to_pkcs1_pem()
|
||||||
|
.map_err(|e| anyhow!("Failed to serialise key as PEM: {}", e))?;
|
||||||
|
let jwks = CoreJsonWebKeySet::new(vec![CoreRsaPrivateSigningKey::from_pem(
|
||||||
|
&pem,
|
||||||
|
Some(JsonWebKeyId::new(KID.to_string())),
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!("Invalid RSA private key: {}", e))?
|
||||||
|
.as_verification_key()]);
|
||||||
|
Ok(jwks.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn provider_metadata(
|
||||||
|
Extension(config): Extension<config::Config>,
|
||||||
|
) -> Result<Json<CoreProviderMetadata>, CustomError> {
|
||||||
|
let pm = CoreProviderMetadata::new(
|
||||||
|
IssuerUrl::from_url(config.base_url.clone()),
|
||||||
|
AuthUrl::from_url(
|
||||||
|
config
|
||||||
|
.base_url
|
||||||
|
.join("authorize")
|
||||||
|
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
|
||||||
|
),
|
||||||
|
JsonWebKeySetUrl::from_url(
|
||||||
|
config
|
||||||
|
.base_url
|
||||||
|
.join("jwk")
|
||||||
|
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
|
||||||
|
),
|
||||||
|
vec![
|
||||||
|
ResponseTypes::new(vec![CoreResponseType::Code]),
|
||||||
|
ResponseTypes::new(vec![CoreResponseType::Token, CoreResponseType::IdToken]),
|
||||||
|
],
|
||||||
|
vec![CoreSubjectIdentifierType::Pairwise],
|
||||||
|
vec![CoreJwsSigningAlgorithm::RsaSsaPssSha256],
|
||||||
|
EmptyAdditionalProviderMetadata {},
|
||||||
|
)
|
||||||
|
.set_token_endpoint(Some(TokenUrl::from_url(
|
||||||
|
config
|
||||||
|
.base_url
|
||||||
|
.join("token")
|
||||||
|
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
|
||||||
|
)))
|
||||||
|
.set_userinfo_endpoint(Some(UserInfoUrl::from_url(
|
||||||
|
config
|
||||||
|
.base_url
|
||||||
|
.join("userinfo")
|
||||||
|
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
|
||||||
|
)))
|
||||||
|
.set_scopes_supported(Some(vec![
|
||||||
|
Scope::new("openid".to_string()),
|
||||||
|
// Scope::new("email".to_string()),
|
||||||
|
// Scope::new("profile".to_string()),
|
||||||
|
]))
|
||||||
|
.set_claims_supported(Some(vec![
|
||||||
|
CoreClaimName::new("sub".to_string()),
|
||||||
|
CoreClaimName::new("aud".to_string()),
|
||||||
|
// CoreClaimName::new("email".to_string()),
|
||||||
|
// CoreClaimName::new("email_verified".to_string()),
|
||||||
|
CoreClaimName::new("exp".to_string()),
|
||||||
|
CoreClaimName::new("iat".to_string()),
|
||||||
|
CoreClaimName::new("iss".to_string()),
|
||||||
|
// CoreClaimName::new("name".to_string()),
|
||||||
|
// CoreClaimName::new("given_name".to_string()),
|
||||||
|
// CoreClaimName::new("family_name".to_string()),
|
||||||
|
// CoreClaimName::new("picture".to_string()),
|
||||||
|
// CoreClaimName::new("locale".to_string()),
|
||||||
|
]))
|
||||||
|
.set_registration_endpoint(Some(RegistrationUrl::from_url(
|
||||||
|
config
|
||||||
|
.base_url
|
||||||
|
.join("register")
|
||||||
|
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
|
||||||
|
)))
|
||||||
|
.set_token_endpoint_auth_methods_supported(Some(vec![CoreClientAuthMethod::ClientSecretPost]));
|
||||||
|
|
||||||
|
Ok(pm.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TokenForm {
|
||||||
|
code: String,
|
||||||
|
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))
|
||||||
|
.await
|
||||||
|
.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())
|
||||||
|
.await
|
||||||
|
.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,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("Failed to set kv: {}", e))?;
|
||||||
|
|
||||||
|
let access_token = AccessToken::new(form.code.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,
|
||||||
|
nonce: Option<Nonce>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO handle `registration` parameter
|
||||||
|
async fn authorize(
|
||||||
|
session: UserSessionFromSession,
|
||||||
|
params: Query<AuthorizeParams>,
|
||||||
|
// Extension(private_key): Extension<RsaPrivateKey>,
|
||||||
|
) -> Result<(HeaderMap, Redirect), CustomError> {
|
||||||
|
// TODO: Enforce Client Registration
|
||||||
|
// let d = std::str::from_utf8(
|
||||||
|
// &jwk.decrypt(
|
||||||
|
// PaddingScheme::new_pkcs1v15_encrypt(),
|
||||||
|
// ¶ms.client_id.as_bytes(),
|
||||||
|
// )
|
||||||
|
// .map_err(|e| anyhow!("Failed to decrypt client id: {}", e))?,
|
||||||
|
// )
|
||||||
|
// .map_err(|e| anyhow!("Failed to decrypt client id: {}", e))?
|
||||||
|
// if d != params.redirect_uri.as_str() {
|
||||||
|
// return Err(anyhow!("Client id not composed of redirect url"));
|
||||||
|
// };
|
||||||
|
|
||||||
|
if params.scope != Scope::new("openid".to_string()) {
|
||||||
|
Err(anyhow!("Scope not supported"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (nonce, headers) = match session {
|
||||||
|
UserSessionFromSession::FoundUserSession(nonce) => (nonce, HeaderMap::new()),
|
||||||
|
UserSessionFromSession::InvalidUserSession(cookie) => {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::SET_COOKIE, cookie);
|
||||||
|
return Ok((
|
||||||
|
headers,
|
||||||
|
Redirect::to(
|
||||||
|
format!(
|
||||||
|
"/authorize?client_id={}&redirect_uri={}&scope={}&response_type={}&state={}{}",
|
||||||
|
¶ms.0.client_id,
|
||||||
|
¶ms.0.redirect_uri.to_string(),
|
||||||
|
¶ms.0.scope.to_string(),
|
||||||
|
¶ms.0.response_type.as_ref(),
|
||||||
|
¶ms.0.state,
|
||||||
|
¶ms.0.nonce.map(|n| format!("&nonce={}", n.secret())).unwrap_or(String::new())
|
||||||
|
)
|
||||||
|
.to_string()
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
UserSessionFromSession::CreatedFreshUserSession { header, nonce } => {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::SET_COOKIE, header);
|
||||||
|
(nonce, headers)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let domain = params.redirect_uri.url().host().unwrap();
|
||||||
|
let oidc_nonce_param = if let Some(n) = ¶ms.nonce {
|
||||||
|
format!("&oidc_nonce={}", n.secret())
|
||||||
|
} else {
|
||||||
|
"".to_string()
|
||||||
|
};
|
||||||
|
Ok((
|
||||||
|
headers,
|
||||||
|
Redirect::to(
|
||||||
|
format!(
|
||||||
|
"/?nonce={}&domain={}&redirect_uri={}&state={}{}",
|
||||||
|
nonce,
|
||||||
|
domain,
|
||||||
|
params.redirect_uri.to_string(),
|
||||||
|
params.state,
|
||||||
|
oidc_nonce_param
|
||||||
|
)
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct SiweCookie {
|
||||||
|
message: Web3ModalMessage,
|
||||||
|
signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Web3ModalMessage {
|
||||||
|
pub domain: String,
|
||||||
|
pub address: String,
|
||||||
|
pub statement: String,
|
||||||
|
pub uri: String,
|
||||||
|
pub version: String,
|
||||||
|
pub chain_id: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub issued_at: String,
|
||||||
|
pub expiration_time: Option<String>,
|
||||||
|
pub not_before: Option<String>,
|
||||||
|
pub request_id: Option<String>,
|
||||||
|
pub resources: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Web3ModalMessage {
|
||||||
|
pub fn to_eip4361_message(&self) -> Result<Message> {
|
||||||
|
let mut next_resources: Vec<UriString> = Vec::new();
|
||||||
|
match &self.resources {
|
||||||
|
Some(resources) => {
|
||||||
|
for resource in resources {
|
||||||
|
let x = UriString::from_str(resource)?;
|
||||||
|
next_resources.push(x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Message {
|
||||||
|
domain: self.domain.clone().try_into()?,
|
||||||
|
address: <[u8; 20]>::from_hex(self.address.chars().skip(2).collect::<String>())?,
|
||||||
|
statement: self.statement.to_string(),
|
||||||
|
uri: UriAbsoluteString::from_str(&self.uri)?,
|
||||||
|
version: Version::from_str(&self.version)?,
|
||||||
|
chain_id: self.chain_id.to_string(),
|
||||||
|
nonce: self.nonce.to_string(),
|
||||||
|
issued_at: self.issued_at.to_string(),
|
||||||
|
expiration_time: self.expiration_time.clone(),
|
||||||
|
not_before: self.not_before.clone(),
|
||||||
|
request_id: self.request_id.clone(),
|
||||||
|
resources: next_resources,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct CodeEntry {
|
||||||
|
exchange_count: usize,
|
||||||
|
address: String,
|
||||||
|
nonce: Option<Nonce>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SignInParams {
|
||||||
|
redirect_uri: RedirectUrl,
|
||||||
|
state: String,
|
||||||
|
oidc_nonce: Option<Nonce>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sign_in(
|
||||||
|
session: UserSessionFromSession,
|
||||||
|
params: Query<SignInParams>,
|
||||||
|
TypedHeader(cookies): TypedHeader<headers::Cookie>,
|
||||||
|
Extension(pool): Extension<ConnectionPool>,
|
||||||
|
) -> Result<(HeaderMap, Redirect), CustomError> {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
let siwe_cookie: SiweCookie = match cookies.get("siwe") {
|
||||||
|
Some(c) => serde_json::from_str(
|
||||||
|
&decode(c).map_err(|e| anyhow!("Could not decode siwe cookie: {}", e))?,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!("Could not deserialize siwe cookie: {}", e))?,
|
||||||
|
None => Err(anyhow!("No `siwe` cookie"))?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (nonce, headers) = match session {
|
||||||
|
UserSessionFromSession::FoundUserSession(nonce) => (nonce, HeaderMap::new()),
|
||||||
|
UserSessionFromSession::InvalidUserSession(header) => {
|
||||||
|
headers.insert(header::SET_COOKIE, header);
|
||||||
|
return Ok((
|
||||||
|
headers,
|
||||||
|
Redirect::to(
|
||||||
|
format!(
|
||||||
|
"/authorize?client_id={}&redirect_uri={}&scope=openid&response_type=code&state={}",
|
||||||
|
¶ms.0.redirect_uri.to_string(),
|
||||||
|
¶ms.0.redirect_uri.to_string(),
|
||||||
|
¶ms.0.state,
|
||||||
|
)
|
||||||
|
.to_string()
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
UserSessionFromSession::CreatedFreshUserSession { .. } => {
|
||||||
|
return Ok((
|
||||||
|
headers,
|
||||||
|
Redirect::to(
|
||||||
|
format!(
|
||||||
|
"/authorize?client_id={}&redirect_uri={}&scope=openid&response_type=code&state={}",
|
||||||
|
¶ms.0.redirect_uri.to_string(),
|
||||||
|
¶ms.0.redirect_uri.to_string(),
|
||||||
|
¶ms.0.state,
|
||||||
|
)
|
||||||
|
.to_string()
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let signature = match <[u8; 65]>::from_hex(
|
||||||
|
siwe_cookie
|
||||||
|
.signature
|
||||||
|
.chars()
|
||||||
|
.skip(2)
|
||||||
|
.take(130)
|
||||||
|
.collect::<String>()
|
||||||
|
.clone(),
|
||||||
|
) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => Err(CustomError::BadRequest(format!("Bad signature: {}", e)))?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = siwe_cookie
|
||||||
|
.message
|
||||||
|
.to_eip4361_message()
|
||||||
|
.map_err(|e| anyhow!("Failed to serialise message: {}", e))?;
|
||||||
|
info!("{}", message);
|
||||||
|
message
|
||||||
|
.verify_eip191(signature)
|
||||||
|
.map_err(|e| anyhow!("Failed signature validation: {}", e))?;
|
||||||
|
|
||||||
|
let domain = params.redirect_uri.url().host().unwrap();
|
||||||
|
if domain.to_string() != siwe_cookie.message.domain {
|
||||||
|
Err(anyhow!("Conflicting domains in message and redirect"))?
|
||||||
|
}
|
||||||
|
if nonce != siwe_cookie.message.nonce {
|
||||||
|
Err(anyhow!("Conflicting nonces in message and session"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
let code_entry = CodeEntry {
|
||||||
|
address: siwe_cookie.message.address,
|
||||||
|
nonce: params.oidc_nonce.clone(),
|
||||||
|
exchange_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let code = Uuid::new_v4();
|
||||||
|
let mut conn = pool
|
||||||
|
.get()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
|
||||||
|
conn.set_ex(
|
||||||
|
code.to_string(),
|
||||||
|
hex::encode(
|
||||||
|
bincode::serialize(&code_entry)
|
||||||
|
.map_err(|e| anyhow!("Failed to serialise code: {}", e))?,
|
||||||
|
),
|
||||||
|
ENTRY_LIFETIME,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("Failed to set kv: {}", e))?;
|
||||||
|
|
||||||
|
let mut url = params.redirect_uri.url().clone();
|
||||||
|
url.query_pairs_mut().append_pair("code", &code.to_string());
|
||||||
|
url.query_pairs_mut().append_pair("state", ¶ms.state);
|
||||||
|
Ok((
|
||||||
|
headers,
|
||||||
|
Redirect::to(
|
||||||
|
url.as_str()
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
// TODO clear session
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register(
|
||||||
|
extract::Json(payload): extract::Json<CoreClientMetadata>,
|
||||||
|
Extension(pool): Extension<ConnectionPool>,
|
||||||
|
) -> Result<Json<CoreClientRegistrationResponse>, CustomError> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let secret = Uuid::new_v4();
|
||||||
|
|
||||||
|
let mut conn = pool
|
||||||
|
.get()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
|
||||||
|
conn.set(format!("{}/{}", KV_CLIENT_PREFIX, id), secret.to_string())
|
||||||
|
.await
|
||||||
|
.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(),
|
||||||
|
)
|
||||||
|
.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)
|
||||||
|
.await
|
||||||
|
.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())
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.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(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();
|
||||||
|
}
|
124
src/session.rs
Normal file
124
src/session.rs
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
use async_redis_session::RedisSessionStore;
|
||||||
|
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> {
|
||||||
|
let Extension(store) = match Extension::<RedisSessionStore>::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,
|
||||||
|
_ => {
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.1 KiB |
17
static/index.html
Normal file
17
static/index.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset='utf-8'>
|
||||||
|
<meta name='viewport' content='width=device-width,initial-scale=1'>
|
||||||
|
|
||||||
|
<title>Svelte app</title>
|
||||||
|
|
||||||
|
<link rel='icon' type='image/png' href='/favicon.png'>
|
||||||
|
<link rel='stylesheet' href='/build/bundle.css'>
|
||||||
|
|
||||||
|
<script defer src='/build/bundle.js'></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in New Issue
Block a user