├── .github └── workflows │ └── rust.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Cargo.toml ├── LICENSE ├── README.md ├── assets ├── favicon.ico └── pico.min.css ├── src ├── lib.rs ├── main.rs ├── oauth │ ├── database │ │ ├── clientmap.rs │ │ ├── mod.rs │ │ └── resource │ │ │ ├── client.rs │ │ │ ├── mod.rs │ │ │ └── user.rs │ ├── endpoint │ │ ├── extension.rs │ │ └── mod.rs │ ├── error.rs │ ├── mod.rs │ ├── models │ │ ├── client.rs │ │ └── mod.rs │ ├── primitives │ │ ├── authorizer.rs │ │ ├── issuer.rs │ │ ├── mod.rs │ │ ├── registrar.rs │ │ └── scopes.rs │ ├── routes │ │ ├── client.rs │ │ ├── mod.rs │ │ ├── oauth.rs │ │ ├── signin.rs │ │ ├── signout.rs │ │ └── signup.rs │ ├── scopes.rs │ ├── solicitor.rs │ ├── state.rs │ └── templates.rs ├── routes.rs └── state.rs ├── svelte-frontend ├── .eslintignore ├── .eslintrc.cjs ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── README.md ├── package-lock.json ├── package.json ├── src │ ├── app.d.ts │ ├── app.html │ ├── hooks.server.ts │ ├── lib │ │ ├── session.ts │ │ └── user.ts │ ├── routes │ │ ├── +layout.server.ts │ │ ├── +layout.svelte │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ ├── authorize │ │ │ ├── +server.ts │ │ │ └── store.js │ │ ├── login │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ ├── logout │ │ │ └── +page.server.ts │ │ ├── profile │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ └── protected │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ └── stores.ts ├── static │ ├── css │ │ └── pico.min.css │ └── favicon.png ├── svelte.config.js ├── tsconfig.json └── vite.config.ts ├── templates ├── authorize.html ├── base.html └── signin.html └── tests └── api ├── client.rs ├── helpers.rs ├── index.rs ├── main.rs ├── signin.rs ├── signout.rs ├── signup.rs └── user.rs /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | 14 | test: 15 | name: Test 16 | runs-on: ubuntu-latest 17 | # services: 18 | # postgres: 19 | # image: postgres:12 20 | # env: 21 | # POSTGRES_USER: postgres 22 | # POSTGRES_PASSWORD: password 23 | # ports: 24 | # - 5432:5432 25 | # env: 26 | # SQLX_VERSION: 0.6.2 27 | # SQLX_FEATURES: "rustls,postgres" 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v2 31 | - name: Cache dependencies 32 | id: cache-dependencies 33 | uses: actions/cache@v2 34 | with: 35 | path: | 36 | ~/.cargo/registry 37 | ~/.cargo/git 38 | target 39 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 40 | - name: Install stable toolchain 41 | uses: actions-rs/toolchain@v1 42 | with: 43 | profile: minimal 44 | toolchain: stable 45 | override: true 46 | 47 | # - name: Migrate database 48 | # run: | 49 | # echo sudo apt-get install libpq-dev -y 50 | # echo SKIP_DOCKER=true ./scripts/init_db.sh 51 | - name: Run cargo test 52 | uses: actions-rs/cargo@v1 53 | with: 54 | command: test 55 | 56 | fmt: 57 | name: Rustfmt 58 | runs-on: ubuntu-latest 59 | steps: 60 | - uses: actions/checkout@v2 61 | - uses: actions-rs/toolchain@v1 62 | with: 63 | toolchain: stable 64 | override: true 65 | components: rustfmt 66 | - uses: actions-rs/cargo@v1 67 | with: 68 | command: fmt 69 | args: --all -- --check 70 | 71 | clippy: 72 | name: Clippy 73 | runs-on: ubuntu-latest 74 | steps: 75 | - uses: actions/checkout@v2 76 | - uses: actions-rs/toolchain@v1 77 | with: 78 | toolchain: stable 79 | override: true 80 | components: clippy 81 | - uses: actions-rs/clippy-check@v1 82 | with: 83 | token: ${{ secrets.GITHUB_TOKEN }} 84 | args: -- -D warnings 85 | 86 | coverage: 87 | name: Code coverage 88 | runs-on: ubuntu-latest 89 | services: 90 | postgres: 91 | image: postgres:12 92 | env: 93 | POSTGRES_USER: postgres 94 | POSTGRES_PASSWORD: password 95 | ports: 96 | - 5432:5432 97 | mailhog: 98 | image: mailhog/mailhog:v1.0.1 99 | ports: 100 | - 1025:1025 101 | - 8025:8025 102 | redis: 103 | image: redis:7 104 | ports: 105 | - 6379:6379 106 | 107 | steps: 108 | - name: Checkout repository 109 | uses: actions/checkout@v2 110 | 111 | - name: Install stable toolchain 112 | uses: actions-rs/toolchain@v1 113 | with: 114 | toolchain: stable 115 | override: true 116 | 117 | - name: Run cargo-tarpaulin 118 | uses: actions-rs/tarpaulin@v0.1 119 | with: 120 | version: 0.22.0 121 | args: '--ignore-tests' 122 | 123 | - name: Upload to codecov.io 124 | uses: codecov/codecov-action@v3 125 | with: 126 | token: ${{ secrets.CODECOV_TOKEN }} 127 | fail_ci_if_error: true 128 | 129 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | # Coverage files 13 | /coverage/* 14 | 15 | # svelte frontend 16 | svelte-frontend/.DS_Store 17 | svelte-frontend/node_modules 18 | svelte-frontend/build 19 | svelte-frontend/.svelte-kit 20 | svelte-frontend/package 21 | svelte-frontend/.env 22 | svelte-frontend/.env.* 23 | svelte-frontend/!.env.example 24 | svelte-frontend/vite.config.js.timestamp-* 25 | svelte-frontend/vite.config.ts.timestamp-* 26 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - hooks: 3 | - id: commitizen 4 | stages: 5 | - commit-msg 6 | repo: https://github.com/commitizen-tools/commitizen 7 | rev: v2.24.0 8 | - hooks: 9 | - id: fmt 10 | - id: cargo-check 11 | - id: clippy 12 | repo: https://github.com/doublify/pre-commit-rust 13 | rev: v1.0 14 | - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook 15 | rev: v8.0.0 16 | hooks: 17 | - id: commitlint 18 | stages: [commit-msg] 19 | additional_dependencies: ["@commitlint/config-conventional"] -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axum-oauth" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | argon2 = { version = "0.5.0", features = ["std"] } 10 | askama = { version = "0.12.0", features = ["with-axum"] } 11 | askama_axum = "0.3.0" 12 | async-session = "3.0.0" 13 | async-trait = "0.1.66" 14 | axum = { version = "0.6.11", features = ["headers"] } 15 | axum-macros = "0.3.6" 16 | axum-sessions = "0.4.1" 17 | csrf = "0.4.1" 18 | futures = "0.3.27" 19 | json = "0.12.4" 20 | jsonwebtoken = "8.2.0" 21 | nanoid = "0.4.0" 22 | once_cell = "1.17.1" 23 | oxide-auth = "0.5" 24 | oxide-auth-async = "0.1.0" 25 | oxide-auth-axum = "0.3.0" 26 | pkce = "0.2.0" 27 | secrecy = "0.8.0" 28 | serde = { version = "1.0.156", features = ["derive"] } 29 | serde_json = "1.0.94" 30 | serde_urlencoded = "0.7.1" 31 | thiserror = "1.0.39" 32 | tokio = { version = "1.26.0", features = ["macros", "rt-multi-thread"] } 33 | tower-http = { version = "0.4.0", features = ["fs"] } 34 | tracing = "0.1.37" 35 | tracing-bunyan-formatter = "0.3.6" 36 | tracing-log = "0.1.3" 37 | tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } 38 | url = "2.3.1" 39 | 40 | [dev-dependencies] 41 | html-escape = "0.2.13" 42 | regex = "1.7.1" 43 | reqwest = { version = "0.11.14", features = ["cookies", "json"] } 44 | urlencoding = "2.1.2" 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2023, Michael Telahun Makonnen 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # axum-oauth 2 | ![Rust](https://github.com/mtelahun/axum-oauth/actions/workflows/rust.yml/badge.svg) 3 | [![codecov](https://codecov.io/gh/mtelahun/axum-oauth/branch/main/graph/badge.svg?token=A1P9I5E2LU)](https://codecov.io/gh/trevi-software/rhodos) 4 | [![License](https://img.shields.io/badge/License-BSD_2--Clause-orange.svg)](https://opensource.org/licenses/BSD-2-Clause) 5 | 6 | 7 | This is a demo application I created to help me understand authentication in rust using OAuth 2.0. Specifically, it shows how to protect your backend APIs using server-side OAuth. It uses the [oxide-auth](https://github.com/HeroicKatora/oxide-auth), 8 | [oxide-auth-async](https://github.com/HeroicKatora/oxide-auth/tree/master/oxide-auth-async), and 9 | [oxide-auth-axum](https://github.com/HeroicKatora/oxide-auth/tree/master/oxide-auth-axum) crates. 10 | The oxide-auth documentation is a bit sparse and it isn't immediately obvious how to go 11 | about implementing an authentication server with it so I created this demo. As a starting point I used the only example 12 | I could find of an app using Oxide-auth with 13 | the Axum web server: [tf-viewer](https://github.com/danielalvsaaker/tf-viewer/) by 14 | [@danielalvsaaker](https://github.com/danielalvsaaker). 15 | 16 | ## Current state 17 | This crate compiles and basically works. However, I haven't done any re-factoring. It is currently in 18 | the "just make it work" stage :grin:. 19 | 20 | ## Example App 21 | This example app shows a basic OAuth 2.0 authentication life-cycle for API access: 22 | 23 | **Note:** ~~I haven't yet implemented a front-end app to show this functionality fully in a browser. See tests for 24 | full life-cycle example.~~ This project now has a frontend built in [SvelteKit](https://kit.svelte.dev/) 25 | 26 | - User registration 27 | - Sign-in 28 | - Client registration (public and private) 29 | - Authorization (public and private) 30 | - Protected resource access 31 | - Sign-out 32 | 33 | ## Usage 34 | 1. Clone this repo and `cd` into its root 35 | 2. Run `cargo run` 36 | 3. Change directory into the front-end: `cd svelte-frontend` 37 | 4. Run `npm install` 38 | 5. Run `npm run dev` 39 | 6. Open your browse to http://localhost:5137 40 | 7. Click the button that says "Sign-in with OAuth" 41 | 8. Use the default username/password pair to sign-in to the backend: `bob/secret` 42 | 43 | * By default the front-end only asks for account:read permission 44 | 45 | ## Internals 46 | [HashMap](https://doc.rust-lang.org/std/collections/struct.HashMap.html) - in-memory implementation of a user database. Also used to create a separate client registration database called __**ClientMap**__. 47 | 48 | 49 | [async-session](https://docs.rs/async-session/latest/async_session/) - for session management (**TO BE REMOVED.** Session management doesn't belong in the backend). -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtelahun/axum-oauth/7bcac8eb220eb408413eea26f65164214b6c7565/assets/favicon.ico -------------------------------------------------------------------------------- /assets/pico.min.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8";/*! 2 | * Pico.css v1.5.7 (https://picocss.com) 3 | * Copyright 2019-2023 - Licensed under MIT 4 | */:root{--font-family:system-ui,-apple-system,"Segoe UI","Roboto","Ubuntu","Cantarell","Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--line-height:1.5;--font-weight:400;--font-size:16px;--border-radius:0.25rem;--border-width:1px;--outline-width:3px;--spacing:1rem;--typography-spacing-vertical:1.5rem;--block-spacing-vertical:calc(var(--spacing) * 2);--block-spacing-horizontal:var(--spacing);--grid-spacing-vertical:0;--grid-spacing-horizontal:var(--spacing);--form-element-spacing-vertical:0.75rem;--form-element-spacing-horizontal:1rem;--nav-element-spacing-vertical:1rem;--nav-element-spacing-horizontal:0.5rem;--nav-link-spacing-vertical:0.5rem;--nav-link-spacing-horizontal:0.5rem;--form-label-font-weight:var(--font-weight);--transition:0.2s ease-in-out;--modal-overlay-backdrop-filter:blur(0.25rem)}@media (min-width:576px){:root{--font-size:17px}}@media (min-width:768px){:root{--font-size:18px}}@media (min-width:992px){:root{--font-size:19px}}@media (min-width:1200px){:root{--font-size:20px}}@media (min-width:576px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 2.5)}}@media (min-width:768px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 3)}}@media (min-width:992px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 3.5)}}@media (min-width:1200px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 4)}}@media (min-width:576px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.25)}}@media (min-width:768px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.5)}}@media (min-width:992px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.75)}}@media (min-width:1200px){article{--block-spacing-horizontal:calc(var(--spacing) * 2)}}dialog>article{--block-spacing-vertical:calc(var(--spacing) * 2);--block-spacing-horizontal:var(--spacing)}@media (min-width:576px){dialog>article{--block-spacing-vertical:calc(var(--spacing) * 2.5);--block-spacing-horizontal:calc(var(--spacing) * 1.25)}}@media (min-width:768px){dialog>article{--block-spacing-vertical:calc(var(--spacing) * 3);--block-spacing-horizontal:calc(var(--spacing) * 1.5)}}a{--text-decoration:none}a.contrast,a.secondary{--text-decoration:underline}small{--font-size:0.875em}h1,h2,h3,h4,h5,h6{--font-weight:700}h1{--font-size:2rem;--typography-spacing-vertical:3rem}h2{--font-size:1.75rem;--typography-spacing-vertical:2.625rem}h3{--font-size:1.5rem;--typography-spacing-vertical:2.25rem}h4{--font-size:1.25rem;--typography-spacing-vertical:1.874rem}h5{--font-size:1.125rem;--typography-spacing-vertical:1.6875rem}[type=checkbox],[type=radio]{--border-width:2px}[type=checkbox][role=switch]{--border-width:3px}tfoot td,tfoot th,thead td,thead th{--border-width:3px}:not(thead,tfoot)>*>td{--font-size:0.875em}code,kbd,pre,samp{--font-family:"Menlo","Consolas","Roboto Mono","Ubuntu Monospace","Noto Mono","Oxygen Mono","Liberation Mono",monospace,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"}kbd{--font-weight:bolder}:root:not([data-theme=dark]),[data-theme=light]{--background-color:#fff;--color:hsl(205deg, 20%, 32%);--h1-color:hsl(205deg, 30%, 15%);--h2-color:#24333e;--h3-color:hsl(205deg, 25%, 23%);--h4-color:#374956;--h5-color:hsl(205deg, 20%, 32%);--h6-color:#4d606d;--muted-color:hsl(205deg, 10%, 50%);--muted-border-color:hsl(205deg, 20%, 94%);--primary:hsl(195deg, 85%, 41%);--primary-hover:hsl(195deg, 90%, 32%);--primary-focus:rgba(16, 149, 193, 0.125);--primary-inverse:#fff;--secondary:hsl(205deg, 15%, 41%);--secondary-hover:hsl(205deg, 20%, 32%);--secondary-focus:rgba(89, 107, 120, 0.125);--secondary-inverse:#fff;--contrast:hsl(205deg, 30%, 15%);--contrast-hover:#000;--contrast-focus:rgba(89, 107, 120, 0.125);--contrast-inverse:#fff;--mark-background-color:#fff2ca;--mark-color:#543a26;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:transparent;--form-element-border-color:hsl(205deg, 14%, 68%);--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:transparent;--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205deg, 18%, 86%);--form-element-disabled-border-color:hsl(205deg, 14%, 68%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#c62828;--form-element-invalid-active-border-color:#d32f2f;--form-element-invalid-focus-color:rgba(211, 47, 47, 0.125);--form-element-valid-border-color:#388e3c;--form-element-valid-active-border-color:#43a047;--form-element-valid-focus-color:rgba(67, 160, 71, 0.125);--switch-background-color:hsl(205deg, 16%, 77%);--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:hsl(205deg, 18%, 86%);--range-active-border-color:hsl(205deg, 16%, 77%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:#f6f8f9;--code-background-color:hsl(205deg, 20%, 94%);--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330deg, 40%, 50%);--code-property-color:hsl(185deg, 40%, 40%);--code-value-color:hsl(40deg, 20%, 50%);--code-comment-color:hsl(205deg, 14%, 68%);--accordion-border-color:var(--muted-border-color);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:var(--background-color);--card-border-color:var(--muted-border-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(27, 40, 50, 0.01698),0.0335rem 0.067rem 0.402rem rgba(27, 40, 50, 0.024),0.0625rem 0.125rem 0.75rem rgba(27, 40, 50, 0.03),0.1125rem 0.225rem 1.35rem rgba(27, 40, 50, 0.036),0.2085rem 0.417rem 2.502rem rgba(27, 40, 50, 0.04302),0.5rem 1rem 6rem rgba(27, 40, 50, 0.06),0 0 0 0.0625rem rgba(27, 40, 50, 0.015);--card-sectionning-background-color:#fbfbfc;--dropdown-background-color:#fbfbfc;--dropdown-border-color:#e1e6eb;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:hsl(205deg, 20%, 94%);--modal-overlay-background-color:rgba(213, 220, 226, 0.7);--progress-background-color:hsl(205deg, 18%, 86%);--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(115, 130, 140)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(198, 40, 40)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(56, 142, 60)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");color-scheme:light}@media only screen and (prefers-color-scheme:dark){:root:not([data-theme]){--background-color:#11191f;--color:hsl(205deg, 16%, 77%);--h1-color:hsl(205deg, 20%, 94%);--h2-color:#e1e6eb;--h3-color:hsl(205deg, 18%, 86%);--h4-color:#c8d1d8;--h5-color:hsl(205deg, 16%, 77%);--h6-color:#afbbc4;--muted-color:hsl(205deg, 10%, 50%);--muted-border-color:#1f2d38;--primary:hsl(195deg, 85%, 41%);--primary-hover:hsl(195deg, 80%, 50%);--primary-focus:rgba(16, 149, 193, 0.25);--primary-inverse:#fff;--secondary:hsl(205deg, 15%, 41%);--secondary-hover:hsl(205deg, 10%, 50%);--secondary-focus:rgba(115, 130, 140, 0.25);--secondary-inverse:#fff;--contrast:hsl(205deg, 20%, 94%);--contrast-hover:#fff;--contrast-focus:rgba(115, 130, 140, 0.25);--contrast-inverse:#000;--mark-background-color:#d1c284;--mark-color:#11191f;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:#11191f;--form-element-border-color:#374956;--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:var(--form-element-background-color);--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205deg, 25%, 23%);--form-element-disabled-border-color:hsl(205deg, 20%, 32%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#b71c1c;--form-element-invalid-active-border-color:#c62828;--form-element-invalid-focus-color:rgba(198, 40, 40, 0.25);--form-element-valid-border-color:#2e7d32;--form-element-valid-active-border-color:#388e3c;--form-element-valid-focus-color:rgba(56, 142, 60, 0.25);--switch-background-color:#374956;--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:#24333e;--range-active-border-color:hsl(205deg, 25%, 23%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:rgba(115, 130, 140, 0.05);--code-background-color:#18232c;--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330deg, 30%, 50%);--code-property-color:hsl(185deg, 30%, 50%);--code-value-color:hsl(40deg, 10%, 50%);--code-comment-color:#4d606d;--accordion-border-color:var(--muted-border-color);--accordion-active-summary-color:var(--primary);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:#141e26;--card-border-color:var(--card-background-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(0, 0, 0, 0.01698),0.0335rem 0.067rem 0.402rem rgba(0, 0, 0, 0.024),0.0625rem 0.125rem 0.75rem rgba(0, 0, 0, 0.03),0.1125rem 0.225rem 1.35rem rgba(0, 0, 0, 0.036),0.2085rem 0.417rem 2.502rem rgba(0, 0, 0, 0.04302),0.5rem 1rem 6rem rgba(0, 0, 0, 0.06),0 0 0 0.0625rem rgba(0, 0, 0, 0.015);--card-sectionning-background-color:#18232c;--dropdown-background-color:hsl(205deg, 30%, 15%);--dropdown-border-color:#24333e;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:rgba(36, 51, 62, 0.75);--modal-overlay-background-color:rgba(36, 51, 62, 0.8);--progress-background-color:#24333e;--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(0, 0, 0)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(115, 130, 140)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(183, 28, 28)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(46, 125, 50)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");color-scheme:dark}}[data-theme=dark]{--background-color:#11191f;--color:hsl(205deg, 16%, 77%);--h1-color:hsl(205deg, 20%, 94%);--h2-color:#e1e6eb;--h3-color:hsl(205deg, 18%, 86%);--h4-color:#c8d1d8;--h5-color:hsl(205deg, 16%, 77%);--h6-color:#afbbc4;--muted-color:hsl(205deg, 10%, 50%);--muted-border-color:#1f2d38;--primary:hsl(195deg, 85%, 41%);--primary-hover:hsl(195deg, 80%, 50%);--primary-focus:rgba(16, 149, 193, 0.25);--primary-inverse:#fff;--secondary:hsl(205deg, 15%, 41%);--secondary-hover:hsl(205deg, 10%, 50%);--secondary-focus:rgba(115, 130, 140, 0.25);--secondary-inverse:#fff;--contrast:hsl(205deg, 20%, 94%);--contrast-hover:#fff;--contrast-focus:rgba(115, 130, 140, 0.25);--contrast-inverse:#000;--mark-background-color:#d1c284;--mark-color:#11191f;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:#11191f;--form-element-border-color:#374956;--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:var(--form-element-background-color);--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205deg, 25%, 23%);--form-element-disabled-border-color:hsl(205deg, 20%, 32%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#b71c1c;--form-element-invalid-active-border-color:#c62828;--form-element-invalid-focus-color:rgba(198, 40, 40, 0.25);--form-element-valid-border-color:#2e7d32;--form-element-valid-active-border-color:#388e3c;--form-element-valid-focus-color:rgba(56, 142, 60, 0.25);--switch-background-color:#374956;--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:#24333e;--range-active-border-color:hsl(205deg, 25%, 23%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:rgba(115, 130, 140, 0.05);--code-background-color:#18232c;--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330deg, 30%, 50%);--code-property-color:hsl(185deg, 30%, 50%);--code-value-color:hsl(40deg, 10%, 50%);--code-comment-color:#4d606d;--accordion-border-color:var(--muted-border-color);--accordion-active-summary-color:var(--primary);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:#141e26;--card-border-color:var(--card-background-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(0, 0, 0, 0.01698),0.0335rem 0.067rem 0.402rem rgba(0, 0, 0, 0.024),0.0625rem 0.125rem 0.75rem rgba(0, 0, 0, 0.03),0.1125rem 0.225rem 1.35rem rgba(0, 0, 0, 0.036),0.2085rem 0.417rem 2.502rem rgba(0, 0, 0, 0.04302),0.5rem 1rem 6rem rgba(0, 0, 0, 0.06),0 0 0 0.0625rem rgba(0, 0, 0, 0.015);--card-sectionning-background-color:#18232c;--dropdown-background-color:hsl(205deg, 30%, 15%);--dropdown-border-color:#24333e;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:rgba(36, 51, 62, 0.75);--modal-overlay-background-color:rgba(36, 51, 62, 0.8);--progress-background-color:#24333e;--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(0, 0, 0)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(115, 130, 140)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(183, 28, 28)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(46, 125, 50)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");color-scheme:dark}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--background-color);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);line-height:var(--line-height);font-family:var(--font-family);text-rendering:optimizeLegibility;overflow-wrap:break-word;cursor:default;-moz-tab-size:4;-o-tab-size:4;tab-size:4}main{display:block}body{width:100%;margin:0}body>footer,body>header,body>main{width:100%;margin-right:auto;margin-left:auto;padding:var(--block-spacing-vertical) 0}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--spacing);padding-left:var(--spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:992px){.container{max-width:920px}}@media (min-width:1200px){.container{max-width:1130px}}section{margin-bottom:var(--block-spacing-vertical)}.grid{grid-column-gap:var(--grid-spacing-horizontal);grid-row-gap:var(--grid-spacing-vertical);display:grid;grid-template-columns:1fr;margin:0}@media (min-width:992px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}figure{display:block;margin:0;padding:0;overflow-x:auto}figure figcaption{padding:calc(var(--spacing) * .5) 0;color:var(--muted-color)}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,figure,form,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--typography-spacing-vertical);color:var(--color);font-style:normal;font-weight:var(--font-weight);font-size:var(--font-size)}[role=link],a{--color:var(--primary);--background-color:transparent;outline:0;background-color:var(--background-color);color:var(--color);-webkit-text-decoration:var(--text-decoration);text-decoration:var(--text-decoration);transition:background-color var(--transition),color var(--transition),box-shadow var(--transition),-webkit-text-decoration var(--transition);transition:background-color var(--transition),color var(--transition),text-decoration var(--transition),box-shadow var(--transition);transition:background-color var(--transition),color var(--transition),text-decoration var(--transition),box-shadow var(--transition),-webkit-text-decoration var(--transition)}[role=link]:is([aria-current],:hover,:active,:focus),a:is([aria-current],:hover,:active,:focus){--color:var(--primary-hover);--text-decoration:underline}[role=link]:focus,a:focus{--background-color:var(--primary-focus)}[role=link].secondary,a.secondary{--color:var(--secondary)}[role=link].secondary:is([aria-current],:hover,:active,:focus),a.secondary:is([aria-current],:hover,:active,:focus){--color:var(--secondary-hover)}[role=link].secondary:focus,a.secondary:focus{--background-color:var(--secondary-focus)}[role=link].contrast,a.contrast{--color:var(--contrast)}[role=link].contrast:is([aria-current],:hover,:active,:focus),a.contrast:is([aria-current],:hover,:active,:focus){--color:var(--contrast-hover)}[role=link].contrast:focus,a.contrast:focus{--background-color:var(--contrast-focus)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--typography-spacing-vertical);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);font-family:var(--font-family)}h1{--color:var(--h1-color)}h2{--color:var(--h2-color)}h3{--color:var(--h3-color)}h4{--color:var(--h4-color)}h5{--color:var(--h5-color)}h6{--color:var(--h6-color)}:where(address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--typography-spacing-vertical)}.headings,hgroup{margin-bottom:var(--typography-spacing-vertical)}.headings>*,hgroup>*{margin-bottom:0}.headings>:last-child,hgroup>:last-child{--color:var(--muted-color);--font-weight:unset;font-size:1rem;font-family:unset}p{margin-bottom:var(--typography-spacing-vertical)}small{font-size:var(--font-size)}:where(dl,ol,ul){padding-right:0;padding-left:var(--spacing);-webkit-padding-start:var(--spacing);padding-inline-start:var(--spacing);-webkit-padding-end:0;padding-inline-end:0}:where(dl,ol,ul) li{margin-bottom:calc(var(--typography-spacing-vertical) * .25)}:where(dl,ol,ul) :is(dl,ol,ul){margin:0;margin-top:calc(var(--typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--mark-background-color);color:var(--mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--typography-spacing-vertical) 0;padding:var(--spacing);border-right:none;border-left:.25rem solid var(--blockquote-border-color);-webkit-border-start:0.25rem solid var(--blockquote-border-color);border-inline-start:0.25rem solid var(--blockquote-border-color);-webkit-border-end:none;border-inline-end:none}blockquote footer{margin-top:calc(var(--typography-spacing-vertical) * .5);color:var(--blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--ins-color);text-decoration:none}del{color:var(--del-color)}::-moz-selection{background-color:var(--primary-focus)}::selection{background-color:var(--primary-focus)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:root){overflow:hidden}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}button{display:block;width:100%;margin-bottom:var(--spacing)}[role=button]{display:inline-block;text-decoration:none}[role=button],button,input[type=button],input[type=reset],input[type=submit]{--background-color:var(--primary);--border-color:var(--primary);--color:var(--primary-inverse);--box-shadow:var(--button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[role=button]:is([aria-current],:hover,:active,:focus),button:is([aria-current],:hover,:active,:focus),input[type=button]:is([aria-current],:hover,:active,:focus),input[type=reset]:is([aria-current],:hover,:active,:focus),input[type=submit]:is([aria-current],:hover,:active,:focus){--background-color:var(--primary-hover);--border-color:var(--primary-hover);--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--color:var(--primary-inverse)}[role=button]:focus,button:focus,input[type=button]:focus,input[type=reset]:focus,input[type=submit]:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--primary-focus)}:is(button,input[type=submit],input[type=button],[role=button]).secondary,input[type=reset]{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);cursor:pointer}:is(button,input[type=submit],input[type=button],[role=button]).secondary:is([aria-current],:hover,:active,:focus),input[type=reset]:is([aria-current],:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover);--color:var(--secondary-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).secondary:focus,input[type=reset]:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--secondary-focus)}:is(button,input[type=submit],input[type=button],[role=button]).contrast{--background-color:var(--contrast);--border-color:var(--contrast);--color:var(--contrast-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).contrast:is([aria-current],:hover,:active,:focus){--background-color:var(--contrast-hover);--border-color:var(--contrast-hover);--color:var(--contrast-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).contrast:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--contrast-focus)}:is(button,input[type=submit],input[type=button],[role=button]).outline,input[type=reset].outline{--background-color:transparent;--color:var(--primary)}:is(button,input[type=submit],input[type=button],[role=button]).outline:is([aria-current],:hover,:active,:focus),input[type=reset].outline:is([aria-current],:hover,:active,:focus){--background-color:transparent;--color:var(--primary-hover)}:is(button,input[type=submit],input[type=button],[role=button]).outline.secondary,input[type=reset].outline{--color:var(--secondary)}:is(button,input[type=submit],input[type=button],[role=button]).outline.secondary:is([aria-current],:hover,:active,:focus),input[type=reset].outline:is([aria-current],:hover,:active,:focus){--color:var(--secondary-hover)}:is(button,input[type=submit],input[type=button],[role=button]).outline.contrast{--color:var(--contrast)}:is(button,input[type=submit],input[type=button],[role=button]).outline.contrast:is([aria-current],:hover,:active,:focus){--color:var(--contrast-hover)}:where(button,[type=submit],[type=button],[type=reset],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]),a[role=button]:not([href]){opacity:.5;pointer-events:none}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--line-height) + var(--form-element-spacing-vertical) * 2 + var(--border-width) * 2)}fieldset{margin:0;margin-bottom:var(--spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--spacing) * .25);font-weight:var(--form-label-font-weight,var(--font-weight))}input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal)}input,select,textarea{--background-color:var(--form-element-background-color);--border-color:var(--form-element-border-color);--color:var(--form-element-color);--box-shadow:none;border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}:where(select,textarea):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--background-color:var(--form-element-active-background-color)}:where(select,textarea):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--border-color:var(--form-element-active-border-color)}input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus,select:focus,textarea:focus{--box-shadow:0 0 0 var(--outline-width) var(--form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],select[disabled],textarea[disabled]{--background-color:var(--form-element-disabled-background-color);--border-color:var(--form-element-disabled-border-color);opacity:var(--form-element-disabled-opacity);pointer-events:none}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week])[aria-invalid]{padding-right:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--form-element-spacing-horizontal);-webkit-padding-start:var(--form-element-spacing-horizontal)!important;padding-inline-start:var(--form-element-spacing-horizontal)!important;-webkit-padding-end:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;padding-inline-end:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week])[aria-invalid=false]{background-image:var(--icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week])[aria-invalid=true]{background-image:var(--icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--border-color:var(--form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--border-color:var(--form-element-valid-active-border-color)!important;--box-shadow:0 0 0 var(--outline-width) var(--form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--border-color:var(--form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--border-color:var(--form-element-invalid-active-border-color)!important;--box-shadow:0 0 0 var(--outline-width) var(--form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--form-element-spacing-horizontal) + 1.5rem);padding-left:var(--form-element-spacing-horizontal);-webkit-padding-start:var(--form-element-spacing-horizontal);padding-inline-start:var(--form-element-spacing-horizontal);-webkit-padding-end:calc(var(--form-element-spacing-horizontal) + 1.5rem);padding-inline-end:calc(var(--form-element-spacing-horizontal) + 1.5rem);background-image:var(--icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}:where(input,select,textarea,.grid)+small{display:block;width:100%;margin-top:calc(var(--spacing) * -.75);margin-bottom:var(--spacing);color:var(--muted-color)}label>:where(input,select,textarea){margin-top:calc(var(--spacing) * .25)}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-right:.375em;margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:.375em;margin-inline-end:.375em;border-width:var(--border-width);font-size:inherit;vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--background-color:var(--primary);--border-color:var(--primary);background-image:var(--icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-right:.375em;margin-bottom:0;cursor:pointer}[type=checkbox]:indeterminate{--background-color:var(--primary);--border-color:var(--primary);background-image:var(--icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--background-color:var(--primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--background-color:var(--switch-background-color);--border-color:var(--switch-background-color);--color:var(--switch-color);width:2.25em;height:1.25em;border:var(--border-width) solid var(--border-color);border-radius:1.25em;background-color:var(--background-color);line-height:1.25em}[type=checkbox][role=switch]:focus{--background-color:var(--switch-background-color);--border-color:var(--switch-background-color)}[type=checkbox][role=switch]:checked{--background-color:var(--switch-checked-background-color);--border-color:var(--switch-checked-background-color)}[type=checkbox][role=switch]:before{display:block;width:calc(1.25em - (var(--border-width) * 2));height:100%;border-radius:50%;background-color:var(--color);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:checked{background-image:none}[type=checkbox][role=switch]:checked::before{margin-left:calc(1.125em - var(--border-width));-webkit-margin-start:calc(1.125em - var(--border-width));margin-inline-start:calc(1.125em - var(--border-width))}[type=checkbox]:checked[aria-invalid=false],[type=checkbox][aria-invalid=false],[type=checkbox][role=switch]:checked[aria-invalid=false],[type=checkbox][role=switch][aria-invalid=false],[type=radio]:checked[aria-invalid=false],[type=radio][aria-invalid=false]{--border-color:var(--form-element-valid-border-color)}[type=checkbox]:checked[aria-invalid=true],[type=checkbox][aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=checkbox][role=switch][aria-invalid=true],[type=radio]:checked[aria-invalid=true],[type=radio][aria-invalid=true]{--border-color:var(--form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--icon-position:0.75rem;--icon-width:1rem;padding-right:calc(var(--icon-width) + var(--icon-position));background-image:var(--icon-date);background-position:center right var(--icon-position);background-size:var(--icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--icon-width);margin-right:calc(var(--icon-width) * -1);margin-left:var(--icon-position);opacity:0}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--color:var(--muted-color);padding:calc(var(--form-element-spacing-vertical) * .5) 0;border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::file-selector-button:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::-webkit-file-upload-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;-webkit-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-webkit-file-upload-button:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::-ms-browse{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;margin-inline-start:0;margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;-ms-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-ms-browse:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-webkit-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-moz-range-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-moz-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-ms-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-ms-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-moz-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-ms-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]:focus,[type=range]:hover{--range-border-color:var(--range-active-border-color);--range-thumb-color:var(--range-thumb-hover-color)}[type=range]:active{--range-thumb-color:var(--range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{-webkit-padding-start:calc(var(--form-element-spacing-horizontal) + 1.75rem);padding-inline-start:calc(var(--form-element-spacing-horizontal) + 1.75rem);border-radius:5rem;background-image:var(--icon-search);background-position:center left 1.125rem;background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{-webkit-padding-start:calc(var(--form-element-spacing-horizontal) + 1.75rem)!important;padding-inline-start:calc(var(--form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--icon-search),var(--icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--icon-search),var(--icon-invalid)}[type=search]::-webkit-search-cancel-button{-webkit-appearance:none;display:none}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--spacing)/ 2) var(--spacing);border-bottom:var(--border-width) solid var(--table-border-color);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--border-width) solid var(--table-border-color);border-bottom:0}table[role=grid] tbody tr:nth-child(odd){background-color:var(--table-row-stripped-background-color)}code,kbd,pre,samp{font-size:.875em;font-family:var(--font-family)}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre{border-radius:var(--border-radius);background:var(--code-background-color);color:var(--code-color);font-weight:var(--font-weight);line-height:initial}code,kbd{display:inline-block;padding:.375rem .5rem}pre{display:block;margin-bottom:var(--spacing);overflow-x:auto}pre>code{display:block;padding:var(--spacing);background:0 0;font-size:14px;line-height:var(--line-height)}code b{color:var(--code-tag-color);font-weight:var(--font-weight)}code i{color:var(--code-property-color);font-style:normal}code u{color:var(--code-value-color);text-decoration:none}code em{color:var(--code-comment-color);font-style:normal}kbd{background-color:var(--code-kbd-background-color);color:var(--code-kbd-color);vertical-align:baseline}hr{height:0;border:0;border-top:1px solid var(--muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}details{display:block;margin-bottom:var(--spacing);padding-bottom:var(--spacing);border-bottom:var(--border-width) solid var(--accordion-border-color)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--transition)}details summary:not([role]){color:var(--accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;-webkit-margin-start:calc(var(--spacing,1rem) * 0.5);margin-inline-start:calc(var(--spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--transition)}details summary:focus{outline:0}details summary:focus:not([role=button]){color:var(--accordion-active-summary-color)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--line-height,1.5));background-image:var(--icon-chevron-button)}details summary[role=button]:not(.outline).contrast::after{background-image:var(--icon-chevron-button-inverse)}details[open]>summary{margin-bottom:calc(var(--spacing))}details[open]>summary:not([role]):not(:focus){color:var(--accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin:var(--block-spacing-vertical) 0;padding:var(--block-spacing-vertical) var(--block-spacing-horizontal);border-radius:var(--border-radius);background:var(--card-background-color);box-shadow:var(--card-box-shadow)}article>footer,article>header{margin-right:calc(var(--block-spacing-horizontal) * -1);margin-left:calc(var(--block-spacing-horizontal) * -1);padding:calc(var(--block-spacing-vertical) * .66) var(--block-spacing-horizontal);background-color:var(--card-sectionning-background-color)}article>header{margin-top:calc(var(--block-spacing-vertical) * -1);margin-bottom:var(--block-spacing-vertical);border-bottom:var(--border-width) solid var(--card-border-color);border-top-right-radius:var(--border-radius);border-top-left-radius:var(--border-radius)}article>footer{margin-top:var(--block-spacing-vertical);margin-bottom:calc(var(--block-spacing-vertical) * -1);border-top:var(--border-width) solid var(--card-border-color);border-bottom-right-radius:var(--border-radius);border-bottom-left-radius:var(--border-radius)}:root{--scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:var(--spacing);border:0;-webkit-backdrop-filter:var(--modal-overlay-backdrop-filter);backdrop-filter:var(--modal-overlay-backdrop-filter);background-color:var(--modal-overlay-background-color);color:var(--color)}dialog article{max-height:calc(100vh - var(--spacing) * 2);overflow:auto}@media (min-width:576px){dialog article{max-width:510px}}@media (min-width:768px){dialog article{max-width:700px}}dialog article>footer,dialog article>header{padding:calc(var(--block-spacing-vertical) * .5) var(--block-spacing-horizontal)}dialog article>header .close{margin:0;margin-left:var(--spacing);float:right}dialog article>footer{text-align:right}dialog article>footer [role=button]{margin-bottom:0}dialog article>footer [role=button]:not(:first-of-type){margin-left:calc(var(--spacing) * .5)}dialog article p:last-of-type{margin:0}dialog article .close{display:block;width:1rem;height:1rem;margin-top:calc(var(--block-spacing-vertical) * -.5);margin-bottom:var(--typography-spacing-vertical);margin-left:auto;background-image:var(--icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;opacity:.5;transition:opacity var(--transition)}dialog article .close:is([aria-current],:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--nav-element-spacing-vertical) var(--nav-element-spacing-horizontal)}nav li>*{--spacing:0}nav :where(a,[role=link]){display:inline-block;margin:calc(var(--nav-link-spacing-vertical) * -1) calc(var(--nav-link-spacing-horizontal) * -1);padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal);border-radius:var(--border-radius);text-decoration:none}nav :where(a,[role=link]):is([aria-current],:hover,:active,:focus){text-decoration:none}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){-webkit-margin-start:var(--nav-link-spacing-horizontal);margin-inline-start:var(--nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{position:absolute;width:calc(var(--nav-link-spacing-horizontal) * 2);-webkit-margin-start:calc(var(--nav-link-spacing-horizontal)/ 2);margin-inline-start:calc(var(--nav-link-spacing-horizontal)/ 2);content:"/";color:var(--muted-color);text-align:center}nav[aria-label=breadcrumb] a[aria-current]{background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}nav [role=button]{margin-right:inherit;margin-left:inherit;padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal)}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--nav-element-spacing-vertical) * .5) var(--nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--spacing) * .5);overflow:hidden;border:0;border-radius:var(--border-radius);background-color:var(--progress-background-color);color:var(--progress-color)}progress::-webkit-progress-bar{border-radius:var(--border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--progress-color)}progress::-moz-progress-bar{background-color:var(--progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--progress-background-color) linear-gradient(to right,var(--progress-color) 30%,var(--progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}details[role=list],li[role=list]{position:relative}details[role=list] summary+ul,li[role=list]>ul{display:flex;z-index:99;position:absolute;top:auto;right:0;left:0;flex-direction:column;margin:0;padding:0;border:var(--border-width) solid var(--dropdown-border-color);border-radius:var(--border-radius);border-top-right-radius:0;border-top-left-radius:0;background-color:var(--dropdown-background-color);box-shadow:var(--card-box-shadow);color:var(--dropdown-color);white-space:nowrap}details[role=list] summary+ul li,li[role=list]>ul li{width:100%;margin-bottom:0;padding:calc(var(--form-element-spacing-vertical) * .5) var(--form-element-spacing-horizontal);list-style:none}details[role=list] summary+ul li:first-of-type,li[role=list]>ul li:first-of-type{margin-top:calc(var(--form-element-spacing-vertical) * .5)}details[role=list] summary+ul li:last-of-type,li[role=list]>ul li:last-of-type{margin-bottom:calc(var(--form-element-spacing-vertical) * .5)}details[role=list] summary+ul li a,li[role=list]>ul li a{display:block;margin:calc(var(--form-element-spacing-vertical) * -.5) calc(var(--form-element-spacing-horizontal) * -1);padding:calc(var(--form-element-spacing-vertical) * .5) var(--form-element-spacing-horizontal);overflow:hidden;color:var(--dropdown-color);text-decoration:none;text-overflow:ellipsis}details[role=list] summary+ul li a:hover,li[role=list]>ul li a:hover{background-color:var(--dropdown-hover-background-color)}details[role=list] summary::after,li[role=list]>a::after{display:block;width:1rem;height:calc(1rem * var(--line-height,1.5));-webkit-margin-start:0.5rem;margin-inline-start:.5rem;float:right;transform:rotate(0);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}details[role=list]{padding:0;border-bottom:none}details[role=list] summary{margin-bottom:0}details[role=list] summary:not([role]){height:calc(1rem * var(--line-height) + var(--form-element-spacing-vertical) * 2 + var(--border-width) * 2);padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal);border:var(--border-width) solid var(--form-element-border-color);border-radius:var(--border-radius);background-color:var(--form-element-background-color);color:var(--form-element-placeholder-color);line-height:inherit;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}details[role=list] summary:not([role]):active,details[role=list] summary:not([role]):focus{border-color:var(--form-element-active-border-color);background-color:var(--form-element-active-background-color)}details[role=list] summary:not([role]):focus{box-shadow:0 0 0 var(--outline-width) var(--form-element-focus-color)}details[role=list][open] summary{border-bottom-right-radius:0;border-bottom-left-radius:0}details[role=list][open] summary::before{display:block;z-index:1;position:fixed;top:0;right:0;bottom:0;left:0;background:0 0;content:"";cursor:default}nav details[role=list] summary,nav li[role=list] a{display:flex;direction:ltr}nav details[role=list] summary+ul,nav li[role=list]>ul{min-width:-moz-fit-content;min-width:fit-content;border-radius:var(--border-radius)}nav details[role=list] summary+ul li a,nav li[role=list]>ul li a{border-radius:0}nav details[role=list] summary,nav details[role=list] summary:not([role]){height:auto;padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal)}nav details[role=list][open] summary{border-radius:var(--border-radius)}nav details[role=list] summary+ul{margin-top:var(--outline-width);-webkit-margin-start:0;margin-inline-start:0}nav details[role=list] summary[role=link]{margin-bottom:calc(var(--nav-link-spacing-vertical) * -1);line-height:var(--line-height)}nav details[role=list] summary[role=link]+ul{margin-top:calc(var(--nav-link-spacing-vertical) + var(--outline-width));-webkit-margin-start:calc(var(--nav-link-spacing-horizontal) * -1);margin-inline-start:calc(var(--nav-link-spacing-horizontal) * -1)}li[role=list] a:active~ul,li[role=list] a:focus~ul,li[role=list]:hover>ul{display:flex}li[role=list]>ul{display:none;margin-top:calc(var(--nav-link-spacing-vertical) + var(--outline-width));-webkit-margin-start:calc(var(--nav-element-spacing-horizontal) - var(--nav-link-spacing-horizontal));margin-inline-start:calc(var(--nav-element-spacing-horizontal) - var(--nav-link-spacing-horizontal))}li[role=list]>a::after{background-image:var(--icon-chevron)}[aria-busy=true]{cursor:progress}[aria-busy=true]:not(input,select,textarea)::before{display:inline-block;width:1em;height:1em;border:.1875em solid currentColor;border-radius:1em;border-right-color:transparent;content:"";vertical-align:text-bottom;vertical-align:-.125em;animation:spinner .75s linear infinite;opacity:var(--loading-spinner-opacity)}[aria-busy=true]:not(input,select,textarea):not(:empty)::before{margin-right:calc(var(--spacing) * .5);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing) * .5);margin-inline-end:calc(var(--spacing) * .5)}[aria-busy=true]:not(input,select,textarea):empty{text-align:center}a[aria-busy=true],button[aria-busy=true],input[type=button][aria-busy=true],input[type=reset][aria-busy=true],input[type=submit][aria-busy=true]{pointer-events:none}@keyframes spinner{to{transform:rotate(360deg)}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--border-radius);background:var(--tooltip-background-color);content:attr(data-tooltip);color:var(--tooltip-color);font-style:normal;font-weight:var(--font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:hover::after,[data-tooltip]:hover::before,[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover [data-tooltip]:focus::after,[data-tooltip][data-placement=bottom]:hover [data-tooltip]:focus::before{animation-duration:.2s;animation-name:tooltip-slide-top}[data-tooltip]:hover::after,[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover [data-tooltip]:focus::after{animation-name:tooltip-caret-slide-top}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{animation-duration:.2s;animation-name:tooltip-slide-bottom}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{animation-name:tooltip-caret-slide-bottom}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{animation-duration:.2s;animation-name:tooltip-slide-left}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{animation-name:tooltip-caret-slide-left}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{animation-duration:.2s;animation-name:tooltip-slide-right}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{animation-name:tooltip-caret-slide-right}}@keyframes tooltip-slide-top{from{transform:translate(-50%,.75rem);opacity:0}to{transform:translate(-50%,-.25rem);opacity:1}}@keyframes tooltip-caret-slide-top{from{opacity:0}50%{transform:translate(-50%,-.25rem);opacity:0}to{transform:translate(-50%,0);opacity:1}}@keyframes tooltip-slide-bottom{from{transform:translate(-50%,-.75rem);opacity:0}to{transform:translate(-50%,.25rem);opacity:1}}@keyframes tooltip-caret-slide-bottom{from{opacity:0}50%{transform:translate(-50%,-.5rem);opacity:0}to{transform:translate(-50%,-.3rem);opacity:1}}@keyframes tooltip-slide-left{from{transform:translate(.75rem,-50%);opacity:0}to{transform:translate(-.25rem,-50%);opacity:1}}@keyframes tooltip-caret-slide-left{from{opacity:0}50%{transform:translate(.05rem,-50%);opacity:0}to{transform:translate(.3rem,-50%);opacity:1}}@keyframes tooltip-slide-right{from{transform:translate(-.75rem,-50%);opacity:0}to{transform:translate(.25rem,-50%);opacity:1}}@keyframes tooltip-caret-slide-right{from{opacity:0}50%{transform:translate(-.05rem,-50%);opacity:0}to{transform:translate(-.3rem,-50%);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} 5 | /*# sourceMappingURL=pico.min.css.map */ -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use async_session::MemoryStore; 2 | use axum::Router; 3 | use std::net::TcpListener; 4 | use tower_http::services::ServeDir; 5 | 6 | pub mod oauth; 7 | pub mod routes; 8 | pub mod state; 9 | 10 | use oauth::database::Database as AuthDB; 11 | use secrecy::Secret; 12 | use state::AppState; 13 | 14 | pub async fn build_service( 15 | bind_address: Option, 16 | server_port: u16, 17 | ) -> (Router, TcpListener) { 18 | let router = get_router().await; 19 | 20 | let mut addr = format!("0.0.0.0:{server_port}"); 21 | if bind_address.is_some() { 22 | addr = bind_address.unwrap(); 23 | } 24 | let listener = TcpListener::bind(addr) 25 | .map_err(|e| { 26 | eprintln!("unable to parse local address: {e}"); 27 | }) 28 | .unwrap(); 29 | 30 | (router, listener) 31 | } 32 | 33 | pub async fn serve(app: Router, listener: TcpListener) { 34 | axum::Server::from_tcp(listener) 35 | .map_err(|e| eprintln!("{e}")) 36 | .unwrap() 37 | .serve(app.into_make_service()) 38 | .await 39 | .unwrap(); 40 | } 41 | 42 | async fn get_router() -> Router { 43 | let mut auth_db = AuthDB::new(); 44 | let _ = auth_db 45 | .register_user("bob", Secret::from("secret".to_string()), "Robert") 46 | .await; 47 | let _ = auth_db 48 | .register_public_client( 49 | "LocalClient", 50 | "https://www.thunderclient.com/oauth/callback", 51 | "account::read", 52 | ) 53 | .await; 54 | let state = oauth::state::State::new(auth_db.clone()); 55 | let sessions = MemoryStore::new(); 56 | let state = AppState { 57 | sessions, 58 | state, 59 | database: auth_db, 60 | }; 61 | 62 | Router::new() 63 | .nest_service("/assets", ServeDir::new("assets")) 64 | .nest("/oauth", crate::oauth::routes::routes()) 65 | .nest("/api", routes::routes()) 66 | .with_state(state) 67 | } 68 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use axum::{http::StatusCode, response::IntoResponse, Json}; 2 | use axum_oauth::{build_service, serve}; 3 | use oxide_auth_axum::WebError; 4 | use tracing_subscriber::{prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt}; 5 | 6 | pub mod oauth; 7 | pub mod state; 8 | 9 | #[tokio::main] 10 | async fn main() { 11 | tracing_subscriber::registry() 12 | .with( 13 | tracing_subscriber::EnvFilter::try_from_default_env() 14 | .unwrap_or_else(|_| "axum_oauth=debug".into()), 15 | ) 16 | .with(tracing_subscriber::fmt::layer()) 17 | .init(); 18 | 19 | let (app, listener) = build_service(None, 3000).await; 20 | serve(app, listener).await; 21 | } 22 | 23 | #[derive(Debug)] 24 | enum AuthError { 25 | #[allow(dead_code)] 26 | WrongCredentials, 27 | MissingCredentials, 28 | InvalidToken, 29 | Unexecpected(String), 30 | } 31 | 32 | impl IntoResponse for AuthError { 33 | fn into_response(self) -> axum::response::Response { 34 | let (status, error_message) = match self { 35 | AuthError::WrongCredentials => (StatusCode::UNAUTHORIZED, "wrong credentials"), 36 | AuthError::MissingCredentials => (StatusCode::BAD_REQUEST, "missing credentials"), 37 | AuthError::InvalidToken => (StatusCode::BAD_REQUEST, "invalid token"), 38 | AuthError::Unexecpected(_) => { 39 | (StatusCode::INTERNAL_SERVER_ERROR, "unknown internal error") 40 | } 41 | }; 42 | let body = Json(serde_json::json!({ 43 | "error": error_message, 44 | })); 45 | (status, body).into_response() 46 | } 47 | } 48 | 49 | impl From for AuthError { 50 | fn from(err: WebError) -> Self { 51 | match err { 52 | WebError::Endpoint(_) => { 53 | AuthError::Unexecpected("internal authorization error".to_string()) 54 | } 55 | WebError::Header(h) => AuthError::Unexecpected(h.to_string()), 56 | WebError::Encoding => AuthError::MissingCredentials, 57 | WebError::Form => AuthError::MissingCredentials, 58 | WebError::Query => AuthError::MissingCredentials, 59 | WebError::Body => AuthError::MissingCredentials, 60 | WebError::Authorization => AuthError::InvalidToken, 61 | WebError::InternalError(opt) => match opt { 62 | Some(e) => AuthError::Unexecpected(e), 63 | None => AuthError::Unexecpected("unknown authentication error".to_string()), 64 | }, 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/oauth/database/clientmap.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, collections::HashMap}; 2 | 3 | use once_cell::sync::Lazy; 4 | use oxide_auth::{ 5 | endpoint::{PreGrant, Registrar}, 6 | primitives::{ 7 | registrar::{ 8 | Argon2, BoundClient, Client, ClientUrl, EncodedClient, PasswordPolicy, 9 | RegisteredClient, RegistrarError, 10 | }, 11 | scope::Scope, 12 | }, 13 | }; 14 | 15 | use crate::oauth::scopes; 16 | 17 | static DEFAULT_PASSWORD_POLICY: Lazy = Lazy::new(Argon2::default); 18 | 19 | #[derive(Default)] 20 | pub struct ClientMap { 21 | pub(crate) clients: HashMap, 22 | password_policy: Option>, 23 | } 24 | 25 | impl ClientMap { 26 | /// Create an empty map without any clients in it. 27 | pub fn new() -> ClientMap { 28 | ClientMap::default() 29 | } 30 | 31 | /// Insert or update the client record. 32 | pub fn register_client(&mut self, id: &str, name: &str, client: Client) { 33 | let id = id.to_owned(); 34 | let password_policy = Self::current_policy(&self.password_policy); 35 | let record = ClientRecord { 36 | id: id.clone(), 37 | name: name.to_owned(), 38 | encoded_client: client.encode(password_policy), 39 | }; 40 | self.clients.insert(id, record); 41 | } 42 | 43 | /// Change how passwords are encoded while stored. 44 | pub fn set_password_policy(&mut self, new_policy: P) { 45 | self.password_policy = Some(Box::new(new_policy)) 46 | } 47 | 48 | // This is not an instance method because it needs to borrow the box but register needs &mut 49 | fn current_policy(policy: &Option>) -> &dyn PasswordPolicy { 50 | policy 51 | .as_ref() 52 | .map(|boxed| &**boxed) 53 | .unwrap_or(&*DEFAULT_PASSWORD_POLICY) 54 | } 55 | } 56 | 57 | impl Registrar for ClientMap { 58 | fn bound_redirect<'a>(&self, bound: ClientUrl<'a>) -> Result, RegistrarError> { 59 | let client = match self.clients.get(bound.client_id.as_ref()) { 60 | None => return Err(RegistrarError::Unspecified), 61 | Some(stored) => stored, 62 | }; 63 | 64 | // Perform exact matching as motivated in the rfc 65 | let registered_url = match bound.redirect_uri { 66 | None => client.encoded_client.redirect_uri.clone(), 67 | Some(ref url) => { 68 | let original = std::iter::once(&client.encoded_client.redirect_uri); 69 | let alternatives = client.encoded_client.additional_redirect_uris.iter(); 70 | 71 | original 72 | .chain(alternatives) 73 | .find(|®istered| *registered == *url.as_ref()) 74 | .cloned() 75 | .ok_or(RegistrarError::Unspecified)? 76 | } 77 | }; 78 | 79 | Ok(BoundClient { 80 | client_id: bound.client_id, 81 | redirect_uri: Cow::Owned(registered_url), 82 | }) 83 | } 84 | 85 | /// Always overrides the scope with a default scope. 86 | fn negotiate( 87 | &self, 88 | bound: BoundClient, 89 | scope: Option, 90 | ) -> Result { 91 | let client = self 92 | .clients 93 | .get(bound.client_id.as_ref()) 94 | .expect("Bound client appears to not have been constructed with this registrar"); 95 | 96 | let scope = scope 97 | .and_then(|scope| { 98 | scope 99 | .iter() 100 | .filter(|scope| scopes::SCOPES.contains(scope)) 101 | .collect::>() 102 | .join(" ") 103 | .parse() 104 | .ok() 105 | }) 106 | .unwrap_or(client.encoded_client.default_scope.clone()); 107 | 108 | Ok(PreGrant { 109 | client_id: bound.client_id.into_owned(), 110 | redirect_uri: bound.redirect_uri.into_owned(), 111 | scope, 112 | }) 113 | } 114 | 115 | fn check(&self, client_id: &str, passphrase: Option<&[u8]>) -> Result<(), RegistrarError> { 116 | tracing::debug!("Registrar: check()"); 117 | let password_policy = Self::current_policy(&self.password_policy); 118 | 119 | self.clients 120 | .get(client_id) 121 | .ok_or(RegistrarError::Unspecified) 122 | .and_then(|client| { 123 | RegisteredClient::new(&client.encoded_client, password_policy) 124 | .check_authentication(passphrase) 125 | })?; 126 | 127 | tracing::debug!("Registrar: client check successfull"); 128 | Ok(()) 129 | } 130 | } 131 | 132 | #[derive(Debug)] 133 | pub struct ClientRecord { 134 | pub id: String, 135 | pub name: String, 136 | pub(crate) encoded_client: EncodedClient, 137 | } 138 | 139 | impl ClientRecord { 140 | #[allow(dead_code)] 141 | fn encoded_client(&self) -> EncodedClient { 142 | self.encoded_client.clone() 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/oauth/database/mod.rs: -------------------------------------------------------------------------------- 1 | use axum_macros::FromRef; 2 | use oxide_auth::{ 3 | endpoint::Scope, 4 | primitives::registrar::{Client, RegisteredUrl}, 5 | }; 6 | use secrecy::{ExposeSecret, Secret}; 7 | use std::{collections::HashMap, str::FromStr, sync::Arc}; 8 | use tokio::sync::RwLock; 9 | 10 | use self::{ 11 | clientmap::ClientMap, 12 | resource::{client::ClientName, user::AuthUser}, 13 | }; 14 | 15 | use super::models::{ClientId, UserId}; 16 | 17 | pub mod clientmap; 18 | pub mod resource; 19 | 20 | #[derive(Clone, FromRef)] 21 | pub struct Database { 22 | pub(crate) inner: Inner, 23 | } 24 | 25 | impl std::ops::Deref for Database { 26 | type Target = Inner; 27 | 28 | fn deref(&self) -> &Self::Target { 29 | &self.inner 30 | } 31 | } 32 | 33 | impl Default for Database { 34 | fn default() -> Self { 35 | Self::new() 36 | } 37 | } 38 | 39 | impl Database { 40 | pub fn new() -> Database { 41 | Database { 42 | inner: Inner { 43 | user_db: Arc::new(RwLock::new(HashMap::::new())), 44 | client_db: Arc::new(RwLock::new(ClientMap::new())), 45 | }, 46 | } 47 | } 48 | 49 | pub async fn register_user( 50 | &mut self, 51 | username: &str, 52 | password: Secret, 53 | given_name: &str, 54 | ) -> UserId { 55 | let id = UserId::new(); 56 | let u = UserRecord::new(id, username, password.expose_secret(), given_name); 57 | let mut map_lock = self.inner.user_db.write().await; 58 | map_lock.insert(id, u); 59 | 60 | id 61 | } 62 | 63 | pub async fn get_user_by_id(&self, user: &AuthUser) -> Result { 64 | let map_lock = self.inner.user_db.read().await; 65 | let record = map_lock 66 | .get(&user.user_id) 67 | .ok_or(StoreError::DoesNotExist)?; 68 | 69 | Ok(record.clone()) 70 | } 71 | 72 | pub async fn update_given_name_by_id( 73 | &mut self, 74 | user: &AuthUser, 75 | name: &str, 76 | ) -> Result { 77 | let mut map_lock = self.inner.user_db.write().await; 78 | let record = map_lock 79 | .get_mut(&user.user_id) 80 | .ok_or(StoreError::DoesNotExist)?; 81 | record.update_given_name(name); 82 | 83 | Ok(true) 84 | } 85 | 86 | pub async fn get_user_by_name(&self, username: &str) -> Result { 87 | let map_lock = self.inner.user_db.read().await; 88 | for v in (*map_lock).values() { 89 | if v.username == username { 90 | return Ok(v.clone()); 91 | } 92 | } 93 | 94 | Err(StoreError::DoesNotExist) 95 | } 96 | 97 | pub async fn contains_user_name(&self, username: &str) -> bool { 98 | match self.get_user_by_name(username).await { 99 | Ok(record) => record.username == username, 100 | Err(_) => false, 101 | } 102 | } 103 | 104 | // XXX - Doesn't really belong in a storage interface. It's just expeditious. 105 | pub async fn verify_password( 106 | &self, 107 | username: &str, 108 | password: &str, 109 | ) -> Result { 110 | #[allow(unused_variables)] 111 | let map_lock = self.inner.user_db.read().await; 112 | let db = self.get_user_by_name(username).await?; 113 | let result = password == db.password.expose_secret(); 114 | 115 | Ok(result) 116 | } 117 | 118 | pub async fn register_public_client( 119 | &mut self, 120 | client_name: &str, 121 | url: &str, 122 | default_scope: &str, 123 | ) -> Result<(String, Option), StoreError> { 124 | let id = ClientId::new(); 125 | let client = Client::public( 126 | id.as_str(), 127 | RegisteredUrl::Semantic(url.parse().unwrap()), 128 | default_scope.parse().unwrap(), 129 | ); 130 | tracing::debug!("Registering public client: {:?}", client); 131 | 132 | let mut client_lock = self.inner.client_db.write().await; 133 | client_lock.register_client(id.as_str(), client_name, client); 134 | 135 | // There is currently no easy way to search ClientMap for a record. So, thisscopescopescope 136 | // function will allways succeed. 137 | Ok((id.to_string(), None)) 138 | } 139 | 140 | pub async fn register_confidential_client( 141 | &mut self, 142 | client_name: &str, 143 | url: &str, 144 | default_scope: &str, 145 | ) -> Result<(String, Option), StoreError> { 146 | let id = ClientId::new(); 147 | let secret = nanoid::nanoid!(32); 148 | let client = Client::confidential( 149 | id.as_str(), 150 | RegisteredUrl::Semantic(url.parse().unwrap()), 151 | default_scope.parse().unwrap(), 152 | secret.as_bytes(), 153 | ); 154 | tracing::debug!("Registering confidential client: {:?}", &client); 155 | let mut map_lock = self.inner.client_db.write().await; 156 | map_lock.register_client(id.as_str(), client_name, client); 157 | 158 | // There is currently no easy way to search ClientMap for a record. So, this 159 | // function will allways succeed. 160 | Ok((id.to_string(), Some(secret))) 161 | } 162 | 163 | pub async fn get_client_name(&self, client_id: ClientId) -> Result { 164 | let map_lock = self.inner.client_db.read().await; 165 | let record = map_lock 166 | .clients 167 | .get(client_id.as_str()) 168 | .ok_or(StoreError::InternalError)?; 169 | 170 | Ok(ClientName { 171 | inner: record.name.clone(), 172 | }) 173 | } 174 | 175 | pub async fn get_scope(&self, user_id: UserId, client_id: ClientId) -> Option { 176 | tracing::debug!("in get_scope()"); 177 | let map_lock = self.inner.user_db.read().await; 178 | let record = map_lock.get(&user_id); 179 | if record.is_some() { 180 | let client_lst = record.unwrap().get_authorized_clients(); 181 | for auth in client_lst { 182 | if auth.client_id == client_id { 183 | tracing::debug!(" current scope: {:?}", auth.scope); 184 | return Some(auth.scope.clone()); 185 | } 186 | } 187 | } 188 | 189 | Some(Scope::from_str("").unwrap()) 190 | } 191 | 192 | pub async fn update_client_scope( 193 | &self, 194 | user_id: UserId, 195 | client_id: ClientId, 196 | scope: Scope, 197 | ) -> Result<(), StoreError> { 198 | tracing::debug!("in update_client_scope()"); 199 | let mut map_write = self.inner.user_db.write().await; 200 | let record = map_write.get_mut(&user_id); 201 | if record.is_some() { 202 | let record = record.unwrap(); 203 | let auth_list: &mut Vec = record.get_authorized_clients_mut(); 204 | for auth in auth_list.iter_mut() { 205 | if auth.client_id == client_id { 206 | auth.scope = scope; 207 | return Ok(()); 208 | } 209 | } 210 | 211 | // couldn't find client authorization, insert a new one 212 | tracing::debug!(" unable to find authorization, inserting new one"); 213 | record.add_authorized_client(client_id, scope); 214 | } 215 | 216 | Err(StoreError::DoesNotExist) 217 | } 218 | } 219 | 220 | #[derive(Clone)] 221 | pub struct Inner { 222 | pub(crate) user_db: Arc>>, 223 | pub(crate) client_db: Arc>, 224 | } 225 | 226 | #[derive(Clone, Debug)] 227 | pub struct UserRecord { 228 | id: UserId, 229 | given_name: String, 230 | username: String, 231 | password: Secret, 232 | authorized_clients: Vec, 233 | } 234 | 235 | impl UserRecord { 236 | pub fn new(id: UserId, user: &str, password: &str, given_name: &str) -> UserRecord { 237 | Self { 238 | id, 239 | username: user.to_owned(), 240 | password: Secret::from(password.to_owned()), 241 | authorized_clients: Vec::::new(), 242 | given_name: given_name.to_owned(), 243 | } 244 | } 245 | 246 | pub fn username(&self) -> Option { 247 | if !self.username.is_empty() { 248 | return Some(self.username.clone()); 249 | } 250 | 251 | None 252 | } 253 | 254 | pub fn given_name(&self) -> Option { 255 | if !self.given_name.is_empty() { 256 | return Some(self.given_name.clone()); 257 | } 258 | 259 | None 260 | } 261 | 262 | pub fn id(&self) -> Option { 263 | if !self.username.is_empty() { 264 | return Some(self.id); 265 | } 266 | 267 | None 268 | } 269 | 270 | pub fn add_authorized_client(&mut self, client_id: ClientId, scope: Scope) { 271 | self.authorized_clients 272 | .push(ClientAuthorization { client_id, scope }); 273 | } 274 | 275 | pub fn get_authorized_clients(&self) -> &Vec { 276 | &self.authorized_clients 277 | } 278 | 279 | pub fn get_authorized_clients_mut(&mut self) -> &mut Vec { 280 | &mut self.authorized_clients 281 | } 282 | 283 | pub fn update_given_name(&mut self, name: &str) { 284 | self.given_name = name.to_owned(); 285 | } 286 | } 287 | 288 | #[derive(Clone, Debug)] 289 | pub struct ClientAuthorization { 290 | pub client_id: ClientId, 291 | pub scope: oxide_auth::primitives::scope::Scope, 292 | } 293 | 294 | #[derive(Debug)] 295 | pub enum StoreError { 296 | DoesNotExist, 297 | DuplicateRecord, 298 | InternalError, 299 | } 300 | 301 | impl std::error::Error for StoreError { 302 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 303 | match *self { 304 | StoreError::DoesNotExist => None, 305 | StoreError::DuplicateRecord => None, 306 | StoreError::InternalError => None, 307 | } 308 | } 309 | } 310 | 311 | impl std::fmt::Display for StoreError { 312 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 313 | match *self { 314 | StoreError::DoesNotExist => write!(f, "the record does not exist"), 315 | StoreError::DuplicateRecord => write!(f, "attempted to insert duplicate record"), 316 | StoreError::InternalError => write!(f, "an unexpected internal error occurred"), 317 | } 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/oauth/database/resource/client.rs: -------------------------------------------------------------------------------- 1 | use oxide_auth::primitives::registrar::EncodedClient as Inner; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::oauth::models::{ClientId, InvalidLengthError}; 5 | 6 | #[derive(Serialize, Deserialize)] 7 | pub struct EncodedClient { 8 | pub inner: Inner, 9 | } 10 | 11 | #[derive(Serialize, Deserialize)] 12 | pub struct ClientName { 13 | pub inner: String, 14 | } 15 | 16 | // impl Resource for ClientName { 17 | // const NAME: &'static str = "client_name"; 18 | 19 | // type Key = ClientQuery; 20 | // } 21 | 22 | #[derive(Clone, Copy, Debug, Serialize, Deserialize)] 23 | pub struct AuthClient { 24 | pub id: ClientId, 25 | } 26 | 27 | impl std::str::FromStr for AuthClient { 28 | type Err = InvalidLengthError; 29 | 30 | fn from_str(src: &str) -> Result { 31 | let id = src.parse()?; 32 | 33 | Ok(Self { id }) 34 | } 35 | } 36 | 37 | // #[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] 38 | // pub struct ClientId(i64); 39 | 40 | // impl Resource for EncodedClient { 41 | // const NAME: &'static str = "client"; 42 | 43 | // type Key = ClientQuery; 44 | // } 45 | 46 | // impl Traverse for EncodedClient { 47 | // type Collection = Relation; 48 | // } 49 | -------------------------------------------------------------------------------- /src/oauth/database/resource/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod client; 2 | pub mod user; 3 | -------------------------------------------------------------------------------- /src/oauth/database/resource/user.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::oauth::models::{InvalidLengthError, UserId}; 4 | 5 | use super::client::AuthClient; 6 | 7 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 8 | pub struct AuthUser { 9 | pub(crate) user_id: UserId, 10 | pub(crate) username: String, 11 | } 12 | 13 | impl std::fmt::Display for AuthUser { 14 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 15 | write!(f, "{}:{}", self.user_id, self.username) 16 | } 17 | } 18 | 19 | impl std::str::FromStr for AuthUser { 20 | type Err = InvalidLengthError; 21 | 22 | fn from_str(src: &str) -> Result { 23 | let user_id = src 24 | .get(0..UserId::LENGTH) 25 | .ok_or(InvalidLengthError { 26 | expected: UserId::LENGTH, 27 | actual: src.len(), 28 | })? 29 | .parse()?; 30 | 31 | let username = src[UserId::LENGTH + 1..].to_string(); 32 | 33 | Ok(Self { user_id, username }) 34 | } 35 | } 36 | 37 | pub struct AuthorizationQuery { 38 | pub user: AuthUser, 39 | pub client: AuthClient, 40 | } 41 | 42 | #[derive(Clone, Debug, Serialize, Deserialize)] 43 | pub struct Authorization { 44 | pub scope: oxide_auth::primitives::scope::Scope, 45 | } 46 | 47 | // #[derive(Serialize, Deserialize)] 48 | // pub struct User { 49 | // pub username: String, 50 | // pub password: String, 51 | // } 52 | 53 | // #[derive(Serialize, Deserialize)] 54 | // pub struct Username; 55 | 56 | // #[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] 57 | // pub struct UserId(i64); 58 | -------------------------------------------------------------------------------- /src/oauth/endpoint/extension.rs: -------------------------------------------------------------------------------- 1 | use oxide_auth::{ 2 | code_grant::{ 3 | accesstoken::Request as AccessTokenRequest, authorization::Request as AuthorizationRequest, 4 | }, 5 | endpoint, 6 | frontends::simple::extensions, 7 | primitives::grant::Extensions, 8 | }; 9 | use oxide_auth_async::endpoint::{AccessTokenExtension, AuthorizationExtension, Extension}; 10 | 11 | pub struct Empty; 12 | 13 | impl Extension for Empty {} 14 | 15 | #[derive(Default)] 16 | pub struct AddonList { 17 | inner: extensions::AddonList, 18 | } 19 | 20 | impl std::ops::Deref for AddonList { 21 | type Target = extensions::AddonList; 22 | 23 | fn deref(&self) -> &Self::Target { 24 | &self.inner 25 | } 26 | } 27 | 28 | impl std::ops::DerefMut for AddonList { 29 | fn deref_mut(&mut self) -> &mut Self::Target { 30 | &mut self.inner 31 | } 32 | } 33 | 34 | #[async_trait::async_trait] 35 | impl AuthorizationExtension for AddonList { 36 | async fn extend( 37 | &mut self, 38 | request: &(dyn AuthorizationRequest + Sync), 39 | ) -> std::result::Result { 40 | endpoint::AuthorizationExtension::extend(&mut self.inner, request) 41 | } 42 | } 43 | 44 | #[async_trait::async_trait] 45 | impl AccessTokenExtension for AddonList { 46 | async fn extend( 47 | &mut self, 48 | request: &(dyn AccessTokenRequest + Sync), 49 | data: Extensions, 50 | ) -> std::result::Result { 51 | endpoint::AccessTokenExtension::extend(&mut self.inner, request, data) 52 | } 53 | } 54 | 55 | impl Extension for AddonList { 56 | fn authorization(&mut self) -> Option<&mut (dyn AuthorizationExtension + Send)> { 57 | Some(self) 58 | } 59 | 60 | fn access_token(&mut self) -> Option<&mut (dyn AccessTokenExtension + Send)> { 61 | Some(self) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/oauth/endpoint/mod.rs: -------------------------------------------------------------------------------- 1 | use super::primitives::Guard; 2 | use oxide_auth::{ 3 | endpoint::{OAuthError, Template, WebRequest}, 4 | frontends::simple::extensions::Pkce, 5 | primitives::{authorizer::AuthMap, generator::RandomGenerator, issuer::TokenMap, scope::Scope}, 6 | }; 7 | use oxide_auth_async::{ 8 | endpoint::{ 9 | self, 10 | access_token::AccessTokenFlow, 11 | authorization::AuthorizationFlow, 12 | // refresh::RefreshFlow, resource::ResourceFlow, client_credentials::ClientCredentialsFlow, 13 | refresh::RefreshFlow, 14 | resource::ResourceFlow, 15 | }, 16 | primitives, 17 | }; 18 | use oxide_auth_axum::OAuthRequest; 19 | 20 | pub mod extension; 21 | 22 | pub struct Endpoint<'a, Registrar, Extension, Solicitor, Scopes> { 23 | pub(super) registrar: &'a Registrar, 24 | pub(super) authorizer: Guard<'a, AuthMap>, 25 | pub(super) issuer: Guard<'a, TokenMap>, 26 | pub(super) extension: Extension, 27 | pub(super) solicitor: Solicitor, 28 | pub(super) scopes: Scopes, 29 | } 30 | 31 | impl<'a, Registrar, Extension, Solicitor, Scopes> 32 | Endpoint<'a, Registrar, Extension, Solicitor, Scopes> 33 | where 34 | Registrar: primitives::Registrar + Send + Sync, 35 | Extension: endpoint::Extension + Send + Sync, 36 | Solicitor: endpoint::OwnerSolicitor + Send + Sync, 37 | Scopes: oxide_auth::endpoint::Scopes + Send + Sync, 38 | { 39 | pub fn with_scopes( 40 | self, 41 | scopes: &'a [Scope], 42 | ) -> Endpoint<'a, Registrar, Extension, Solicitor, &'a [Scope]> { 43 | Endpoint { 44 | registrar: self.registrar, 45 | authorizer: self.authorizer, 46 | issuer: self.issuer, 47 | extension: self.extension, 48 | solicitor: self.solicitor, 49 | scopes, 50 | } 51 | } 52 | 53 | pub fn with_solicitor( 54 | self, 55 | solicitor: S, 56 | ) -> Endpoint<'a, Registrar, extension::AddonList, S, Scopes> 57 | where 58 | S: endpoint::OwnerSolicitor, 59 | { 60 | let pkce = Pkce::required(); 61 | let mut extension = extension::AddonList::default(); 62 | extension.push_code(pkce); 63 | 64 | Endpoint { 65 | registrar: self.registrar, 66 | authorizer: self.authorizer, 67 | issuer: self.issuer, 68 | extension, 69 | solicitor, 70 | scopes: self.scopes, 71 | } 72 | } 73 | 74 | pub fn authorization_flow(self) -> AuthorizationFlow { 75 | match AuthorizationFlow::prepare(self) { 76 | Ok(flow) => flow, 77 | Err(_) => unreachable!(), 78 | } 79 | } 80 | 81 | pub fn access_token_flow(self) -> AccessTokenFlow { 82 | match AccessTokenFlow::prepare(self) { 83 | Ok(flow) => flow, 84 | Err(_) => unreachable!(), 85 | } 86 | } 87 | 88 | // pub fn client_credentials_flow(self) -> ClientCredentialsFlow { 89 | // match ClientCredentialsFlow::prepare(self) { 90 | // Ok(flow) => flow, 91 | // Err(_) => unreachable!(), 92 | // } 93 | // } 94 | 95 | pub fn refresh_flow(self) -> RefreshFlow { 96 | match RefreshFlow::prepare(self) { 97 | Ok(flow) => flow, 98 | Err(_) => unreachable!(), 99 | } 100 | } 101 | 102 | pub fn resource_flow(self) -> ResourceFlow { 103 | match ResourceFlow::prepare(self) { 104 | Ok(flow) => flow, 105 | Err(_) => unreachable!(), 106 | } 107 | } 108 | } 109 | 110 | impl<'a, Request, Registrar, Extension, Solicitor, Scopes> endpoint::Endpoint 111 | for Endpoint<'a, Registrar, Extension, Solicitor, Scopes> 112 | where 113 | Request: WebRequest, 114 | Request::Response: Default, 115 | Request::Error: From, 116 | Registrar: primitives::Registrar + Sync, 117 | Extension: endpoint::Extension + Send, 118 | Solicitor: endpoint::OwnerSolicitor + Send, 119 | Scopes: oxide_auth::endpoint::Scopes, 120 | { 121 | type Error = Request::Error; 122 | 123 | fn registrar(&self) -> Option<&(dyn primitives::Registrar + Sync)> { 124 | Some(self.registrar) 125 | } 126 | 127 | fn authorizer_mut(&mut self) -> Option<&mut (dyn primitives::Authorizer + Send)> { 128 | Some(&mut self.authorizer) 129 | } 130 | 131 | fn issuer_mut(&mut self) -> Option<&mut (dyn primitives::Issuer + Send)> { 132 | Some(&mut self.issuer) 133 | } 134 | 135 | fn owner_solicitor(&mut self) -> Option<&mut (dyn endpoint::OwnerSolicitor + Send)> { 136 | Some(&mut self.solicitor) 137 | } 138 | 139 | fn scopes(&mut self) -> Option<&mut dyn oxide_auth::endpoint::Scopes> { 140 | Some(&mut self.scopes) 141 | } 142 | 143 | fn response(&mut self, _: &mut Request, _: Template) -> Result { 144 | Ok(Default::default()) 145 | } 146 | 147 | fn error(&mut self, err: OAuthError) -> Self::Error { 148 | err.into() 149 | } 150 | 151 | fn web_error(&mut self, err: Request::Error) -> Self::Error { 152 | err 153 | } 154 | 155 | fn extension(&mut self) -> Option<&mut (dyn endpoint::Extension + Send)> { 156 | Some(&mut self.extension) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/oauth/error.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | http::StatusCode, 3 | response::{IntoResponse, Response}, 4 | }; 5 | 6 | pub type Result = std::result::Result; 7 | 8 | #[derive(Debug)] 9 | pub enum Error { 10 | Database { 11 | source: crate::oauth::database::StoreError, 12 | }, 13 | NotFound, 14 | InvalidKey { 15 | source: crate::oauth::models::InvalidLengthError, 16 | }, 17 | Hash { 18 | source: argon2::password_hash::Error, 19 | }, 20 | OAuth { 21 | source: oxide_auth_axum::WebError, 22 | }, 23 | ResourceConflict, 24 | InternalError, 25 | } 26 | 27 | impl std::fmt::Display for Error { 28 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 29 | match self { 30 | #[allow(unused_variables)] 31 | Error::Database { source } => write!(f, "Database error"), 32 | Error::NotFound => write!(f, "Not found"), 33 | #[allow(unused_variables)] 34 | Error::InvalidKey { source } => write!(f, "Invalid key"), 35 | #[allow(unused_variables)] 36 | Error::Hash { source } => write!(f, "Invalid hash in database"), 37 | Error::OAuth { source } => write!(f, "{source}"), 38 | Error::InternalError => write!(f, "Unexpected internal error"), 39 | Error::ResourceConflict => write!(f, "User already exists"), 40 | } 41 | } 42 | } 43 | 44 | impl std::error::Error for Error { 45 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 46 | match self { 47 | Error::Database { source } => Some(source), 48 | Error::NotFound => None, 49 | Error::InvalidKey { source } => Some(source), 50 | Error::Hash { source } => Some(source), 51 | Error::OAuth { source } => Some(source), 52 | Error::InternalError => None, 53 | Error::ResourceConflict => None, 54 | } 55 | } 56 | } 57 | 58 | impl IntoResponse for Error { 59 | fn into_response(self) -> Response { 60 | if let Self::OAuth { source } = self { 61 | source.into_response() 62 | } else if let Self::ResourceConflict = self { 63 | (StatusCode::CONFLICT, "User already exists").into_response() 64 | } else { 65 | StatusCode::INTERNAL_SERVER_ERROR.into_response() 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/oauth/mod.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | pub mod database; 4 | pub mod endpoint; 5 | pub mod error; 6 | pub mod models; 7 | pub mod primitives; 8 | pub mod routes; 9 | pub mod scopes; 10 | pub mod solicitor; 11 | pub mod state; 12 | pub mod templates; 13 | 14 | #[derive(Debug, Deserialize)] 15 | #[serde(tag = "consent", rename_all = "lowercase")] 16 | pub enum Consent { 17 | Allow, 18 | Deny, 19 | } 20 | -------------------------------------------------------------------------------- /src/oauth/models/client.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use super::{ClientId, UserId}; 4 | 5 | #[derive(Clone, Copy, Serialize, Deserialize)] 6 | pub struct ClientQuery { 7 | pub user_id: UserId, 8 | pub id: ClientId, 9 | } 10 | 11 | impl std::fmt::Display for ClientQuery { 12 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 13 | write!(f, "{}{}", self.user_id, self.id) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/oauth/models/mod.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 2 | 3 | pub mod client; 4 | 5 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 6 | struct Id { 7 | inner: [u8; L], 8 | } 9 | 10 | impl Id { 11 | const LENGTH: usize = L; 12 | 13 | pub fn as_bytes(&self) -> [u8; L] { 14 | self.inner 15 | } 16 | 17 | pub fn as_str(&self) -> &str { 18 | std::str::from_utf8(&self.inner).unwrap() 19 | } 20 | 21 | pub fn from_bytes(bytes: &[u8]) -> Result { 22 | Ok(Self { 23 | inner: bytes.try_into().map_err(|_| InvalidLengthError { 24 | expected: L, 25 | actual: bytes.len(), 26 | })?, 27 | }) 28 | } 29 | } 30 | 31 | impl std::str::FromStr for Id { 32 | type Err = InvalidLengthError; 33 | 34 | fn from_str(s: &str) -> Result { 35 | Self::from_bytes(s.as_bytes()) 36 | } 37 | } 38 | 39 | impl Serialize for Id { 40 | fn serialize(&self, serializer: S) -> Result 41 | where 42 | S: Serializer, 43 | { 44 | serializer 45 | .serialize_str(std::str::from_utf8(&self.inner).map_err(serde::ser::Error::custom)?) 46 | } 47 | } 48 | 49 | impl<'de, const L: usize> Deserialize<'de> for Id { 50 | fn deserialize(deserializer: D) -> Result 51 | where 52 | D: Deserializer<'de>, 53 | { 54 | Self::from_bytes(String::deserialize(deserializer)?.as_bytes()) 55 | .map_err(serde::de::Error::custom) 56 | } 57 | } 58 | 59 | #[derive(Debug)] 60 | pub struct InvalidLengthError { 61 | pub expected: usize, 62 | pub actual: usize, 63 | } 64 | 65 | impl std::fmt::Display for InvalidLengthError { 66 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 67 | write!( 68 | f, 69 | "invalid id length, expected: {}, actual: {}", 70 | self.expected, self.actual 71 | ) 72 | } 73 | } 74 | 75 | impl std::error::Error for InvalidLengthError {} 76 | 77 | macro_rules! declare_id { 78 | ($name:ident, $length:expr) => { 79 | #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] 80 | pub struct $name(Id<$length>); 81 | 82 | impl $name { 83 | pub const LENGTH: usize = Id::<$length>::LENGTH; 84 | 85 | pub fn new() -> Self { 86 | ::nanoid::nanoid!($length).parse().unwrap() 87 | } 88 | 89 | pub fn as_bytes(&self) -> [u8; Self::LENGTH] { 90 | self.0.as_bytes() 91 | } 92 | 93 | pub fn as_str(&self) -> &str { 94 | self.0.as_str() 95 | } 96 | 97 | pub fn from_bytes(bytes: &[u8]) -> Result { 98 | Ok(Self(Id::from_bytes(bytes)?)) 99 | } 100 | } 101 | 102 | impl ::std::default::Default for $name { 103 | fn default() -> Self { 104 | Self::new() 105 | } 106 | } 107 | 108 | impl ::std::fmt::Display for $name { 109 | fn fmt(&self, f: &mut ::std::fmt::Formatter) -> std::fmt::Result { 110 | f.write_str(::std::str::from_utf8(&self.as_bytes()).unwrap_or_default()) 111 | } 112 | } 113 | 114 | impl ::std::str::FromStr for $name { 115 | type Err = InvalidLengthError; 116 | 117 | fn from_str(s: &str) -> Result { 118 | Self::from_bytes(s.as_bytes()) 119 | } 120 | } 121 | }; 122 | } 123 | 124 | declare_id!(UserId, 21); 125 | declare_id!(ClientId, 21); 126 | -------------------------------------------------------------------------------- /src/oauth/primitives/authorizer.rs: -------------------------------------------------------------------------------- 1 | use super::Guard; 2 | use oxide_auth::primitives::{authorizer::Authorizer, grant::Grant}; 3 | use oxide_auth_async::primitives::Authorizer as AuthorizerAsync; 4 | 5 | #[async_trait::async_trait] 6 | impl AuthorizerAsync for Guard<'_, T> 7 | where 8 | T: Authorizer + Send, 9 | { 10 | async fn authorize(&mut self, grant: Grant) -> Result { 11 | Authorizer::authorize(&mut *self.inner, grant) 12 | } 13 | 14 | async fn extract(&mut self, token: &str) -> Result, ()> { 15 | Authorizer::extract(&mut *self.inner, token) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/oauth/primitives/issuer.rs: -------------------------------------------------------------------------------- 1 | use super::Guard; 2 | use oxide_auth::primitives::{ 3 | grant::Grant, 4 | issuer::{IssuedToken, Issuer, RefreshedToken}, 5 | }; 6 | use oxide_auth_async::primitives::Issuer as IssuerAsync; 7 | 8 | #[async_trait::async_trait] 9 | impl IssuerAsync for Guard<'_, T> 10 | where 11 | T: Issuer + Send, 12 | { 13 | async fn issue(&mut self, grant: Grant) -> Result { 14 | Issuer::issue(&mut **self, grant) 15 | } 16 | 17 | async fn refresh(&mut self, token: &str, grant: Grant) -> Result { 18 | Issuer::refresh(&mut **self, token, grant) 19 | } 20 | 21 | async fn recover_token(&mut self, token: &str) -> Result, ()> { 22 | Issuer::recover_token(&**self, token) 23 | } 24 | 25 | async fn recover_refresh(&mut self, token: &str) -> Result, ()> { 26 | Issuer::recover_refresh(&**self, token) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/oauth/primitives/mod.rs: -------------------------------------------------------------------------------- 1 | mod authorizer; 2 | mod issuer; 3 | mod registrar; 4 | pub mod scopes; 5 | 6 | use tokio::sync::MutexGuard; 7 | 8 | pub struct Guard<'a, T> { 9 | inner: MutexGuard<'a, T>, 10 | } 11 | 12 | impl<'a, T> From> for Guard<'a, T> { 13 | fn from(guard: MutexGuard<'a, T>) -> Self { 14 | Self { inner: guard } 15 | } 16 | } 17 | 18 | impl std::ops::Deref for Guard<'_, T> { 19 | type Target = T; 20 | 21 | fn deref(&self) -> &Self::Target { 22 | &self.inner 23 | } 24 | } 25 | 26 | impl std::ops::DerefMut for Guard<'_, T> { 27 | fn deref_mut(&mut self) -> &mut Self::Target { 28 | &mut self.inner 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/oauth/primitives/registrar.rs: -------------------------------------------------------------------------------- 1 | use crate::oauth::database::Database; 2 | use oxide_auth::primitives::{ 3 | registrar::{BoundClient, ClientUrl, PreGrant, RegistrarError}, 4 | scope::Scope, 5 | }; 6 | use oxide_auth_async::primitives::Registrar; 7 | 8 | #[async_trait::async_trait] 9 | impl Registrar for Database { 10 | async fn bound_redirect<'a>( 11 | &self, 12 | bound: ClientUrl<'a>, 13 | ) -> Result, RegistrarError> { 14 | let client_map_lock = self.inner.client_db.read().await; 15 | client_map_lock.bound_redirect(bound).await 16 | } 17 | 18 | async fn negotiate<'a>( 19 | &self, 20 | bound: BoundClient<'a>, 21 | scope: Option, 22 | ) -> Result { 23 | let client_map_lock = self.inner.client_db.read().await; 24 | client_map_lock.negotiate(bound, scope).await 25 | } 26 | 27 | async fn check( 28 | &self, 29 | client_id: &str, 30 | passphrase: Option<&[u8]>, 31 | ) -> Result<(), RegistrarError> { 32 | let client_map_lock = self.inner.client_db.read().await; 33 | client_map_lock.check(client_id, passphrase).await 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/oauth/primitives/scopes.rs: -------------------------------------------------------------------------------- 1 | use crate::oauth::scopes; 2 | 3 | pub struct Grant { 4 | pub grant: oxide_auth::primitives::grant::Grant, 5 | _type: std::marker::PhantomData, 6 | } 7 | 8 | use axum::{ 9 | extract::{FromRef, FromRequestParts}, 10 | http::request::Parts, 11 | }; 12 | use oxide_auth_axum::{OAuthResource, OAuthResponse, WebError}; 13 | 14 | #[axum::async_trait] 15 | impl FromRequestParts for Grant 16 | where 17 | super::super::state::State: FromRef, 18 | State: Send + Sync + 'static, 19 | Scope: scopes::Scope, 20 | { 21 | type Rejection = Result; 22 | 23 | async fn from_request_parts(parts: &mut Parts, state: &State) -> Result { 24 | tracing::debug!("Middleware: Grant: parts: {:?}", parts); 25 | let req = OAuthResource::from_request_parts(parts, state) 26 | .await 27 | .map_err(Err)?; 28 | 29 | let state = crate::oauth::state::State::from_ref(state); 30 | 31 | let auth = state 32 | .endpoint() 33 | .await 34 | .with_scopes(&[Scope::SCOPE.parse().unwrap()]) 35 | .resource_flow() 36 | .execute(req.into()) 37 | .await; 38 | 39 | auth.map(|grant| Self { 40 | grant, 41 | _type: Default::default(), 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/oauth/routes/client.rs: -------------------------------------------------------------------------------- 1 | use crate::oauth::{ 2 | database::Database, 3 | error::{Error, Result}, 4 | }; 5 | 6 | use axum::{ 7 | extract::{Form, FromRef, State}, 8 | response::{IntoResponse, Json}, 9 | routing::post, 10 | Router, 11 | }; 12 | use serde::{Deserialize, Serialize}; 13 | 14 | pub fn routes() -> Router 15 | where 16 | S: Send + Sync + 'static + Clone, 17 | Database: FromRef, 18 | { 19 | Router::new().route("/", post(post_client)) 20 | } 21 | 22 | #[derive(Deserialize)] 23 | #[serde(rename_all = "lowercase")] 24 | enum ClientType { 25 | Public, 26 | Confidential, 27 | } 28 | 29 | #[derive(Deserialize)] 30 | struct ClientForm { 31 | name: String, 32 | redirect_uri: String, 33 | r#type: ClientType, 34 | } 35 | 36 | async fn post_client( 37 | State(mut db): State, 38 | Form(client_form): Form, 39 | ) -> Result { 40 | tracing::debug!("POST Handler: post_client()"); 41 | 42 | let client_name = client_form.name; 43 | 44 | let (client_id, client_secret) = match client_form.r#type { 45 | ClientType::Public => db 46 | .register_public_client(&client_name, &client_form.redirect_uri, "") 47 | .await 48 | .map_err(|e| Error::Database { source: (e) })?, 49 | 50 | ClientType::Confidential => db 51 | .register_confidential_client(&client_name, &client_form.redirect_uri, "") 52 | .await 53 | .map_err(|e| Error::Database { source: (e) })?, 54 | }; 55 | 56 | #[derive(Serialize)] 57 | struct Response { 58 | client_id: String, 59 | client_secret: Option, 60 | } 61 | 62 | tracing::debug!( 63 | "POST Handler: post_client(): return (id, secret): ({:?},{:?})", 64 | client_id, 65 | client_secret 66 | ); 67 | Ok(Json(Response { 68 | client_id, 69 | client_secret, 70 | }) 71 | .into_response()) 72 | } 73 | -------------------------------------------------------------------------------- /src/oauth/routes/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::oauth::database::Database; 2 | use axum::{extract::FromRef, Router}; 3 | use axum_sessions::{async_session::MemoryStore, PersistencePolicy, SameSite, SessionLayer}; 4 | use serde::{Deserialize, Serialize}; 5 | use std::borrow::Cow; 6 | 7 | mod session { 8 | use crate::oauth::database::resource::user::AuthUser; 9 | 10 | use super::Callback; 11 | use axum::{extract::FromRequestParts, http::request::Parts, response::Redirect}; 12 | use axum_sessions::extractors::ReadableSession; 13 | 14 | pub struct Session { 15 | pub user: AuthUser, 16 | } 17 | 18 | #[axum::async_trait] 19 | impl FromRequestParts for Session 20 | where 21 | S: Send + Sync + 'static, 22 | { 23 | type Rejection = Redirect; 24 | 25 | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { 26 | tracing::debug!("Middleware: Session: parts: {:?}", parts); 27 | let session = ReadableSession::from_request_parts(parts, state) 28 | .await 29 | .ok() 30 | .and_then(|session| session.get("user")); 31 | 32 | if let Some(user) = session { 33 | Ok(Self { user }) 34 | } else { 35 | let path_and_query = parts 36 | .uri 37 | .path_and_query() 38 | .map(|x| x.as_str()) 39 | .map(|x| x.trim_start_matches('/')) 40 | .unwrap_or_default(); 41 | let callback = Callback::from_str(path_and_query); 42 | 43 | let uri = format!( 44 | "/oauth/signin?{}", 45 | serde_urlencoded::to_string(callback).unwrap() 46 | ); 47 | 48 | Err(Redirect::to(&uri)) 49 | } 50 | } 51 | } 52 | } 53 | 54 | pub fn routes() -> Router 55 | where 56 | crate::oauth::state::State: FromRef, 57 | Database: FromRef, 58 | S: Send + Sync + 'static + Clone, 59 | { 60 | let session_layer = SessionLayer::new(MemoryStore::new(), nanoid::nanoid!(128).as_bytes()) 61 | .with_cookie_name("axum_oauth") 62 | .with_secure(true) 63 | .with_persistence_policy(PersistencePolicy::ChangedOnly) 64 | .with_cookie_path("/oauth/") 65 | .with_same_site_policy(SameSite::Lax); 66 | 67 | Router::new() 68 | .merge(oauth::routes()) 69 | .nest("/client", client::routes()) 70 | .nest("/signin", signin::routes()) 71 | .nest("/signout", signout::routes().with_state(())) 72 | .nest("/signup", signup::routes()) 73 | .layer(session_layer) 74 | } 75 | 76 | mod client; 77 | mod oauth; 78 | mod signin; 79 | mod signout; 80 | mod signup; 81 | 82 | #[derive(Default, Serialize, Deserialize)] 83 | pub struct Callback<'a> { 84 | callback: Cow<'a, str>, 85 | } 86 | 87 | impl<'a> Callback<'a> { 88 | fn as_str(&self) -> &str { 89 | self.callback.as_ref() 90 | } 91 | 92 | fn from_str(callback: &'a str) -> Self { 93 | Self { 94 | callback: Cow::Borrowed(callback), 95 | } 96 | } 97 | } 98 | 99 | #[derive(Deserialize, Clone)] 100 | pub struct LoginForm { 101 | pub username: String, 102 | pub password: String, 103 | } 104 | 105 | #[derive(Deserialize, Clone)] 106 | pub struct SignUpForm { 107 | pub username: String, 108 | pub password: String, 109 | pub given_name: String, 110 | } 111 | -------------------------------------------------------------------------------- /src/oauth/routes/oauth.rs: -------------------------------------------------------------------------------- 1 | use crate::oauth::{ 2 | database::Database, error::Error, models::ClientId, routes::session::Session, 3 | solicitor::Solicitor, Consent, 4 | }; 5 | use axum::{ 6 | extract::{FromRef, Query, State}, 7 | response::IntoResponse, 8 | routing::{get, post}, 9 | Router, 10 | }; 11 | use oxide_auth::{ 12 | endpoint::{OwnerConsent, PreGrant, QueryParameter, Solicitation}, 13 | frontends::simple::endpoint::FnSolicitor, 14 | primitives::scope::Scope, 15 | }; 16 | use oxide_auth_axum::{OAuthRequest, OAuthResponse, WebError}; 17 | 18 | pub fn routes() -> Router 19 | where 20 | S: Send + Sync + 'static + Clone, 21 | crate::oauth::state::State: FromRef, 22 | crate::oauth::database::Database: FromRef, 23 | { 24 | Router::new() 25 | .route("/authorize", get(get_authorize).post(post_authorize)) 26 | .route("/refresh", get(refresh)) 27 | .route("/token", post(token)) 28 | } 29 | 30 | async fn get_authorize( 31 | State(state): State, 32 | State(db): State, 33 | Session { user }: Session, 34 | request: OAuthRequest, 35 | ) -> Result { 36 | tracing::debug!("in get_authorize()"); 37 | tracing::debug!("OAuth Request:\n{:?}", request); 38 | state 39 | .endpoint() 40 | .await 41 | .with_solicitor(Solicitor::new(db, user)) 42 | .authorization_flow() 43 | .execute(request) 44 | .await 45 | .map(IntoResponse::into_response) 46 | .map_err(|e| Error::OAuth { source: e }) 47 | } 48 | 49 | async fn post_authorize( 50 | State(state): State, 51 | State(db): State, 52 | Query(consent): Query, 53 | Session { user }: Session, 54 | request: OAuthRequest, 55 | ) -> Result { 56 | tracing::debug!("in post_authorize()"); 57 | tracing::debug!("request:\n{:?}", request); 58 | tracing::debug!("consent:\n{:?}", consent); 59 | 60 | state 61 | .endpoint() 62 | .await 63 | .with_solicitor(FnSolicitor( 64 | move |_: &mut OAuthRequest, solicitation: Solicitation| { 65 | if let Consent::Allow = consent { 66 | let PreGrant { 67 | client_id, scope, .. 68 | } = solicitation.pre_grant().clone(); 69 | 70 | let current_scope = futures::executor::block_on(get_current_authorization( 71 | &db, 72 | &user.username, 73 | &client_id, 74 | )); 75 | if current_scope.is_none() || current_scope.unwrap() < scope { 76 | futures::executor::block_on(update_authorization( 77 | &db, 78 | &user.username, 79 | &client_id, 80 | scope, 81 | )); 82 | } 83 | 84 | OwnerConsent::Authorized(user.to_string()) 85 | } else { 86 | OwnerConsent::Denied 87 | } 88 | }, 89 | )) 90 | .authorization_flow() 91 | .execute(request) 92 | .await 93 | .map(IntoResponse::into_response) 94 | .map_err(|e| Error::OAuth { source: e }) 95 | } 96 | 97 | async fn token( 98 | State(state): State, 99 | request: OAuthRequest, 100 | ) -> Result { 101 | tracing::debug!("Endpoint: token(), Request:\n{:?}", request); 102 | let grant_type = request 103 | .body() 104 | .and_then(|x| x.unique_value("grant_type")) 105 | .unwrap_or_default(); 106 | tracing::debug!("Grant Type: {:?}", grant_type); 107 | 108 | match &*grant_type { 109 | "refresh_token" => refresh(State(state), request).await, 110 | // "client_credentials" => state 111 | // .endpoint() 112 | // .await 113 | // .with_solicitor(FnSolicitor( 114 | // move |_: &mut OAuthRequest, solicitation: Solicitation| { 115 | // let PreGrant { 116 | // client_id, .. 117 | // } = solicitation.pre_grant().clone(); 118 | // tracing::debug!("Client credentials consent OK: {}", client_id); 119 | // OwnerConsent::Authorized(client_id.to_string()) 120 | // }, 121 | // )) 122 | // .client_credentials_flow() 123 | // .execute(request) 124 | // .await, 125 | _ => { 126 | state 127 | .endpoint() 128 | .await 129 | .access_token_flow() 130 | .execute(request) 131 | .await 132 | } 133 | } 134 | } 135 | 136 | async fn refresh( 137 | State(state): State, 138 | request: OAuthRequest, 139 | ) -> Result { 140 | state.endpoint().await.refresh_flow().execute(request).await 141 | } 142 | 143 | async fn get_current_authorization( 144 | db: &Database, 145 | username: &str, 146 | client_str: &str, 147 | ) -> Option { 148 | let user_record = db.get_user_by_name(username).await; 149 | let client_id = client_str.parse::(); 150 | if user_record.is_err() || client_id.is_err() { 151 | return None; 152 | } 153 | let user_record = user_record.unwrap(); 154 | let client_id = client_id.unwrap(); 155 | 156 | db.get_scope(user_record.id().unwrap(), client_id).await 157 | } 158 | 159 | async fn update_authorization(db: &Database, username: &str, client_str: &str, new_scope: Scope) { 160 | let user_record = db.get_user_by_name(username).await; 161 | let client_id = client_str.parse::(); 162 | if user_record.is_err() || client_id.is_err() { 163 | return; 164 | } 165 | let user_record = user_record.unwrap(); 166 | let client_id = client_id.unwrap(); 167 | let _ = db 168 | .update_client_scope(user_record.id().unwrap(), client_id, new_scope) 169 | .await; 170 | } 171 | -------------------------------------------------------------------------------- /src/oauth/routes/signin.rs: -------------------------------------------------------------------------------- 1 | use super::{Callback, LoginForm}; 2 | use crate::oauth::{ 3 | database::{resource::user::AuthUser, Database}, 4 | error::{Error, Result}, 5 | templates::SignIn, 6 | }; 7 | 8 | use axum::{ 9 | extract::{Form, FromRef, Query, State}, 10 | http::StatusCode, 11 | response::{IntoResponse, Redirect}, 12 | routing::get, 13 | Router, 14 | }; 15 | use axum_sessions::extractors::WritableSession; 16 | 17 | pub fn routes() -> Router 18 | where 19 | S: Send + Sync + 'static + Clone, 20 | crate::oauth::state::State: FromRef, 21 | Database: FromRef, 22 | { 23 | Router::new().route("/", get(get_signin).post(post_signin)) 24 | } 25 | 26 | async fn get_signin(query: Option>>) -> impl IntoResponse { 27 | let query = &query 28 | .as_ref() 29 | .and_then(|Query(x)| serde_urlencoded::to_string(x).ok()) 30 | .unwrap_or_default(); 31 | SignIn { query }.into_response() 32 | } 33 | 34 | async fn post_signin( 35 | State(db): State, 36 | query: Option>>, 37 | mut session: WritableSession, 38 | Form(user_form): Form, 39 | ) -> Result { 40 | let query = query.as_ref().map(|x| x.as_str()); 41 | 42 | tracing::debug!("entered -> post_signin()"); 43 | let user_record = db.get_user_by_name(&user_form.username).await; 44 | if user_record.is_err() { 45 | tracing::debug!(" user DOES NOT exist"); 46 | return Ok(( 47 | StatusCode::UNAUTHORIZED, 48 | SignIn { 49 | query: query.unwrap_or_default(), 50 | }, 51 | ) 52 | .into_response()); 53 | } 54 | let user_record = user_record.unwrap(); 55 | let authorized = db 56 | .verify_password(&user_form.username, &user_form.password) 57 | .await 58 | .map_err(|e| Error::Database { source: (e) })?; 59 | let _ = session.insert( 60 | "user", 61 | AuthUser { 62 | user_id: user_record.id().unwrap(), 63 | username: user_form.username, 64 | }, 65 | ); 66 | 67 | tracing::debug!(" checking authorization"); 68 | if !authorized { 69 | tracing::debug!(" NOT authorized"); 70 | Ok(( 71 | StatusCode::UNAUTHORIZED, 72 | SignIn { 73 | query: query.unwrap_or_default(), 74 | }, 75 | ) 76 | .into_response()) 77 | } else if let Some(query) = query { 78 | if !query.is_empty() { 79 | tracing::debug!(" redirect to callback: {}", query); 80 | Ok(Redirect::to(query).into_response()) 81 | } else { 82 | tracing::debug!(" redirect to /oauth/"); 83 | Ok(Redirect::to("/oauth/").into_response()) 84 | } 85 | } else { 86 | tracing::debug!(" redirect to /oauth/"); 87 | Ok(Redirect::to("/oauth/").into_response()) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/oauth/routes/signout.rs: -------------------------------------------------------------------------------- 1 | use axum::{http::StatusCode, response::IntoResponse, routing::post, Router}; 2 | use axum_sessions::extractors::WritableSession; 3 | 4 | pub fn routes() -> Router { 5 | Router::new().route("/", post(post_signout)) 6 | } 7 | 8 | async fn post_signout(mut session: WritableSession) -> impl IntoResponse { 9 | session.destroy(); 10 | 11 | StatusCode::OK 12 | } 13 | -------------------------------------------------------------------------------- /src/oauth/routes/signup.rs: -------------------------------------------------------------------------------- 1 | use super::{Callback, SignUpForm}; 2 | use crate::oauth::{ 3 | database::Database, 4 | error::{Error, Result}, 5 | }; 6 | use axum::{ 7 | extract::{Form, FromRef, Query, State}, 8 | http::StatusCode, 9 | routing::post, 10 | Router, 11 | }; 12 | use secrecy::Secret; 13 | 14 | pub fn routes() -> Router 15 | where 16 | S: Send + Sync + 'static + Clone, 17 | Database: FromRef, 18 | { 19 | Router::new().route("/", post(post_signup)) 20 | } 21 | 22 | async fn post_signup( 23 | State(mut db): State, 24 | _query: Option>>, 25 | Form(user): Form, 26 | ) -> Result { 27 | if db.contains_user_name(&user.username).await { 28 | return Err(Error::ResourceConflict); 29 | } 30 | 31 | db.register_user( 32 | &user.username, 33 | Secret::from(user.password), 34 | &user.given_name, 35 | ) 36 | .await; 37 | 38 | Ok(StatusCode::CREATED) 39 | } 40 | -------------------------------------------------------------------------------- /src/oauth/scopes.rs: -------------------------------------------------------------------------------- 1 | pub const SCOPES: &[&str] = &[Account::READ, Account::WRITE]; 2 | 3 | pub trait Resource { 4 | const READ: &'static str; 5 | const WRITE: &'static str; 6 | } 7 | 8 | pub struct Account; 9 | 10 | impl Resource for Account { 11 | const READ: &'static str = "account:read"; 12 | const WRITE: &'static str = "account:write"; 13 | } 14 | 15 | #[derive(Debug)] 16 | pub enum Scopes { 17 | AccountRead, 18 | AccountWrite, 19 | } 20 | 21 | impl std::str::FromStr for Scopes { 22 | type Err = (); 23 | 24 | fn from_str(s: &str) -> Result { 25 | Ok(match s { 26 | Account::READ => Self::AccountRead, 27 | Account::WRITE => Self::AccountWrite, 28 | _ => return Err(()), 29 | }) 30 | } 31 | } 32 | 33 | pub struct Read(pub S); 34 | pub struct Write(pub S); 35 | 36 | pub trait Scope { 37 | const SCOPE: &'static str; 38 | } 39 | 40 | impl Scope for () { 41 | const SCOPE: &'static str = ""; 42 | } 43 | 44 | impl Scope for Read { 45 | const SCOPE: &'static str = S::READ; 46 | } 47 | 48 | impl Scope for Write { 49 | const SCOPE: &'static str = S::WRITE; 50 | } 51 | -------------------------------------------------------------------------------- /src/oauth/solicitor.rs: -------------------------------------------------------------------------------- 1 | use crate::oauth::{ 2 | database::{ 3 | resource::{ 4 | client::AuthClient, 5 | user::{AuthUser, Authorization}, 6 | }, 7 | Database, 8 | }, 9 | templates::Authorize, 10 | }; 11 | use askama::Template; 12 | use oxide_auth::endpoint::{OwnerConsent, Solicitation, WebRequest}; 13 | use oxide_auth_async::endpoint::OwnerSolicitor; 14 | use oxide_auth_axum::{OAuthRequest, OAuthResponse, WebError}; 15 | 16 | pub struct Solicitor { 17 | db: Database, 18 | user: AuthUser, 19 | } 20 | 21 | impl Solicitor { 22 | pub fn new(db: Database, user: AuthUser) -> Self { 23 | tracing::debug!("db: XXXX, user: {:?}", user); 24 | Self { db, user } 25 | } 26 | } 27 | 28 | #[async_trait::async_trait] 29 | impl OwnerSolicitor for Solicitor { 30 | async fn check_consent( 31 | &mut self, 32 | req: &mut OAuthRequest, 33 | solicitation: Solicitation<'_>, 34 | ) -> OwnerConsent<::Response> { 35 | tracing::debug!("in check_consent()"); 36 | tracing::debug!("Request: {:?}", req); 37 | fn map_err( 38 | err: E, 39 | ) -> OwnerConsent<::Response> { 40 | OwnerConsent::Error(WebError::InternalError(Some(err.to_string()))) 41 | } 42 | 43 | let pre_g = solicitation.pre_grant(); 44 | tracing::debug!("PreGrant: {:?}", pre_g); 45 | 46 | let client_id = match solicitation 47 | .pre_grant() 48 | .client_id 49 | .parse::() 50 | .map_err(map_err) 51 | { 52 | Ok(id) => id, 53 | Err(err) => return err, 54 | }; 55 | 56 | // Is there already an authorization (user:client pair) ? 57 | // 58 | let previous_scope = self.db.get_scope(self.user.user_id, client_id.id).await; 59 | let authorization = previous_scope.map(|scope| Authorization { scope }); 60 | 61 | tracing::debug!("Current scope of client: {:?}", authorization); 62 | tracing::debug!( 63 | "Requested grant scope: {:?}", 64 | solicitation.pre_grant().scope 65 | ); 66 | match authorization { 67 | // Yes, there is and it's scope >= requested scope. Return authorized consent. 68 | Some(Authorization { scope }) if scope >= solicitation.pre_grant().scope => { 69 | return OwnerConsent::Authorized(self.user.to_string()) 70 | } 71 | 72 | // No, so continue on. 73 | _ => (), 74 | } 75 | 76 | // Attempt to get user and encoded client records 77 | let res = self.db.get_client_name(client_id.id).await.map_err(map_err); 78 | let client = match res { 79 | Ok(name) => name, 80 | Err(err) => return err, 81 | }; 82 | let res = self.db.get_user_by_id(&self.user).await.map_err(map_err); 83 | let user = match res { 84 | Ok(user) => user, 85 | Err(err) => return err, 86 | }; 87 | 88 | // create parameters for consent form and display it to the owner 89 | if let Some((client, user)) = Some(client).zip(Some(user)) { 90 | // username() is guaranteed to return a value because user was returned from the db 91 | let username = user.username().unwrap(); 92 | let body = Authorize::new(req, &solicitation, &username, &client.inner); 93 | 94 | match body.render().map_err(map_err) { 95 | Ok(inner) => OwnerConsent::InProgress( 96 | OAuthResponse::default() 97 | .content_type("text/html") 98 | .unwrap() 99 | .body(&inner), 100 | ), 101 | Err(err) => err, 102 | } 103 | } else { 104 | OwnerConsent::Denied 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/oauth/state.rs: -------------------------------------------------------------------------------- 1 | use oxide_auth::{ 2 | frontends::simple::endpoint::Vacant, 3 | primitives::{authorizer::AuthMap, generator::RandomGenerator, issuer::TokenMap}, 4 | }; 5 | use oxide_auth_async::primitives; 6 | use std::sync::Arc; 7 | use tokio::sync::Mutex; 8 | 9 | use super::endpoint::{extension::Empty, Endpoint}; 10 | use crate::oauth::database::Database; 11 | 12 | #[derive(Clone, axum_macros::FromRef)] 13 | pub struct State { 14 | registrar: Database, 15 | authorizer: Arc>>, 16 | issuer: Arc>>, 17 | } 18 | 19 | impl State { 20 | pub fn new(registrar: Database) -> Self { 21 | State { 22 | registrar, 23 | authorizer: Arc::new(Mutex::new(AuthMap::new(RandomGenerator::new(16)))), 24 | issuer: Arc::new(Mutex::new(TokenMap::new(RandomGenerator::new(16)))), 25 | } 26 | } 27 | 28 | pub async fn endpoint( 29 | &self, 30 | ) -> Endpoint<'_, impl primitives::Registrar, Empty, Vacant, Vacant> { 31 | Endpoint { 32 | registrar: &self.registrar, 33 | authorizer: self.authorizer.lock().await.into(), 34 | issuer: self.issuer.lock().await.into(), 35 | extension: Empty, 36 | solicitor: Vacant, 37 | scopes: Vacant, 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/oauth/templates.rs: -------------------------------------------------------------------------------- 1 | use askama::Template; 2 | 3 | use oxide_auth::endpoint::WebRequest; 4 | 5 | #[derive(Template)] 6 | #[template(path = "signin.html")] 7 | pub struct SignIn<'a> { 8 | pub query: &'a str, 9 | } 10 | 11 | #[derive(Template, Debug)] 12 | #[template(path = "authorize.html")] 13 | pub struct Authorize<'a> { 14 | pub query: String, 15 | pub client_name: &'a str, 16 | pub username: &'a str, 17 | pub scopes: String, 18 | } 19 | 20 | impl<'a> Authorize<'a> { 21 | pub fn new( 22 | req: &mut oxide_auth_axum::OAuthRequest, 23 | solicitation: &oxide_auth::endpoint::Solicitation<'a>, 24 | username: &'a str, 25 | client_name: &'a str, 26 | ) -> Self { 27 | tracing::debug!( 28 | "in Authorize::new()\nusername: {:?}, client name: {:?}\nRequest: {:?}", 29 | username, 30 | client_name, 31 | req 32 | ); 33 | let query = req.query().unwrap(); 34 | let grant = solicitation.pre_grant(); 35 | let state = solicitation.state(); 36 | let code_challenge = query 37 | .unique_value("code_challenge") 38 | .unwrap_or_default() 39 | .to_string(); 40 | let method = query 41 | .unique_value("code_challenge_method") 42 | .unwrap_or_default() 43 | .to_string(); 44 | let scope = grant.scope.to_string(); 45 | 46 | let mut extra = vec![ 47 | ("response_type", "code"), 48 | ("client_id", grant.client_id.as_str()), 49 | ("redirect_uri", grant.redirect_uri.as_str()), 50 | ("code_challenge", &code_challenge), 51 | ("code_challenge_method", &method), 52 | ("scope", &scope), 53 | ]; 54 | 55 | if let Some(state) = state { 56 | extra.push(("state", state)); 57 | } 58 | 59 | let query = serde_urlencoded::to_string(extra).unwrap(); 60 | 61 | Self { 62 | query, 63 | client_name, 64 | username, 65 | scopes: grant.scope.iter().collect::>().join(", "), 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/routes.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use crate::oauth::{ 4 | database::{resource::user::AuthUser, Database}, 5 | error::Error, 6 | models::{ClientId, UserId}, 7 | primitives::scopes::Grant, 8 | scopes::{Account, Read, Write}, 9 | }; 10 | use axum::{ 11 | extract::{FromRef, State}, 12 | routing::get, 13 | Json, Router, 14 | }; 15 | use serde::{Deserialize, Serialize}; 16 | 17 | pub fn routes() -> Router 18 | where 19 | crate::oauth::state::State: FromRef, 20 | S: Send + Sync + 'static + Clone, 21 | Database: FromRef, 22 | { 23 | Router::new().route("/user", get(user).post(update_account_name)) 24 | } 25 | 26 | #[derive(Debug, Serialize)] 27 | pub struct ClientInfo { 28 | pub id: ClientId, 29 | pub name: String, 30 | } 31 | 32 | #[derive(Debug, Serialize)] 33 | pub struct UserInfo { 34 | pub id: UserId, 35 | pub login: String, 36 | pub name: String, 37 | pub authorized_clients: Vec, 38 | } 39 | 40 | pub async fn user( 41 | State(db): State, 42 | grant: Grant>, 43 | ) -> Result, Error> { 44 | tracing::debug!("enter -> user()"); 45 | let u = grant.grant.owner_id; 46 | let user_record = db 47 | .get_user_by_id(&AuthUser::from_str(&u).unwrap()) 48 | .await 49 | .map_err(|e| Error::Database { source: e })?; 50 | let authorized_clients = user_record.get_authorized_clients(); 51 | let mut clients = Vec::::new(); 52 | for cauth in authorized_clients { 53 | let client_name = db 54 | .get_client_name(cauth.client_id) 55 | .await 56 | .map_err(|e| Error::Database { source: e })?; 57 | clients.push(ClientInfo { 58 | id: cauth.client_id, 59 | name: client_name.inner, 60 | }); 61 | } 62 | 63 | let user_info = UserInfo { 64 | id: user_record.id().unwrap(), 65 | login: user_record.username().unwrap(), 66 | name: user_record.given_name().unwrap(), 67 | authorized_clients: clients, 68 | }; 69 | 70 | Ok(Json(user_info)) 71 | } 72 | 73 | #[derive(Debug, Deserialize)] 74 | pub struct ChangeResource { 75 | pub given_name: String, 76 | } 77 | 78 | #[derive(Debug, Default, Serialize)] 79 | pub struct MsgReply { 80 | pub success: bool, 81 | } 82 | 83 | async fn update_account_name( 84 | State(mut db): State, 85 | grant: Grant>, 86 | Json(form): Json, 87 | ) -> Result, Error> { 88 | tracing::debug!("enter -> update_account_name()"); 89 | let u = grant.grant.owner_id; 90 | let success = db 91 | .update_given_name_by_id(&AuthUser::from_str(&u).unwrap(), &form.given_name) 92 | .await 93 | .map_err(|e| Error::Database { source: e })?; 94 | 95 | let res = MsgReply { success }; 96 | 97 | Ok(Json(res)) 98 | } 99 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | use async_session::MemoryStore; 2 | 3 | use crate::oauth::{database::Database, state::State as AuthState}; 4 | 5 | #[derive(Clone, axum_macros::FromRef)] 6 | pub struct AppState { 7 | pub sessions: MemoryStore, 8 | pub state: AuthState, 9 | pub database: Database, 10 | } 11 | -------------------------------------------------------------------------------- /svelte-frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /svelte-frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 5 | plugins: ['svelte3', '@typescript-eslint'], 6 | ignorePatterns: ['*.cjs'], 7 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 8 | settings: { 9 | 'svelte3/typescript': () => require('typescript') 10 | }, 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020 14 | }, 15 | env: { 16 | browser: true, 17 | es2017: true, 18 | node: true 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /svelte-frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | -------------------------------------------------------------------------------- /svelte-frontend/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /svelte-frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /svelte-frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "pluginSearchDirs": ["."], 8 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 9 | } 10 | -------------------------------------------------------------------------------- /svelte-frontend/README.md: -------------------------------------------------------------------------------- 1 | # create-svelte 2 | 3 | Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte). 4 | 5 | ## Creating a project 6 | 7 | If you're seeing this, you've probably already done this step. Congrats! 8 | 9 | ```bash 10 | # create a new project in the current directory 11 | npm create svelte@latest 12 | 13 | # create a new project in my-app 14 | npm create svelte@latest my-app 15 | ``` 16 | 17 | ## Developing 18 | 19 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 20 | 21 | ```bash 22 | npm run dev 23 | 24 | # or start the server and open the app in a new browser tab 25 | npm run dev -- --open 26 | ``` 27 | 28 | ## Building 29 | 30 | To create a production version of your app: 31 | 32 | ```bash 33 | npm run build 34 | ``` 35 | 36 | You can preview the production build with `npm run preview`. 37 | 38 | > To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. 39 | -------------------------------------------------------------------------------- /svelte-frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "axum-oauth-frontend", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 12 | "format": "prettier --plugin-search-dir . --write ." 13 | }, 14 | "devDependencies": { 15 | "@sveltejs/adapter-auto": "^2.0.0", 16 | "@sveltejs/kit": "^1.5.0", 17 | "@typescript-eslint/eslint-plugin": "^5.45.0", 18 | "@typescript-eslint/parser": "^5.45.0", 19 | "eslint": "^8.28.0", 20 | "eslint-config-prettier": "^8.5.0", 21 | "eslint-plugin-svelte3": "^4.0.0", 22 | "prettier": "^2.8.0", 23 | "prettier-plugin-svelte": "^2.8.1", 24 | "svelte": "^3.54.0", 25 | "svelte-check": "^3.0.1", 26 | "tslib": "^2.4.1", 27 | "typescript": "^4.9.3", 28 | "vite": "^4.0.0" 29 | }, 30 | "type": "module" 31 | } 32 | -------------------------------------------------------------------------------- /svelte-frontend/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | 7 | interface Locals { 8 | user: UserInfo; 9 | } 10 | 11 | // interface PageData {} 12 | // interface Platform {} 13 | 14 | interface ClientForm { 15 | name: string, 16 | redirect_uri: string, 17 | type: string, 18 | } 19 | } 20 | } 21 | 22 | export {}; 23 | -------------------------------------------------------------------------------- /svelte-frontend/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %sveltekit.head% 9 | 10 | 11 |
%sveltekit.body%
12 | 13 | 14 | -------------------------------------------------------------------------------- /svelte-frontend/src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import type { Handle } from "@sveltejs/kit"; 2 | import { getSession } from "./lib/session"; 3 | 4 | export const handle: Handle = (async ({ event, resolve }) => { 5 | 6 | const sid = event.cookies.get('session'); 7 | if (sid) { 8 | const session = getSession(sid); 9 | if (session) { 10 | event.locals.user = session.user_info; 11 | } else { 12 | event.cookies.delete(sid); 13 | } 14 | } 15 | 16 | return await resolve(event); 17 | }) satisfies Handle; 18 | -------------------------------------------------------------------------------- /svelte-frontend/src/lib/session.ts: -------------------------------------------------------------------------------- 1 | import { getUserInfo } from "./user"; 2 | import type { UserInfo } from "./user"; 3 | 4 | type SessionInfo = { 5 | access_token: string; 6 | user_info: UserInfo; 7 | invalidAt: number; 8 | 9 | }; 10 | 11 | type Sid = string; 12 | 13 | const sessionStore = new Map(); 14 | 15 | function getSid(): Sid { 16 | return crypto.randomUUID(); 17 | } 18 | 19 | export async function createSession(access_token: string, maxAge: number): Promise { 20 | let sid: Sid = ''; 21 | 22 | do { 23 | sid = getSid(); 24 | } while (sessionStore.has(sid)); 25 | 26 | updateSession(sid, access_token, maxAge); 27 | 28 | return sid; 29 | } 30 | 31 | export function getSession(sid: Sid): SessionInfo | undefined { 32 | const session = sessionStore.get(sid); 33 | if (session) { 34 | if (Date.now() > session.invalidAt) { 35 | console.log('delete invalid session', sid); 36 | sessionStore.delete(sid); 37 | return undefined; 38 | } else { 39 | return session; 40 | } 41 | } else { 42 | console.log('session not found', sid) 43 | return undefined; 44 | } 45 | } 46 | 47 | export function deleteSession(sid: Sid) { 48 | if (sessionStore.has(sid)) { 49 | sessionStore.delete(sid); 50 | } 51 | } 52 | 53 | export async function updateSession(sid: Sid, access_token: string, maxAge: number) { 54 | console.debug('enter updateSession()'); 55 | const uinfo: UserInfo = await getUserInfo(access_token); 56 | 57 | sessionStore.set(sid, { 58 | access_token, 59 | user_info: uinfo, 60 | invalidAt: Date.now() + maxAge, 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /svelte-frontend/src/lib/user.ts: -------------------------------------------------------------------------------- 1 | type ClientInfo = { 2 | id: string, 3 | name: string, 4 | } 5 | 6 | export type UserInfo = { 7 | id: string, 8 | login: string, 9 | name: string, 10 | authorized_clients: Array, 11 | } 12 | 13 | const userURL = 'http://localhost:3000/api/user' 14 | 15 | export async function getUserInfo(token: string): Promise { 16 | const user_info: UserInfo = await getUser(token); 17 | console.log(user_info); 18 | 19 | return user_info 20 | 21 | } 22 | 23 | function getUser(access_token: string): Promise { 24 | 25 | return fetch(userURL, { 26 | headers: { 27 | Accept: 'application/json', 28 | Authorization: `Bearer ${access_token}` 29 | } 30 | }) 31 | .then(r => r.json()) 32 | .catch(error => {console.debug(error); return {id: '', login: '', authorized_clients: []};}); 33 | 34 | } 35 | 36 | export async function updateName(access_token: string, name: string): Promise { 37 | 38 | const response = await fetch(userURL, { 39 | method: 'POST', 40 | headers: { 41 | 'Content-Type': 'application/json', 42 | Accept: 'application/json', 43 | Authorization: `Bearer ${access_token}` 44 | }, 45 | body: JSON.stringify({ 'given_name': name}), 46 | }); 47 | console.debug("response status: ", response.status, response.statusText); 48 | if (!response.ok) { 49 | return false; 50 | } 51 | 52 | return true; 53 | } 54 | -------------------------------------------------------------------------------- /svelte-frontend/src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutServerLoad } from "./$types"; 2 | 3 | export const load: LayoutServerLoad = async ({ locals }) => { 4 | const { user } = locals; 5 | 6 | return { user } 7 | } 8 | -------------------------------------------------------------------------------- /svelte-frontend/src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 6 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /svelte-frontend/src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { Actions, PageServerLoad } from './$types'; 2 | import { error, redirect } from '@sveltejs/kit'; 3 | import { createHash, randomBytes, type BinaryLike } from 'crypto'; 4 | import { client_id, client_secret, code_verifier, code_challenge } from './authorize/store.js'; 5 | 6 | interface ClientForm { 7 | name: string, 8 | redirect_uri: string, 9 | type: string, 10 | } 11 | 12 | type ClientCredential = { 13 | client_id: string, 14 | client_secret: string, 15 | } 16 | 17 | export const load: PageServerLoad = async (event) => { 18 | const { locals } = event; 19 | const user = locals.user; 20 | 21 | if (user) { 22 | return { user }; 23 | } 24 | 25 | } 26 | 27 | 28 | export const actions = { 29 | default: async ({ request }) => { 30 | 31 | const data = await request.formData(); 32 | const do_register = data.get('register_client'); 33 | if (do_register) { 34 | const client_cred = await register_client(); 35 | if (client_cred) { 36 | client_id.update(() => client_cred.client_id); 37 | client_secret.update(() => client_cred.client_secret); 38 | } else { 39 | throw error(500, 'unable to register client'); 40 | } 41 | } 42 | 43 | let c_id = ''; 44 | client_id.subscribe(value => { 45 | c_id = value; 46 | }); 47 | const verifier = base64URLEncode(randomBytes(32)); 48 | const challenge = base64URLEncode(sha256(verifier)); 49 | code_verifier.update(() => verifier); 50 | code_challenge.update(() => challenge); 51 | const authorization_url = 'http://localhost:3000/oauth/authorize?' + 52 | new URLSearchParams({ 53 | 'response_type': 'code', 54 | 'redirect_uri': 'http://localhost:5173/authorize', 55 | 'client_id': c_id, 56 | 'scope': 'account:read', 57 | 'code_challenge': challenge, 58 | 'code_challenge_method': 'S256', 59 | 'state': '12345', 60 | }).toString(); 61 | 62 | throw redirect(302, authorization_url); 63 | } 64 | } satisfies Actions; 65 | 66 | // Dependency: Node.js crypto module 67 | // https://nodejs.org/api/crypto.html#crypto_crypto 68 | function base64URLEncode(str: Buffer) { 69 | return str.toString('base64') 70 | .replace(/\+/g, '-') 71 | .replace(/\//g, '_') 72 | .replace(/=/g, ''); 73 | } 74 | 75 | // Dependency: Node.js crypto module 76 | // https://nodejs.org/api/crypto.html#crypto_crypto 77 | function sha256(buffer: BinaryLike) { 78 | return createHash('sha256').update(buffer).digest(); 79 | } 80 | 81 | async function register_client() { 82 | const form = { 83 | 'name': 'axum-oauth-frontend', 84 | 'redirect_uri': 'http://localhost:5173/authorize', 85 | 'type': 'confidential', 86 | }; 87 | const form_body = Object.keys(form).map(key => 88 | encodeURIComponent(key) + '=' + encodeURIComponent(form[key as keyof ClientForm])).join('&'); 89 | 90 | console.log("form_body: " + form_body); 91 | console.log("Fetching..."); 92 | const response = await fetch( 93 | 'http://localhost:3000/oauth/client', 94 | { 95 | method: 'POST', 96 | headers: { 97 | 'Content-Type': "application/x-www-form-urlencoded", 98 | }, 99 | body: form_body, 100 | } 101 | ); 102 | 103 | if (!response.ok) { 104 | const error = (await response.text()); 105 | console.log("error: unable to register client: " + error); 106 | return; 107 | } 108 | 109 | const client_cred: ClientCredential = JSON.parse(await response.text()); 110 | console.log(client_cred); 111 | 112 | return client_cred; 113 | } 114 | -------------------------------------------------------------------------------- /svelte-frontend/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 |
7 |
8 |
9 | {#if data?.user} 10 |
You are currently signed in
11 | {:else} 12 |
13 | 14 |
15 | 16 | Register a new client 17 |
18 |
19 | {/if} 20 |
21 |
22 |
23 | -------------------------------------------------------------------------------- /svelte-frontend/src/routes/authorize/+server.ts: -------------------------------------------------------------------------------- 1 | import { error, redirect } from '@sveltejs/kit'; 2 | 3 | import type { RequestHandler } from './$types'; 4 | 5 | import { client_id, client_secret, code_verifier } from './store.js'; 6 | import { createSession } from '$lib/session'; 7 | 8 | const tokenURL = 'http://localhost:3000/oauth/token' 9 | 10 | interface TokenRequest { 11 | grant_type: string, 12 | redirect_uri: string, 13 | code_verifier: string, 14 | code: string, 15 | } 16 | 17 | export const GET = ( async ({ cookies, url }) => { 18 | const code = url.searchParams.get('code') || ''; 19 | const state = url.searchParams.get('state') || ''; 20 | console.debug('Authorization code: ' + code); 21 | console.debug('State: ' + state); 22 | if (state != '12345') { 23 | throw error(500, "Authorization state does not match. Aborting."); 24 | } 25 | const token = await getAccessToken(code) 26 | console.debug('Access Token: ' + token); 27 | // access_token.update(() => token); 28 | 29 | const maxAge = 60 * 60 * 24 * 30; 30 | const session_id: string = await createSession(token, maxAge); 31 | 32 | cookies.set('session', session_id, { 33 | path: '/', 34 | httpOnly: true, 35 | sameSite: 'strict', 36 | secure: process.env.NODE_ENV === 'production', 37 | maxAge: maxAge, 38 | }); 39 | 40 | // const session_data: SessionData = { 41 | // id: cookie_token, 42 | // access_token: token, 43 | // }; 44 | // const db = session_db; 45 | // db.set(session_data); 46 | 47 | throw redirect(302, '/profile'); 48 | }) satisfies RequestHandler; 49 | 50 | function getAccessToken(code: string) { 51 | 52 | let c_id = ''; 53 | let c_secret = ''; 54 | let verifier = ''; 55 | client_id.subscribe(value => { 56 | c_id = value; 57 | }); 58 | client_secret.subscribe(value => { 59 | c_secret = value; 60 | }) 61 | code_verifier.subscribe(value => { 62 | verifier = value; 63 | }); 64 | const form = { 65 | grant_type: 'authorization_code', 66 | redirect_uri: 'http://localhost:5173/authorize', 67 | code_verifier: verifier, 68 | code, 69 | }; 70 | const form_body = Object.keys(form).map(key => 71 | encodeURIComponent(key) + '=' + encodeURIComponent(form[key as keyof TokenRequest])).join('&'); 72 | return fetch(tokenURL, { 73 | method: 'POST', 74 | headers: { 75 | 'Authorization': 'Basic ' + Buffer.from(c_id + ":" + c_secret).toString('base64'), 76 | 'Content-Type': "application/x-www-form-urlencoded", 77 | Accept: 'application/json' 78 | }, 79 | body: form_body, 80 | }).then(r => r.json()) 81 | .then(r => r.access_token) 82 | } 83 | -------------------------------------------------------------------------------- /svelte-frontend/src/routes/authorize/store.js: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | export const client_id = writable(''); 4 | export const client_secret = writable(''); 5 | export const code_verifier = writable(''); 6 | export const code_challenge = writable(''); 7 | export const access_token = writable(''); 8 | -------------------------------------------------------------------------------- /svelte-frontend/src/routes/login/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { Actions } from './$types'; 2 | import { redirect } from '@sveltejs/kit'; 3 | import { createHash, randomBytes, type BinaryLike } from 'crypto'; 4 | import { client_id, client_secret, code_verifier, code_challenge } from '../authorize/store.js'; 5 | 6 | interface ClientForm { 7 | name: string, 8 | redirect_uri: string, 9 | type: string, 10 | } 11 | 12 | type ClientCredential = { 13 | client_id: string, 14 | client_secret: string, 15 | } 16 | 17 | export const actions = { 18 | default: async ({ request }) => { 19 | 20 | const data = await request.formData(); 21 | const username = data.get('username'); 22 | const password = data.get('password'); 23 | const form = { 24 | 'name': 'axum-oauth-frontend', 25 | 'redirect_uri': 'http://localhost:5173/authorize', 26 | 'type': 'confidential', 27 | }; 28 | const form_body = Object.keys(form).map(key => 29 | encodeURIComponent(key) + '=' + encodeURIComponent(form[key as keyof ClientForm])).join('&'); 30 | 31 | console.log("form_body: " + form_body); 32 | console.log("Fetching..."); 33 | const regResponse = await fetch( 34 | 'http://localhost:3000/oauth/client', 35 | { 36 | method: 'POST', 37 | headers: { 38 | 'Content-Type': "application/x-www-form-urlencoded", 39 | }, 40 | body: form_body, 41 | } 42 | ); 43 | 44 | console.log("is response ok?"); 45 | if (!regResponse.ok) { 46 | const error = (await regResponse.text()); 47 | console.log(error); 48 | return; 49 | } 50 | 51 | const client_cred: ClientCredential = JSON.parse(await regResponse.text()); 52 | console.log(client_cred); 53 | client_id.update(() => client_cred.client_id); 54 | client_secret.update(() => client_cred.client_secret); 55 | 56 | const verifier = base64URLEncode(randomBytes(32)); 57 | const challenge = base64URLEncode(sha256(verifier)); 58 | code_verifier.update(() => verifier); 59 | code_challenge.update(() => challenge); 60 | const authorization_url = 'http://localhost:3000/oauth/authorize?' + 61 | new URLSearchParams({ 62 | 'response_type': 'code', 63 | 'redirect_uri': 'http://localhost:5173/authorize', 64 | 'client_id': client_cred.client_id, 65 | 'scope': 'account:read', 66 | 'code_challenge': challenge, 67 | 'code_challenge_method': 'S256', 68 | 'state': '12345', 69 | }).toString(); 70 | 71 | throw redirect(302, authorization_url); 72 | } 73 | } satisfies Actions; 74 | 75 | // Dependency: Node.js crypto module 76 | // https://nodejs.org/api/crypto.html#crypto_crypto 77 | function base64URLEncode(str: Buffer) { 78 | return str.toString('base64') 79 | .replace(/\+/g, '-') 80 | .replace(/\//g, '_') 81 | .replace(/=/g, ''); 82 | } 83 | 84 | // Dependency: Node.js crypto module 85 | // https://nodejs.org/api/crypto.html#crypto_crypto 86 | function sha256(buffer: BinaryLike) { 87 | return createHash('sha256').update(buffer).digest(); 88 | } 89 | -------------------------------------------------------------------------------- /svelte-frontend/src/routes/login/+page.svelte: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |
6 |

Sign in

7 |

Please sign in to continue

8 |
9 |
10 |
11 | 12 | 13 |
14 |
15 | 16 | 17 |
18 | 19 | Don't have an account yet? Please Register. 20 |
21 |
22 |
23 |
24 | -------------------------------------------------------------------------------- /svelte-frontend/src/routes/logout/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { Actions } from "@sveltejs/kit"; 2 | import { deleteSession } from '$lib/session'; 3 | import { redirect } from '@sveltejs/kit'; 4 | 5 | export const actions = { 6 | default: async (event ) => { 7 | const { cookies, locals } = event; 8 | const sid = cookies.get('session'); 9 | if (sid) { 10 | deleteSession(sid); 11 | cookies.delete('session'); 12 | locals.user = undefined; 13 | } else { 14 | console.log("logout: no valid session cookie found"); 15 | } 16 | 17 | throw redirect(302, '/'); 18 | 19 | } 20 | } satisfies Actions; 21 | -------------------------------------------------------------------------------- /svelte-frontend/src/routes/profile/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | import type { PageServerLoad } from './$types'; 3 | 4 | export const load: PageServerLoad = async (event) => { 5 | const { locals } = event; 6 | const user = locals.user; 7 | 8 | if (user) { 9 | return { user }; 10 | } 11 | 12 | throw redirect(302, '/'); 13 | } 14 | -------------------------------------------------------------------------------- /svelte-frontend/src/routes/profile/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 |
7 |

Profile

8 |
9 |

Hi 10 | {data.user.name} 11 | 👋 12 |

13 |

Your Id is: {data.user.id}

14 |
Authorized Clients
15 |
    16 | {#each data.user.authorized_clients as c} 17 |
  • 18 | {c.name} 19 |
  • 20 | {/each} 21 |
22 |
23 |
-------------------------------------------------------------------------------- /svelte-frontend/src/routes/protected/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getSession, updateSession } from '$lib/session'; 2 | import { updateName } from '$lib/user'; 3 | import { redirect } from '@sveltejs/kit'; 4 | import type { PageServerLoad } from './$types'; 5 | 6 | export const load: PageServerLoad = async (event) => { 7 | const { cookies, locals } = event; 8 | const sid = cookies.get('session'); 9 | const maxAge = 60 * 60 * 24 * 30; 10 | let error = ''; 11 | if (sid) { 12 | const session = getSession(sid); 13 | if (session) { 14 | const res = await updateName(session.access_token, 'Alice'); 15 | if (res) { 16 | updateSession(sid, session.access_token, maxAge); 17 | } else { 18 | error = 'Unable to set user\'s Name. Check the console for errors.'; 19 | } 20 | } 21 | } else { 22 | throw redirect(302, '/'); 23 | } 24 | const user = locals.user; 25 | if (user) { 26 | return { user, error }; 27 | } 28 | 29 | throw redirect(302, '/'); 30 | } 31 | -------------------------------------------------------------------------------- /svelte-frontend/src/routes/protected/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 |
7 |
8 |

Hi 9 | {#if data.user} 10 | {data?.user.name} 11 | {:else} 12 | ??? 13 | {/if} 14 | 👋 15 |

16 | {#if data.error} 17 |

18 | {data.error} 19 |

20 | {/if} 21 |
22 |
-------------------------------------------------------------------------------- /svelte-frontend/src/stores.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryDatabase } from './lib/db'; 2 | import type { SessionData } from './lib/db'; 3 | 4 | export const session_db = new InMemoryDatabase(); 5 | -------------------------------------------------------------------------------- /svelte-frontend/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtelahun/axum-oauth/7bcac8eb220eb408413eea26f65164214b6c7565/svelte-frontend/static/favicon.png -------------------------------------------------------------------------------- /svelte-frontend/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/kit/vite'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter() 15 | } 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /svelte-frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 14 | // 15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 16 | // from the referenced tsconfig.json - TypeScript does not merge them in 17 | } 18 | -------------------------------------------------------------------------------- /svelte-frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()] 6 | }); 7 | -------------------------------------------------------------------------------- /templates/authorize.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Authorize{% endblock %} 3 | {% block content %} 4 |
5 |
6 |
7 |

Authorize {{ client_name }}

8 |

{{ client_name }} wants access to your {{ username }} account with the following permissions:

9 |

{{ scopes }}

10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %}{% endblock %} - Axum OAuth 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 26 | 27 | 28 | 29 |
30 | {% block content %}{% endblock %} 31 |
32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /templates/signin.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Sign in{% endblock %} 3 | {% block content %} 4 |
5 |
6 |
7 |

Sign in

8 |

Sign in to an existing account

9 |
10 |
11 | 12 | 13 | 14 |
15 | Don't have an account? Sign up 16 |
17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /tests/api/client.rs: -------------------------------------------------------------------------------- 1 | use csrf::CsrfToken; 2 | use serde::Serialize; 3 | 4 | use crate::helpers::{spawn_app, ClientResponse, ClientType, Token}; 5 | 6 | #[derive(Debug, Serialize)] 7 | struct AuthorizationQuery { 8 | client_id: String, 9 | client_secret: String, 10 | redirect_uri: String, 11 | response_type: String, 12 | } 13 | 14 | #[tokio::test] 15 | pub async fn register_client_form_errors() { 16 | // Arrange 17 | let state = spawn_app().await; 18 | let client = state.api_client; 19 | let invalid_cases = [ 20 | ( 21 | serde_json::json!({ 22 | "redirect_uri": "https://foo/authorized", 23 | "type": "confidential", 24 | }), 25 | "missing client name", 26 | ), 27 | ( 28 | serde_json::json!({ 29 | "name": "foo client", 30 | "type": "confidential", 31 | }), 32 | "missing redirect URI", 33 | ), 34 | ( 35 | serde_json::json!({ 36 | "name": "foo client", 37 | "redirect_uri": "https://foo/authorized", 38 | "type": "wrong_type", 39 | }), 40 | "wrong client type", 41 | ), 42 | ( 43 | serde_json::json!({ 44 | "name": "foo client", 45 | "redirect_uri": "https://foo/authorized", 46 | }), 47 | "missing client type", 48 | ), 49 | (serde_json::json!({}), "all fields missing"), 50 | ]; 51 | 52 | for (case, msg) in invalid_cases { 53 | // Act 54 | let response = client 55 | .post(&format!("{}/oauth/client", &state.app_address)) 56 | .form(&case) 57 | .send() 58 | .await 59 | .expect("request to server api failed"); 60 | 61 | // Assert 62 | assert_eq!( 63 | response.status().as_u16(), 64 | 422, 65 | "{}: returns client error status", 66 | msg 67 | ); 68 | } 69 | } 70 | 71 | #[tokio::test] 72 | pub async fn happy_path_register_client_confidential() { 73 | // Arrange 74 | let state = spawn_app().await; 75 | let params = serde_json::json!({ 76 | "name": "foo client", 77 | "redirect_uri": "https://foo/authorized", 78 | "type": "confidential", 79 | }); 80 | 81 | // Act 82 | let response = state 83 | .api_client 84 | .post(&format!("{}/oauth/client", &state.app_address)) 85 | .form(¶ms) 86 | .send() 87 | .await 88 | .expect("request to server api failed"); 89 | 90 | // Assert 91 | assert_eq!( 92 | response.status().as_u16(), 93 | 200, 94 | "client registration form processed successfully" 95 | ); 96 | let res = response 97 | .json::() 98 | .await 99 | .expect("Failed to get response body"); 100 | 101 | let client_secret = res 102 | .client_secret 103 | .clone() 104 | .map_or(String::new(), |secret| secret); 105 | 106 | assert!( 107 | !res.client_id.is_empty(), 108 | "The client_id field of the response is NOT empty" 109 | ); 110 | assert!( 111 | !client_secret.is_empty(), 112 | "The client_secret field of the response is NOT empty" 113 | ); 114 | assert_eq!( 115 | client_secret.len(), 116 | 32, 117 | "The client_secret field contains a response of the right length for nanoid output" 118 | ); 119 | } 120 | 121 | #[tokio::test] 122 | pub async fn happy_path_register_client_public() { 123 | // Arrange 124 | let state = spawn_app().await; 125 | let form = serde_json::json!({ 126 | "name": "foo client", 127 | "redirect_uri": "https://foo/authorized", 128 | "type": "public", 129 | }); 130 | 131 | // Act 132 | let response = state 133 | .api_client 134 | .post(&format!("{}/oauth/client", &state.app_address)) 135 | .form(&form) 136 | .send() 137 | .await 138 | .expect("request to server api failed"); 139 | 140 | // Assert 141 | assert_eq!( 142 | response.status().as_u16(), 143 | 200, 144 | "client registration form processed successfully" 145 | ); 146 | let res = response 147 | .json::() 148 | .await 149 | .expect("Failed to get response body"); 150 | let client_secret = res 151 | .client_secret 152 | .clone() 153 | .map_or(String::new(), |secret| secret); 154 | assert!( 155 | !res.client_id.is_empty(), 156 | "The client_id field of the response is NOT empty" 157 | ); 158 | assert!( 159 | client_secret.is_empty(), 160 | "The client_secret field of a public client IS empty" 161 | ); 162 | } 163 | 164 | #[tokio::test] 165 | pub async fn happy_path_confidential_client_authorization_flow() { 166 | // Arrange - 1 167 | let state = spawn_app().await; 168 | let params = serde_json::json!({ 169 | "name": "foo client", 170 | "redirect_uri": "http://localhost:3001/endpoint", 171 | "type": "confidential", 172 | }); 173 | state.signin("bob", "secret").await; 174 | let res = state 175 | .register_client(¶ms, ClientType::Confidential) 176 | .await; 177 | 178 | let code_verifier = pkce::code_verifier(128); 179 | let code_challenge = pkce::code_challenge(&code_verifier); 180 | let csrf_token = CsrfToken::new(nanoid::nanoid!().into_bytes()).b64_string(); 181 | let query = serde_json::json!({ 182 | "response_type": "code", 183 | "redirect_uri": "http://localhost:3001/endpoint", 184 | "client_id": res.client_id.clone(), 185 | "scope": "account:read account:write account:follow", 186 | "code_challenge": code_challenge, 187 | "code_challenge_method": "S256", 188 | "state": csrf_token, 189 | }); 190 | 191 | // Act - 1 192 | let body = state.get_consent_prompt_confidential(&query).await; 193 | let consent_response = state.owner_consent_allow(&body).await; 194 | let authorization_code = state 195 | .capture_authorizer_redirect( 196 | &res, 197 | &consent_response, 198 | ClientType::Confidential, 199 | &csrf_token, 200 | ) 201 | .await; 202 | 203 | // Arrange - 2 204 | let cv = String::from_utf8_lossy(&code_verifier); 205 | let params = vec![ 206 | ("grant_type", "authorization_code"), 207 | ("redirect_uri", "http://localhost:3001/endpoint"), 208 | ("code", &authorization_code), 209 | ("code_verifier", &cv), 210 | ]; 211 | 212 | // Act - 2 213 | let token = state 214 | .exchange_auth_code_for_token(&res, ClientType::Confidential, ¶ms) 215 | .await; 216 | 217 | // Act - 3 218 | let refresh_token = token.refresh_token.clone().unwrap(); 219 | let params = vec![ 220 | ("grant_type", "refresh_token"), 221 | ("refresh_token", &refresh_token), 222 | ("scope", "account:read account:write"), 223 | ]; 224 | let refreshed_token = state 225 | .refresh_token( 226 | &res, 227 | ClientType::Confidential, 228 | ¶ms, 229 | token.access_token.unwrap(), 230 | ) 231 | .await; 232 | 233 | // Assert - 3 234 | let json_user = r#""login":"bob","name":"Robert","authorized_clients":"#; 235 | state 236 | .access_resource_success(&refreshed_token.access_token.unwrap(), json_user) 237 | .await; 238 | } 239 | 240 | #[tokio::test] 241 | pub async fn happy_path_public_client_authorization_flow() { 242 | // Arrange - 1 243 | let state = spawn_app().await; 244 | let params = serde_json::json!({ 245 | "name": "foo client", 246 | "redirect_uri": "http://localhost:3001/endpoint", 247 | "type": "public", 248 | }); 249 | let res = state.register_client(¶ms, ClientType::Public).await; 250 | state.signin("bob", "secret").await; 251 | 252 | let code_verifier = pkce::code_verifier(128); 253 | let code_challenge = pkce::code_challenge(&code_verifier); 254 | let csrf_token = CsrfToken::new(nanoid::nanoid!().into_bytes()).b64_string(); 255 | let query = serde_json::json!({ 256 | "response_type": "code", 257 | "redirect_uri": "http://localhost:3001/endpoint", 258 | "client_id": res.client_id.clone(), 259 | "scope": "account:read account:write", 260 | "code_challenge": code_challenge, 261 | "code_challenge_method": "S256", 262 | "state": csrf_token, 263 | }); 264 | 265 | // Act - 1 266 | let body = state.get_consent_prompt_public(&&query).await; 267 | let consent_response = state.owner_consent_allow(&body).await; 268 | let authorization_code = state 269 | .capture_authorizer_redirect(&res, &consent_response, ClientType::Public, &csrf_token) 270 | .await; 271 | 272 | // Arrange - 2 273 | let cv = String::from_utf8_lossy(&code_verifier); 274 | let params = vec![ 275 | ("grant_type", "authorization_code"), 276 | ("redirect_uri", "http://localhost:3001/endpoint"), 277 | ("code", &authorization_code), 278 | ("client_id", &res.client_id), 279 | ("code_verifier", &cv), 280 | ]; 281 | 282 | // Act - 2 283 | let token = state 284 | .exchange_auth_code_for_token(&res, ClientType::Public, ¶ms) 285 | .await; 286 | 287 | // Act - 3 288 | let refresh_token = token.refresh_token.clone().unwrap(); 289 | let params = vec![ 290 | ("grant_type", "refresh_token"), 291 | ("refresh_token", &refresh_token), 292 | ("scope", "account:read account:write"), 293 | ]; 294 | let refreshed_token = state 295 | .refresh_token( 296 | &res, 297 | ClientType::Public, 298 | ¶ms, 299 | token.access_token.unwrap(), 300 | ) 301 | .await; 302 | 303 | // Assert - 3 304 | let json_user = r#""login":"bob","name":"Robert","authorized_clients":"#; 305 | state 306 | .access_resource_success(&refreshed_token.access_token.unwrap(), json_user) 307 | .await; 308 | } 309 | 310 | #[tokio::test] 311 | #[ignore] 312 | pub async fn happy_path_client_credentials_authorization_flow() { 313 | // Arrange 1 314 | let state = spawn_app().await; 315 | let params = serde_json::json!({ 316 | "name": "foo service client", 317 | "redirect_uri": "http://localhost:3001/endpoint", 318 | "type": "confidential", 319 | }); 320 | state.signin("bob", "secret").await; 321 | let res = state 322 | .register_client(¶ms, ClientType::Confidential) 323 | .await; 324 | 325 | let params = vec![ 326 | ("grant_type", "client_credentials"), 327 | ("scope", "account:read account:write"), 328 | ]; 329 | 330 | // Act 1 331 | // let token = state.exchange_auth_code_for_token(&res, ClientType::Confidential, ¶ms).await; 332 | let response = state 333 | .api_client 334 | .post(format!("{}/oauth/token", state.app_address)) 335 | .basic_auth( 336 | res.client_id.clone(), 337 | Some(res.client_secret.clone().unwrap()), 338 | ) 339 | .form(¶ms) 340 | .send() 341 | .await 342 | .expect("failed to get response from api client"); 343 | 344 | // Assert 1 345 | let code = response.status().as_u16(); 346 | let token = response.text().await.expect("failed to get response body"); 347 | tracing::debug!("Token Response: {:?}", token); 348 | assert_eq!(code, 200, "Request for access token returns successfully"); 349 | let token: Token = serde_json::from_str(token.as_str()).unwrap(); 350 | assert_eq!(token.token_type, "bearer", "Token is a Bearer token"); 351 | for s in ["account:read", "account:write"] { 352 | assert!(token.scope.contains(s), "Token scope includes {}", s); 353 | } 354 | assert!( 355 | !token.access_token.is_none(), 356 | "Access token contains a value" 357 | ); 358 | assert!( 359 | token.error.is_none(), 360 | "Error value of token response is empty" 361 | ); 362 | 363 | // Assert 2 364 | state 365 | .access_resource_success(&token.access_token.unwrap(), res.client_id.clone().as_str()) 366 | .await; 367 | } 368 | -------------------------------------------------------------------------------- /tests/api/helpers.rs: -------------------------------------------------------------------------------- 1 | use csrf::CsrfToken; 2 | use once_cell::sync::Lazy; 3 | use regex::Regex; 4 | use serde::{Deserialize, Serialize}; 5 | use serde_json::Value; 6 | use tokio::task::JoinHandle; 7 | use tracing::subscriber::set_global_default; 8 | use tracing::Subscriber; 9 | use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer}; 10 | use tracing_log::LogTracer; 11 | use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt, EnvFilter, Registry}; 12 | 13 | pub fn get_subscriber( 14 | name: String, 15 | env_filter: String, 16 | sink: Sink, 17 | ) -> impl Subscriber + Send + Sync 18 | where 19 | Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static, 20 | { 21 | // Default to printing spans at info-level if RUST_LOG isn't set 22 | let env_filter = 23 | EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter)); 24 | 25 | let formatting_layer = BunyanFormattingLayer::new(name, sink); 26 | 27 | Registry::default() 28 | .with(env_filter) 29 | .with(JsonStorageLayer) 30 | .with(formatting_layer) 31 | } 32 | 33 | pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) { 34 | // Redirect all of `log`'s events to subscriber 35 | LogTracer::init().expect("Failed to set logger"); 36 | set_global_default(subscriber).expect("Failed to set tracing subscriber"); 37 | } 38 | 39 | // Just copied trait bounds and signature from `spawn_blocking` 40 | #[allow(dead_code)] 41 | pub fn spawn_blocking_with_tracing(f: F) -> JoinHandle 42 | where 43 | F: FnOnce() -> R + Send + 'static, 44 | R: Send + 'static, 45 | { 46 | let current_span = tracing::Span::current(); 47 | tokio::task::spawn_blocking(move || current_span.in_scope(f)) 48 | } 49 | 50 | #[derive(Debug, Default)] 51 | pub struct TestState { 52 | pub app_address: String, 53 | pub port: u16, 54 | pub api_client: reqwest::Client, 55 | pub token: Token, 56 | } 57 | 58 | impl TestState { 59 | pub async fn signin(&self, username: &str, password: &str) { 60 | let form = serde_json::json!({ 61 | "username": username, 62 | "password": password, 63 | }); 64 | 65 | let response = self 66 | .api_client 67 | .post(&format!("{}/oauth/signin", &self.app_address)) 68 | .form(&form) 69 | .send() 70 | .await 71 | .expect("request to server api failed"); 72 | 73 | assert_eq!( 74 | response.status().as_u16(), 75 | 303, 76 | "correct credentials result in 303 redirect to oauth root uri", 77 | ); 78 | assert_is_redirect_to(&response, 303, "/oauth/", false); 79 | } 80 | 81 | pub async fn register_client(&self, params: &Value, client_type: ClientType) -> ClientResponse { 82 | // Arrange 83 | tracing::debug!("Test::POST /oauth/client (Register Client)"); 84 | 85 | // Act 86 | let response = self 87 | .api_client 88 | .post(&format!("{}/oauth/client", self.app_address)) 89 | .form(params) 90 | .send() 91 | .await 92 | .expect("request to server api failed"); 93 | 94 | // Assert 95 | assert_eq!( 96 | response.status().as_u16(), 97 | 200, 98 | "client registration form processed successfully" 99 | ); 100 | let res = response 101 | .json::() 102 | .await 103 | .expect("Failed to get response body"); 104 | let client_secret = res 105 | .client_secret 106 | .clone() 107 | .map_or(String::new(), |secret| secret); 108 | assert!( 109 | !res.client_id.is_empty(), 110 | "The client_id field of the response is NOT empty" 111 | ); 112 | match client_type { 113 | ClientType::Confidential => { 114 | assert!( 115 | !client_secret.is_empty(), 116 | "The client_secret field of the response is NOT empty" 117 | ); 118 | assert_eq!( 119 | client_secret.len(), 120 | 32, 121 | "The client_secret field contains a response of the right length for nanoid output" 122 | ); 123 | } 124 | ClientType::Public => { 125 | assert!( 126 | client_secret.is_empty(), 127 | "The client_secret field of the response IS empty" 128 | ); 129 | } 130 | } 131 | 132 | res 133 | } 134 | 135 | pub async fn get_consent_prompt_confidential(&self, query: &Value) -> String { 136 | // Arrange 137 | tracing::debug!("Test::GET /oauth/authorize? (Get authorization)"); 138 | 139 | // Act 140 | let response = self 141 | .api_client 142 | .get(format!("{}/oauth/authorize", self.app_address)) 143 | .query(&query) 144 | .send() 145 | .await 146 | .expect("failed to get response from api client"); 147 | 148 | // Assert 149 | assert_eq!( 150 | response.status().as_u16(), 151 | 200, 152 | "The authorization endpoint returns successfully" 153 | ); 154 | let body = response 155 | .text() 156 | .await 157 | .expect("unable to decode response from dummy client"); 158 | tracing::debug!("consent page:\n{}", body); 159 | assert!( 160 | body.contains("

Authorize foo client

"), 161 | "The authorization endpoint returned a consent page that shows the client name" 162 | ); 163 | assert!( 164 | body.contains("

foo client wants access to your bob account"), 165 | "The authorization endpoint returned a consent page that shows the target resource" 166 | ); 167 | // let token: Token = serde_json::from_str(body.as_str()).unwrap(); 168 | // for s in ["account:read", "account:write"] { 169 | // assert!(token.scope.contains(s), "Token scope includes {}", s); 170 | // } 171 | 172 | body 173 | } 174 | 175 | pub async fn get_consent_prompt_public(&self, query: &Value) -> String { 176 | // Arrange 177 | tracing::debug!("Test::GET /oauth/authorize? (Get authorization)"); 178 | 179 | // Act 180 | let response = self 181 | .api_client 182 | .get(format!("{}/oauth/authorize", self.app_address)) 183 | .query(&query) 184 | .send() 185 | .await 186 | .expect("failed to get response from api client"); 187 | 188 | // Assert 189 | let status = response.status().as_u16(); 190 | let body = response 191 | .text() 192 | .await 193 | .expect("unable to decode response from dummy client"); 194 | tracing::debug!("consent page:\n{}", body); 195 | assert_eq!( 196 | status, 200, 197 | "The authorization endpoint returns successfully" 198 | ); 199 | assert!( 200 | body.contains("

Authorize foo client

"), 201 | "The authorization endpoint returned a consent page that shows the client name" 202 | ); 203 | assert!( 204 | body.contains("

foo client wants access to your bob account"), 205 | "The authorization endpoint returned a consent page that shows the target resource" 206 | ); 207 | // let token: Token = serde_json::from_str(body.as_str()).unwrap(); 208 | // for s in ["account:read", "account:write"] { 209 | // assert!(token.scope.contains(s), "Token scope includes {}", s); 210 | // } 211 | 212 | body 213 | } 214 | 215 | pub async fn owner_consent_allow(&self, body: &str) -> String { 216 | let re_action = Regex::new("formaction=\"(.*)\"").unwrap(); 217 | let caps = re_action.captures(&body).unwrap(); 218 | let allow_path = caps.get(1).map_or("/", |m| m.as_str()); 219 | let allow_path = urlencoding::decode(allow_path).expect("failed to decode formaction"); 220 | let allow_path = html_escape::decode_html_entities(&allow_path); 221 | let allow_uri = format!("{}/oauth/{}", self.app_address, allow_path); 222 | tracing::debug!("allow uri: {}", allow_uri); 223 | 224 | allow_uri 225 | } 226 | 227 | pub async fn capture_authorizer_redirect( 228 | &self, 229 | client: &ClientResponse, 230 | consent_response: &str, 231 | client_type: ClientType, 232 | csrf: &str, 233 | ) -> String { 234 | // Send response from owner consent to authorization endpoint 235 | let client_request = self.api_client.post(consent_response); 236 | let client_request = match client_type { 237 | ClientType::Confidential => client_request.basic_auth( 238 | client.client_id.clone(), 239 | Some(client.client_secret.clone().unwrap()), 240 | ), 241 | _ => client_request, 242 | }; 243 | let response = client_request 244 | .send() 245 | .await 246 | .expect("failed to get response from api client"); 247 | assert_is_redirect_to(&response, 302, "http://localhost:3001/endpoint?code=", true); 248 | 249 | // Get access token from authorizer redirect 250 | let location = response 251 | .headers() 252 | .get("Location") 253 | .unwrap() 254 | .to_str() 255 | .expect("failed to get redirect location"); 256 | tracing::debug!("Client redirect: {}", location); 257 | let re_code = Regex::new("\\?code=(.*)\\&").unwrap(); 258 | let caps = re_code.captures(&location).unwrap(); 259 | let code = caps.get(1).map_or("X", |m| m.as_str()); 260 | let code = urlencoding::decode(code).expect("failed to decode authorization code"); 261 | tracing::debug!("Extracted code: {}", code); 262 | 263 | let re_code = Regex::new("\\&state=(.*)").unwrap(); 264 | let caps = re_code.captures(&location).unwrap(); 265 | let state = caps.get(1).map_or("X", |m| m.as_str()); 266 | let state = urlencoding::decode(state).expect("failed to decode state"); 267 | tracing::debug!("Extracted state: {}", state); 268 | 269 | assert_eq!( 270 | state, csrf, 271 | "Oauth state returned from authorization endpoint matches" 272 | ); 273 | 274 | code.into_owned() 275 | } 276 | 277 | pub async fn exchange_auth_code_for_token( 278 | &self, 279 | client: &ClientResponse, 280 | client_type: ClientType, 281 | params: &Vec<(&str, &str)>, 282 | ) -> Token { 283 | // Act 284 | let client_request = self 285 | .api_client 286 | .post(format!("{}/oauth/token", self.app_address)); 287 | let client_request = match client_type { 288 | ClientType::Confidential => client_request.basic_auth( 289 | client.client_id.clone(), 290 | Some(client.client_secret.clone().unwrap()), 291 | ), 292 | _ => client_request, 293 | }; 294 | let response = client_request 295 | .form(params) 296 | .send() 297 | .await 298 | .expect("failed to get response from api client"); 299 | 300 | // Assert 301 | let status = response.status().as_u16(); 302 | let token = response.text().await.expect("failed to get response body"); 303 | tracing::debug!("Token Response: {:?}", token); 304 | assert_eq!(status, 200, "Request for access token returns successfully"); 305 | let token: Token = serde_json::from_str(token.as_str()).unwrap(); 306 | assert_eq!(token.token_type, "bearer", "Token is a Bearer token"); 307 | for s in ["account:read", "account:write"] { 308 | assert!(token.scope.contains(s), "Token scope includes {}", s); 309 | } 310 | assert!( 311 | !token.access_token.is_none(), 312 | "Access token contains a value" 313 | ); 314 | assert!( 315 | !token.refresh_token.is_none(), 316 | "Refresh token contains a value" 317 | ); 318 | assert!( 319 | token.error.is_none(), 320 | "Error value of token response is empty" 321 | ); 322 | 323 | token 324 | } 325 | 326 | pub async fn refresh_token( 327 | &self, 328 | client: &ClientResponse, 329 | client_type: ClientType, 330 | params: &Vec<(&str, &str)>, 331 | str_old_token: String, 332 | ) -> Token { 333 | // Act 334 | let client_request = self 335 | .api_client 336 | .post(format!("{}/oauth/token", self.app_address)); 337 | let client_request = match client_type { 338 | ClientType::Confidential => client_request.basic_auth( 339 | client.client_id.clone(), 340 | Some(client.client_secret.clone().unwrap()), 341 | ), 342 | _ => client_request, 343 | }; 344 | let response = client_request 345 | .form(¶ms) 346 | .send() 347 | .await 348 | .expect("failed to get response from api client"); 349 | 350 | // Assert 351 | let status = response.status().as_u16(); 352 | let token = response.text().await.expect("failed to get response body"); 353 | tracing::debug!("Token Response: {:?}", token); 354 | assert_eq!( 355 | status, 200, 356 | "Request for refresh token returns successfully" 357 | ); 358 | let token: Token = serde_json::from_str(token.as_str()).unwrap(); 359 | assert_eq!(token.token_type, "bearer", "Token is a Bearer token"); 360 | for s in ["account:read", "account:write"] { 361 | assert!(token.scope.contains(s), "Token scope includes {}", s); 362 | } 363 | let new_token = token.access_token.clone().unwrap(); 364 | assert!( 365 | !token.access_token.is_none() && new_token != str_old_token, 366 | "New access token is different from the previous token" 367 | ); 368 | assert!( 369 | !token.refresh_token.is_none(), 370 | "Refresh token contains a value" 371 | ); 372 | assert!( 373 | token.error.is_none(), 374 | "Error value of token response is empty" 375 | ); 376 | 377 | token 378 | } 379 | 380 | pub async fn access_resource_success(&self, token: &str, substr: &str) { 381 | // Act 382 | let response = self 383 | .api_client 384 | .get(&format!("{}/api/user", &self.app_address)) 385 | .bearer_auth(token) 386 | .send() 387 | .await 388 | .expect("request to client api failed"); 389 | 390 | // Assert 391 | assert_eq!( 392 | response.status().as_u16(), 393 | 200, 394 | "Access to protected resource succeeded" 395 | ); 396 | let body = response.text().await.unwrap(); 397 | tracing::debug!("protected resource: {}", body); 398 | assert!( 399 | body.contains(substr), 400 | "Confirm access to protected resource" 401 | ); 402 | } 403 | 404 | pub async fn authorization_flow(&mut self, client: &ClientResponse) { 405 | let code_verifier = pkce::code_verifier(128); 406 | let code_challenge = pkce::code_challenge(&code_verifier); 407 | let csrf_token = CsrfToken::new(nanoid::nanoid!().into_bytes()).b64_string(); 408 | let query = serde_json::json!({ 409 | "response_type": "code", 410 | "redirect_uri": "http://localhost:3001/endpoint", 411 | "client_id": client.client_id.clone(), 412 | "scope": "account:read account:write", 413 | "code_challenge": code_challenge, 414 | "code_challenge_method": "S256", 415 | "state": csrf_token, 416 | }); 417 | 418 | // Owner consent prompt + allow response + authorization code 419 | let body = self.get_consent_prompt_confidential(&query).await; 420 | let consent_response = self.owner_consent_allow(&body).await; 421 | let authorization_code = self 422 | .capture_authorizer_redirect( 423 | client, 424 | &consent_response, 425 | ClientType::Confidential, 426 | &csrf_token, 427 | ) 428 | .await; 429 | 430 | // Bearer token 431 | let cv = String::from_utf8_lossy(&code_verifier); 432 | let params = vec![ 433 | ("grant_type", "authorization_code"), 434 | ("redirect_uri", "http://localhost:3001/endpoint"), 435 | ("code", &authorization_code), 436 | ("code_verifier", &cv), 437 | ]; 438 | self.token = self 439 | .exchange_auth_code_for_token(client, ClientType::Confidential, ¶ms) 440 | .await; 441 | } 442 | } 443 | 444 | // Ensure that the `tracing` stack is only initialized once 445 | static TRACING: Lazy<()> = Lazy::new(|| { 446 | let default_filter_level = "test=debug,tower_http=debug".to_string(); 447 | let subscriber_name = "test".to_string(); 448 | if std::env::var("TEST_LOG").is_ok() { 449 | let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout); 450 | init_subscriber(subscriber); 451 | } else { 452 | let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink); 453 | init_subscriber(subscriber); 454 | } 455 | }); 456 | 457 | pub async fn spawn_app() -> TestState { 458 | // Initialize tracing stack 459 | Lazy::force(&TRACING); 460 | 461 | // Launch app 462 | let (router, listener) = axum_oauth::build_service(Some("0.0.0.0:0".to_string()), 3000).await; 463 | let port = listener.local_addr().unwrap().port(); 464 | tokio::spawn(axum_oauth::serve(router, listener)); 465 | 466 | let reqwest_client = reqwest::Client::builder() 467 | .redirect(reqwest::redirect::Policy::none()) 468 | .cookie_store(true) 469 | .build() 470 | .unwrap(); 471 | 472 | let res = TestState { 473 | app_address: format!("http://localhost:{}", port), 474 | port: port, 475 | api_client: reqwest_client, 476 | ..Default::default() 477 | }; 478 | tracing::debug!("The app was spawned at: {}", res.app_address); 479 | 480 | res 481 | } 482 | 483 | pub fn assert_is_redirect_to( 484 | response: &reqwest::Response, 485 | status_code: u16, 486 | location: &str, 487 | location_is_partial: bool, 488 | ) { 489 | assert_eq!( 490 | response.status().as_u16(), 491 | status_code, 492 | "received https status code: {} Redirect", 493 | status_code 494 | ); 495 | if !location_is_partial { 496 | assert_eq!( 497 | response.headers().get("Location").unwrap(), 498 | location, 499 | "redirect location is: {}", 500 | location 501 | ) 502 | } else { 503 | let loc_header = response 504 | .headers() 505 | .get("Location") 506 | .unwrap() 507 | .to_str() 508 | .expect("failed to convert header to str"); 509 | 510 | assert!( 511 | loc_header.contains(location), 512 | "partial match to initial part of redirect location" 513 | ) 514 | } 515 | } 516 | 517 | #[derive(Debug, Serialize)] 518 | struct AuthorizationCode { 519 | code: String, 520 | } 521 | 522 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] 523 | pub struct Token { 524 | pub token_type: String, 525 | 526 | pub scope: String, 527 | 528 | #[serde(skip_serializing_if = "Option::is_none")] 529 | pub access_token: Option, 530 | 531 | #[serde(skip_serializing_if = "Option::is_none")] 532 | pub refresh_token: Option, 533 | 534 | #[serde(skip_serializing_if = "Option::is_none")] 535 | pub expires_in: Option, 536 | 537 | #[serde(skip_serializing_if = "Option::is_none")] 538 | pub error: Option, 539 | } 540 | 541 | #[derive(Debug, Deserialize)] 542 | pub struct ClientResponse { 543 | pub client_id: String, 544 | pub client_secret: Option, 545 | } 546 | 547 | #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] 548 | pub enum ClientType { 549 | Confidential, 550 | Public, 551 | } 552 | -------------------------------------------------------------------------------- /tests/api/index.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::spawn_app; 2 | 3 | #[tokio::test] 4 | pub async fn index_not_found() { 5 | // Arrange 6 | let state = spawn_app().await; 7 | let client = reqwest::Client::new(); 8 | 9 | // Act 10 | let response = client 11 | .get(&format!("{}/", &state.app_address)) 12 | .send() 13 | .await 14 | .expect("request to client api failed"); 15 | 16 | // Assert 17 | assert_eq!( 18 | response.status().as_u16(), 19 | 404, 20 | "Getting index (https://foo/) returns 404 Not Found" 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /tests/api/main.rs: -------------------------------------------------------------------------------- 1 | mod client; 2 | mod helpers; 3 | mod index; 4 | // mod oauth_client_helper; 5 | mod signin; 6 | mod signout; 7 | mod signup; 8 | mod user; 9 | -------------------------------------------------------------------------------- /tests/api/signin.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::{assert_is_redirect_to, spawn_app}; 2 | 3 | #[tokio::test] 4 | async fn signin_form_fields_problem() { 5 | // Arrange 6 | let test_state = spawn_app().await; 7 | let client = test_state.api_client; 8 | let invalid_cases = [ 9 | ( 10 | serde_json::json!({ 11 | "password": "secret", 12 | }), 13 | "no username", 14 | ), 15 | ( 16 | serde_json::json!({ 17 | "username": "bob", 18 | }), 19 | "no password", 20 | ), 21 | (serde_json::json!({}), "empty form"), 22 | ]; 23 | 24 | for (case, msg) in invalid_cases { 25 | // Act 26 | let response = client 27 | .post(&format!("{}/oauth/signin", &test_state.app_address)) 28 | .form(&case) 29 | .send() 30 | .await 31 | .expect("request to server api failed"); 32 | 33 | // Assert 34 | assert_eq!( 35 | response.status().as_u16(), 36 | 422, 37 | "{} returns client error status", 38 | msg 39 | ); 40 | } 41 | } 42 | 43 | #[tokio::test] 44 | async fn signin_form_wrong_credentials() { 45 | // Arrange 46 | let test_state = spawn_app().await; 47 | let client = test_state.api_client; 48 | let invalid_cases = [ 49 | ( 50 | serde_json::json!({ 51 | "username": "bar", 52 | "password": "secret", 53 | }), 54 | "invalid username", 55 | ), 56 | ( 57 | serde_json::json!({ 58 | "username": "bob", 59 | "password": "not_my_secret" 60 | }), 61 | "invalid password", 62 | ), 63 | ]; 64 | 65 | for (case, msg) in invalid_cases { 66 | // Act 67 | let response = client 68 | .post(&format!("{}/oauth/signin", &test_state.app_address)) 69 | .form(&case) 70 | .send() 71 | .await 72 | .expect("request to server api failed"); 73 | 74 | // Assert 75 | println!("response: {:?}", response); 76 | assert_eq!( 77 | response.status().as_u16(), 78 | 401, 79 | "{} returns client error 401 Unauthorized", 80 | msg 81 | ); 82 | } 83 | } 84 | 85 | #[tokio::test] 86 | async fn happy_path_signin_form() { 87 | // Arrange 88 | let test_state = spawn_app().await; 89 | let client = test_state.api_client; 90 | let form = serde_json::json!({ 91 | "username": "bob", 92 | "password": "secret", 93 | }); 94 | 95 | // Act 96 | let response = client 97 | .post(&format!("{}/oauth/signin", &test_state.app_address)) 98 | .form(&form) 99 | .send() 100 | .await 101 | .expect("request to server api failed"); 102 | 103 | // Assert 104 | assert_eq!( 105 | response.status().as_u16(), 106 | 303, 107 | "correct credentials result in 303 redirect to oauth root uri", 108 | ); 109 | assert_is_redirect_to(&response, 303, "/oauth/", false); 110 | } 111 | -------------------------------------------------------------------------------- /tests/api/signout.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::{spawn_app, ClientType}; 2 | 3 | #[tokio::test] 4 | async fn signout_ok() { 5 | // Arrange 6 | let mut state = spawn_app().await; 7 | let params = serde_json::json!({ 8 | "name": "foo client", 9 | "redirect_uri": "http://localhost:3001/endpoint", 10 | "type": "confidential", 11 | }); 12 | let client_id = state 13 | .register_client(¶ms, ClientType::Confidential) 14 | .await; 15 | state.signin("bob", "secret").await; 16 | state.authorization_flow(&client_id).await; 17 | 18 | // Act 19 | let client = state.api_client; 20 | let response = client 21 | .post(&format!("{}/oauth/signout/", &state.app_address)) 22 | .bearer_auth(state.token.access_token.unwrap()) 23 | .send() 24 | .await 25 | .expect("request to client api failed"); 26 | 27 | // Assert 28 | assert_eq!( 29 | response.status(), 30 | 200, 31 | "signout from session returns 200 Ok" 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /tests/api/signup.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::spawn_app; 2 | 3 | #[tokio::test] 4 | async fn signup_form_fields_problem() { 5 | // Arrange 6 | let test_state = spawn_app().await; 7 | let client = test_state.api_client; 8 | let invalid_cases = [ 9 | ( 10 | serde_json::json!({ 11 | "password": "secret", 12 | }), 13 | "password only", 14 | ), 15 | ( 16 | serde_json::json!({ 17 | "username": "bob", 18 | }), 19 | "username only", 20 | ), 21 | ( 22 | serde_json::json!({ 23 | "given_name": "Robert", 24 | }), 25 | "given name only", 26 | ), 27 | ( 28 | serde_json::json!({ 29 | "username": "bob", 30 | "given_name": "Robert", 31 | }), 32 | "no password", 33 | ), 34 | ( 35 | serde_json::json!({ 36 | "password": "secret", 37 | "given_name": "Robert", 38 | }), 39 | "no username", 40 | ), 41 | (serde_json::json!({}), "empty form"), 42 | ]; 43 | 44 | for (case, msg) in invalid_cases { 45 | // Act 46 | let response = client 47 | .post(&format!("{}/oauth/signup", &test_state.app_address)) 48 | .form(&case) 49 | .send() 50 | .await 51 | .expect("request to server api failed"); 52 | 53 | // Assert 54 | assert_eq!( 55 | response.status().as_u16(), 56 | 422, 57 | "{} returns client error status", 58 | msg 59 | ); 60 | } 61 | } 62 | 63 | #[tokio::test] 64 | async fn signup_existing_user() { 65 | // Arrange 66 | let test_state = spawn_app().await; 67 | let client = test_state.api_client; 68 | let form = serde_json::json!({ 69 | "username": "bob", 70 | "password": "secret", 71 | "given_name": "Robert", 72 | }); 73 | 74 | // Act 75 | let response = client 76 | .post(&format!("{}/oauth/signup", &test_state.app_address)) 77 | .form(&form) 78 | .send() 79 | .await 80 | .expect("request to server api failed"); 81 | 82 | // Assert 83 | assert_eq!( 84 | response.status().as_u16(), 85 | 409, 86 | "signup existing user returns 409 Conflict", 87 | ); 88 | } 89 | 90 | #[tokio::test] 91 | async fn happy_path_signup_form() { 92 | // Arrange 93 | let test_state = spawn_app().await; 94 | let client = test_state.api_client; 95 | let form = serde_json::json!({ 96 | "username": "alice", 97 | "password": "secret", 98 | "given_name": "Alice", 99 | }); 100 | 101 | // Act 102 | let response = client 103 | .post(&format!("{}/oauth/signup", &test_state.app_address)) 104 | .form(&form) 105 | .send() 106 | .await 107 | .expect("request to server api failed"); 108 | 109 | // Assert 110 | assert_eq!( 111 | response.status().as_u16(), 112 | 201, 113 | "successful signup returns 201 Created", 114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /tests/api/user.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::{spawn_app, ClientType}; 2 | 3 | #[tokio::test] 4 | pub async fn unauthenticated_access() { 5 | // Arrange 6 | let state = spawn_app().await; 7 | let client = state.api_client; 8 | 9 | // Act 10 | let response = client 11 | .get(&format!("{}/api/user", &state.app_address)) 12 | .send() 13 | .await 14 | .expect("request to client api failed"); 15 | 16 | // Assert 17 | assert_eq!( 18 | response.status(), 19 | 401, 20 | "unauthenticated access to api endpoint returns 401 Unauthorized" 21 | ); 22 | } 23 | 24 | #[tokio::test] 25 | pub async fn happy_path_authenticated_access() { 26 | // Arrange 27 | let mut state = spawn_app().await; 28 | let params = serde_json::json!({ 29 | "name": "foo client", 30 | "redirect_uri": "http://localhost:3001/endpoint", 31 | "type": "confidential", 32 | }); 33 | let client_id = state 34 | .register_client(¶ms, ClientType::Confidential) 35 | .await; 36 | state.signin("bob", "secret").await; 37 | state.authorization_flow(&client_id).await; 38 | 39 | // Act 40 | let client = state.api_client; 41 | let response = client 42 | .get(&format!("{}/api/user", &state.app_address)) 43 | .bearer_auth(state.token.access_token.unwrap()) 44 | .send() 45 | .await 46 | .expect("request to client api failed"); 47 | 48 | // Assert 49 | assert_eq!( 50 | response.status(), 51 | 200, 52 | "authenticated access to api endpoint returns 200 Ok" 53 | ); 54 | } 55 | --------------------------------------------------------------------------------