├── .convco └── template │ ├── commit.hbs │ ├── footer.hbs │ ├── header.hbs │ └── template.hbs ├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .versionrc ├── Cargo.toml ├── LICENSE ├── README.md ├── develop └── docker-compose.yaml ├── src ├── agent │ ├── client │ │ ├── mod.rs │ │ ├── oauth2.rs │ │ └── openid.rs │ ├── config.rs │ ├── error.rs │ ├── mod.rs │ ├── ops.rs │ └── state.rs ├── components │ ├── authenticated.rs │ ├── context │ │ ├── agent.rs │ │ └── mod.rs │ ├── failure.rs │ ├── mod.rs │ ├── noauth.rs │ ├── redirect │ │ ├── location.rs │ │ ├── mod.rs │ │ └── router.rs │ └── use_authentication.rs ├── config.rs ├── context │ ├── mod.rs │ └── utils.rs ├── hook.rs ├── lib.rs └── prelude.rs ├── yew-oauth2-example ├── .gitignore ├── Cargo.toml ├── assets │ └── style.scss ├── index.html └── src │ ├── app.rs │ ├── components │ ├── component.rs │ ├── expiration.rs │ ├── functional.rs │ ├── identity.rs │ ├── mod.rs │ ├── use_auth.rs │ ├── use_latest_token.rs │ └── view.rs │ └── main.rs └── yew-oauth2-redirect-example ├── .gitignore ├── Cargo.toml ├── assets └── style.scss ├── index.html └── src ├── app.rs ├── components ├── component.rs ├── debug.rs ├── expiration.rs ├── functional.rs ├── mod.rs ├── use_auth.rs └── view.rs └── main.rs /.convco/template/commit.hbs: -------------------------------------------------------------------------------- 1 | {{#word-wrap}} 2 | *{{#if scope}} **{{scope}}:**{{/if}} {{subject}} 3 | {{~#if hash}} {{#if @root.linkReferences}}([{{shortHash}}]({{commitUrlFormat}})){{else}}({{shortHash}}){{/if}}{{/if}} 4 | {{~#if references}}, closes 5 | {{~#each references}} {{#if @root.linkReferences~}} 6 | [{{this.prefix}}{{this.issue}}]({{issueUrlFormat}}) 7 | {{~else}}{{this.prefix}}{{this.issue}} 8 | {{~/if}}{{/each}} 9 | {{~/if}} 10 | 11 | {{/word-wrap}} 12 | -------------------------------------------------------------------------------- /.convco/template/footer.hbs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctron/yew-oauth2/f889f391c570bf5666f072bd8254d415836f5f3c/.convco/template/footer.hbs -------------------------------------------------------------------------------- /.convco/template/header.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{#if isPatch}}###{{else}}##{{/if}}{{#if @root.linkCompare}} [{{version}}]({{compareUrlFormat}}){{else}} {{version}}{{/if}}{{#if title}} "{{title}}"{{/if}}{{#if date}} ({{date}}){{/if}} 3 | -------------------------------------------------------------------------------- /.convco/template/template.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 | {{#if noteGroups}}{{#each noteGroups}} 3 | 4 | ### ⚠️ {{title}} 5 | 6 | {{#each notes}}* {{#if commit.scope}}**{{commit.scope}}:** {{/if}}{{this.text}} 7 | {{/each}} 8 | {{/each}} 9 | {{/if}} 10 | 11 | {{#each commitGroups}} 12 | {{#if title}}{{#if @root.isPatch}} 13 | ####{{else}} 14 | ###{{/if}} {{title}}{{/if}} 15 | 16 | {{#each commits}} 17 | {{> commit root=@root}} 18 | {{/each}} 19 | {{/each}} 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_call: 11 | # But don't trigger on tags, as they are covered by the "release.yaml" workflow 12 | 13 | jobs: 14 | check: 15 | 16 | runs-on: ubuntu-22.04 17 | strategy: 18 | matrix: 19 | toolchain: 20 | - stable 21 | # minimum version: because of "bumpalo" 22 | - "1.73.0" 23 | 24 | steps: 25 | 26 | - uses: actions/checkout@v4 27 | 28 | - name: Install Rust ${{ matrix.toolchain }} 29 | run: | 30 | rustup toolchain install ${{ matrix.toolchain }} --component rustfmt,clippy --target wasm32-unknown-unknown 31 | rustup default ${{ matrix.toolchain }} 32 | 33 | - uses: actions/cache@v4 34 | with: 35 | path: | 36 | ~/.cargo/registry/index/ 37 | ~/.cargo/registry/cache/ 38 | ~/.cargo/git/db/ 39 | target/ 40 | key: ${{ runner.os }}-cargo-${{ matrix.toolchain }}-${{ hashFiles('**/Cargo.toml') }} 41 | 42 | - name: Run cargo fmt 43 | run: | 44 | cargo +${{ matrix.toolchain }} fmt --all -- --check 45 | 46 | - name: Install binstall 47 | run: | 48 | curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash 49 | 50 | - name: Install cargo-all-features 51 | run: | 52 | cargo binstall -y cargo-all-features 53 | 54 | - name: Run cargo check 55 | run: | 56 | cargo +${{ matrix.toolchain }} check-all-features --target wasm32-unknown-unknown 57 | 58 | - name: Run cargo test 59 | run: | 60 | cargo +${{ matrix.toolchain }} test-all-features 61 | 62 | - name: Run cargo clippy 63 | run: | 64 | cargo +${{ matrix.toolchain }} clippy --target wasm32-unknown-unknown -- -D warnings 65 | 66 | - name: Run cargo clippy (all features) 67 | run: | 68 | cargo +${{ matrix.toolchain }} clippy --all-features --target wasm32-unknown-unknown -- -D warnings 69 | 70 | examples: 71 | 72 | runs-on: ubuntu-22.04 73 | 74 | steps: 75 | 76 | - uses: actions/checkout@v4 77 | 78 | - uses: actions/cache@v4 79 | with: 80 | path: | 81 | ~/.cargo/registry/index/ 82 | ~/.cargo/registry/cache/ 83 | ~/.cargo/git/db/ 84 | target/ 85 | key: ${{ runner.os }}-cargo-examples-${{ matrix.toolchain }}-${{ hashFiles('**/Cargo.toml') }} 86 | 87 | - name: Check (yew-oauth2-example) 88 | run: | 89 | cd yew-oauth2-example 90 | cargo check 91 | cargo check --features openid 92 | 93 | - name: Check (yew-oauth2-redirect-example) 94 | run: | 95 | cd yew-oauth2-redirect-example 96 | cargo check 97 | cargo check --features openid 98 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | # Releases are tags named 'v', and must have the "major.minor.micro", for example: "0.1.0". 6 | # Release candidates are tagged as `v-rc`, for example: "0.1.0-rc1". 7 | tags: 8 | - "v*" 9 | 10 | permissions: 11 | contents: write # for creating a release 12 | 13 | jobs: 14 | 15 | init: 16 | runs-on: ubuntu-22.04 17 | outputs: 18 | version: ${{steps.version.outputs.version}} 19 | prerelease: ${{steps.state.outputs.prerelease}} 20 | steps: 21 | - name: Evaluate state 22 | id: state 23 | env: 24 | HEAD_REF: ${{github.head_ref}} 25 | run: | 26 | test -z "${HEAD_REF}" && (echo 'do-publish=true' >> $GITHUB_OUTPUT) 27 | if [[ "${{ github.event.ref }}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 28 | echo release=true >> $GITHUB_OUTPUT 29 | elif [[ "${{ github.event.ref }}" =~ ^refs/tags/v.*$ ]]; then 30 | echo prerelease=true >> $GITHUB_OUTPUT 31 | fi 32 | - name: Set version 33 | id: version 34 | run: | 35 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 36 | [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') 37 | [ "$VERSION" == "main" ] && VERSION=latest 38 | echo "Version: $VERSION" 39 | echo "version=$VERSION" >> $GITHUB_OUTPUT 40 | 41 | 42 | # check that our CI would pass 43 | ci: 44 | uses: ./.github/workflows/ci.yaml 45 | 46 | # publish directly after CI check 47 | publish: 48 | needs: [ init, ci ] 49 | runs-on: ubuntu-22.04 50 | steps: 51 | 52 | - name: Checkout 53 | uses: actions/checkout@v4 54 | with: 55 | fetch-depth: 0 56 | 57 | - name: Install convco 58 | run: | 59 | curl -sLO https://github.com/convco/convco/releases/download/v0.5.1/convco-ubuntu.zip 60 | unzip convco-ubuntu.zip 61 | sudo install convco /usr/local/bin 62 | 63 | - name: Generate changelog 64 | run: | 65 | convco changelog -s --max-majors=1 --max-minors=1 --max-patches=1 -n > /tmp/changelog.md 66 | 67 | - name: Create Release 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | TAG: v${{ needs.init.outputs.version }} 71 | run: | 72 | OPTS="" 73 | 74 | if [[ "${{ needs.init.outputs.prerelease }}" == "true" ]]; then 75 | OPTS="${OPTS} -p" 76 | fi 77 | 78 | gh release create ${OPTS} --title "${{ needs.init.outputs.version }}" -F /tmp/changelog.md ${TAG} 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | 3 | target/ 4 | Cargo.lock 5 | -------------------------------------------------------------------------------- /.versionrc: -------------------------------------------------------------------------------- 1 | template: ".convco/template" 2 | header: "" -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yew-oauth2" 3 | version = "0.11.0" 4 | authors = ["Jens Reimann "] 5 | edition = "2021" 6 | license = "Apache-2.0" 7 | description = "OAuth2 components for Yew" 8 | repository = "https://github.com/ctron/yew-oauth2" 9 | categories = ["wasm", "web-programming", "gui"] 10 | keywords = ["yew", "oauth2", "oidc", "web", "html"] 11 | readme = "README.md" 12 | rust-version = "1.70" 13 | 14 | [dependencies] 15 | async-trait = "0.1" 16 | gloo-storage = "0.3" 17 | gloo-timers = "0.3" 18 | gloo-utils = "0.2" 19 | js-sys = "0.3" 20 | log = "0.4" 21 | num-traits = "0.2" 22 | oauth2 = "4" 23 | reqwest = "0.11" 24 | serde = { version = "1", features = ["derive"] } 25 | time = { version = "0.3", features = ["wasm-bindgen"] } 26 | tokio = { version = "1", features = ["sync"] } 27 | wasm-bindgen = "0.2" 28 | wasm-bindgen-futures = "0.4" 29 | yew = "0.21.0" 30 | 31 | web-sys = { version = "0.3", features = [ 32 | "Window", 33 | ] } 34 | 35 | openidconnect = { version = "3.0", optional = true } 36 | yew-nested-router = { version = "0.7.0", optional = true } 37 | 38 | [features] 39 | # Enable for OpenID Connect support 40 | openid = ["openidconnect"] 41 | 42 | [package.metadata.docs.rs] 43 | all-features = true 44 | rustdoc-args = ["--cfg", "docsrs"] 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OAuth2 (and OIDC) component for Yew 2 | 3 | [![crates.io](https://img.shields.io/crates/v/yew-oauth2.svg)](https://crates.io/crates/yew-oauth2) 4 | [![docs.rs](https://docs.rs/yew-oauth2/badge.svg)](https://docs.rs/yew-oauth2) 5 | [![CI](https://github.com/ctron/yew-oauth2/actions/workflows/ci.yaml/badge.svg)](https://github.com/ctron/yew-oauth2/actions/workflows/ci.yaml) 6 | 7 | Add to your `Cargo.toml`: 8 | 9 | ```toml 10 | yew-oauth2 = "0.11" 11 | ``` 12 | 13 | By default, the `yew-nested-router` integration for [`yew-nested-router`](https://github.com/ctron/yew-nested-router) is 14 | disabled. You can enable it using: 15 | 16 | ```toml 17 | yew-oauth2 = { version = "0.10", features = ["yew-nested-router"] } 18 | ``` 19 | 20 | ## OpenID Connect 21 | 22 | OpenID Connect requires an additional dependency and can be enabled using the feature `openid`. 23 | 24 | ## Examples 25 | 26 | A quick example of how to use it (see below for more complete examples): 27 | 28 | ```rust 29 | use yew::prelude::*; 30 | use yew_oauth2::prelude::*; 31 | use yew_oauth2::oauth2::*; // use `openid::*` when using OpenID connect 32 | 33 | #[function_component(MyApplication)] 34 | fn my_app() -> Html { 35 | let config = Config::new( 36 | "my-client", 37 | "https://my-sso/auth/realms/my-realm/protocol/openid-connect/auth", 38 | "https://my-sso/auth/realms/my-realm/protocol/openid-connect/token" 39 | ); 40 | 41 | html!( 42 | 43 | 44 | 45 | ) 46 | } 47 | 48 | #[function_component(MyApplicationMain)] 49 | fn my_app_main() -> Html { 50 | let agent = use_auth_agent().expect("Must be nested inside an OAuth2 component"); 51 | 52 | let login = use_callback(agent.clone(), |_, agent| { 53 | let _ = agent.start_login(); 54 | }); 55 | let logout = use_callback(agent, |_, agent| { 56 | let _ = agent.logout(); 57 | }); 58 | 59 | html!( 60 | <> 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | ) 70 | } 71 | ``` 72 | 73 | This repository also has some complete examples: 74 | 75 |
76 |
77 | 78 | [yew-oauth2-example](yew-oauth2-example/)
79 |
80 | A complete example, hiding everything behind a "login" page, and revealing the content once the user logged in. 81 | 82 | Use with either OpenID Connect or OAuth2. 83 |
84 | 85 |
86 | 87 | [yew-oauth2-redirect-example](yew-oauth2-redirect-example/)
88 |
89 | A complete example, showing the full menu structure, but redirecting the user automatically to the login server 90 | when required. 91 | 92 | Use with either OpenID Connect or OAuth2. 93 |
94 | 95 |
96 | 97 | ### Testing 98 | 99 | Testing the example projects locally can be done using a local Keycloak instance and `trunk`. 100 | 101 | Start the Keycloak instance using: 102 | 103 | ```shell 104 | podman-compose -f develop/docker-compose.yaml up 105 | ``` 106 | 107 | Then start `trunk` with the local developer instance: 108 | 109 | ```shell 110 | cd yew-oauth2-example # or yew-oauth2-redirect-example 111 | trunk serve 112 | ``` 113 | 114 | And navigate your browser to [http://localhost:8080](http://localhost:8080). 115 | 116 | **NOTE:** It is important to use `http://localhost:8080` instead of e.g. `http://127.0.0.1:8080`, as Keycloak is 117 | configured by default to use `http://localhost:*` as a valid redirect URL when in dev-mode. Otherwise, you will get 118 | an "invalid redirect" error from Keycloak. 119 | -------------------------------------------------------------------------------- /develop/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | 5 | keycloak: 6 | image: docker.io/bitnami/keycloak:23.0.7 7 | environment: 8 | - KEYCLOAK_DATABASE_VENDOR=dev-file 9 | - KEYCLOAK_ADMIN=admin 10 | - KEYCLOAK_ADMIN_PASSWORD=admin123456 11 | - KEYCLOAK_ENABLE_HEALTH_ENDPOINTS=true 12 | - KEYCLOAK_CACHE_TYPE=local 13 | ports: 14 | - "8081:8080" 15 | healthcheck: 16 | test: [ "CMD", "curl", "-f", "http://localhost:8080/health/ready" ] 17 | interval: 5s 18 | timeout: 5s 19 | retries: 20 20 | 21 | init_keycloak: 22 | image: docker.io/bitnami/keycloak:23.0.7 23 | depends_on: 24 | keycloak: 25 | condition: service_healthy 26 | entrypoint: /usr/bin/bash 27 | environment: 28 | - KCADM_PATH=/opt/bitnami/keycloak/bin/kcadm.sh 29 | - KEYCLOAK_URL=http://keycloak:8080 30 | - KEYCLOAK_ADMIN=admin 31 | - KEYCLOAK_ADMIN_PASSWORD=admin123456 32 | - REALM=master 33 | 34 | command: 35 | - -exc 36 | - | 37 | # wait until keycloak is ready 38 | while ! curl -sf "$$KEYCLOAK_URL" --output /dev/null; do 39 | echo "Waiting for Keycloak to start up..." 40 | sleep 5 41 | done 42 | 43 | echo "Keycloak ready" 44 | 45 | kcadm() { local cmd="$$1" ; shift ; "$$KCADM_PATH" "$$cmd" --config /tmp/kcadm.config "$$@" ; } 46 | 47 | # login 48 | kcadm config credentials config --server "$$KEYCLOAK_URL" --realm master --user "$$KEYCLOAK_ADMIN" --password "$$KEYCLOAK_ADMIN_PASSWORD" 49 | 50 | # create client 51 | kcadm create clients -r $${REALM} -f - << EOF 52 | { 53 | "enabled": true, 54 | "clientId": "example", 55 | "publicClient": true, 56 | "standardFlowEnabled": true, 57 | "fullScopeAllowed": true, 58 | "webOrigins": ["*"], 59 | "redirectUris": ["http://localhost:*", "http://localhost:*/*", "http://127.0.0.1:*", "http://127.0.0.1:*/*" ], 60 | "attributes": { 61 | "access.token.lifespan": "300", 62 | "post.logout.redirect.uris": "+" 63 | } 64 | } 65 | EOF 66 | -------------------------------------------------------------------------------- /src/agent/client/mod.rs: -------------------------------------------------------------------------------- 1 | //! Client implementations 2 | 3 | mod oauth2; 4 | #[cfg(feature = "openid")] 5 | mod openid; 6 | 7 | pub use self::oauth2::*; 8 | #[cfg(feature = "openid")] 9 | pub use openid::*; 10 | 11 | use crate::{ 12 | agent::{InnerConfig, LogoutOptions, OAuth2Error}, 13 | context::OAuth2Context, 14 | }; 15 | use async_trait::async_trait; 16 | use js_sys::Date; 17 | use num_traits::ToPrimitive; 18 | use reqwest::Url; 19 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; 20 | use std::fmt::Debug; 21 | use std::time::Duration; 22 | 23 | #[derive(Clone, Debug, Serialize, Deserialize)] 24 | pub struct LoginContext 25 | where 26 | S: Serialize, 27 | { 28 | pub url: Url, 29 | pub csrf_token: String, 30 | pub state: S, 31 | } 32 | 33 | #[async_trait(?Send)] 34 | pub trait Client: 'static + Sized + Clone + Debug { 35 | type TokenResponse; 36 | type Configuration: Clone + Debug + PartialEq; 37 | type LoginState: Debug + Serialize + DeserializeOwned; 38 | type SessionState: Clone + Debug; 39 | 40 | async fn from_config(config: Self::Configuration) -> Result; 41 | 42 | fn set_redirect_uri(self, url: Url) -> Self; 43 | 44 | fn make_login_context( 45 | &self, 46 | config: &InnerConfig, 47 | redirect_url: Url, 48 | ) -> Result, OAuth2Error>; 49 | 50 | async fn exchange_code( 51 | &self, 52 | code: String, 53 | login_state: Self::LoginState, 54 | ) -> Result<(OAuth2Context, Self::SessionState), OAuth2Error>; 55 | 56 | async fn exchange_refresh_token( 57 | &self, 58 | refresh_token: String, 59 | session_state: Self::SessionState, 60 | ) -> Result<(OAuth2Context, Self::SessionState), OAuth2Error>; 61 | 62 | /// Trigger the logout of the session 63 | /// 64 | /// Clients may choose to contact some back-channel or redirect to a logout URL. 65 | fn logout(&self, _session_state: Self::SessionState, _options: LogoutOptions) {} 66 | } 67 | 68 | /// Convert a duration to a timestamp, in seconds. 69 | fn expires(expires_in: Option) -> Option { 70 | if let Some(expires_in) = expires_in { 71 | let expires = ((Date::now() / 1000f64) + expires_in.as_secs_f64()) 72 | .to_u64() 73 | .unwrap_or(u64::MAX); 74 | Some(expires) 75 | } else { 76 | None 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/agent/client/oauth2.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | agent::{ 3 | client::{expires, Client, LoginContext}, 4 | InnerConfig, OAuth2Error, 5 | }, 6 | config::oauth2, 7 | context::{Authentication, OAuth2Context}, 8 | }; 9 | use ::oauth2::{ 10 | basic::{BasicClient, BasicTokenResponse}, 11 | reqwest::async_http_client, 12 | url::Url, 13 | AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, PkceCodeVerifier, 14 | RedirectUrl, RefreshToken, Scope, TokenResponse, TokenUrl, 15 | }; 16 | use async_trait::async_trait; 17 | use serde::{Deserialize, Serialize}; 18 | use std::fmt::Debug; 19 | 20 | #[derive(Clone, Debug, Serialize, Deserialize)] 21 | pub struct LoginState { 22 | pub pkce_verifier: String, 23 | } 24 | 25 | /// An OAuth2 based client implementation 26 | #[derive(Clone, Debug)] 27 | pub struct OAuth2Client { 28 | client: BasicClient, 29 | } 30 | 31 | impl OAuth2Client { 32 | fn make_authenticated(result: BasicTokenResponse) -> OAuth2Context { 33 | OAuth2Context::Authenticated(Authentication { 34 | access_token: result.access_token().secret().to_string(), 35 | refresh_token: result.refresh_token().map(|t| t.secret().to_string()), 36 | expires: expires(result.expires_in()), 37 | #[cfg(feature = "openid")] 38 | claims: None, 39 | }) 40 | } 41 | } 42 | 43 | #[async_trait(?Send)] 44 | impl Client for OAuth2Client { 45 | type TokenResponse = BasicTokenResponse; 46 | type Configuration = oauth2::Config; 47 | type LoginState = LoginState; 48 | type SessionState = (); 49 | 50 | async fn from_config(config: Self::Configuration) -> Result { 51 | let oauth2::Config { 52 | client_id, 53 | auth_url, 54 | token_url, 55 | } = config; 56 | 57 | let client = BasicClient::new( 58 | ClientId::new(client_id), 59 | None, 60 | AuthUrl::new(auth_url) 61 | .map_err(|err| OAuth2Error::Configuration(format!("invalid auth URL: {err}")))?, 62 | Some( 63 | TokenUrl::new(token_url).map_err(|err| { 64 | OAuth2Error::Configuration(format!("invalid token URL: {err}")) 65 | })?, 66 | ), 67 | ); 68 | 69 | Ok(Self { client }) 70 | } 71 | 72 | fn set_redirect_uri(mut self, url: Url) -> Self { 73 | self.client = self.client.set_redirect_uri(RedirectUrl::from_url(url)); 74 | self 75 | } 76 | 77 | fn make_login_context( 78 | &self, 79 | config: &InnerConfig, 80 | redirect_url: Url, 81 | ) -> Result, OAuth2Error> { 82 | let client = self 83 | .client 84 | .clone() 85 | .set_redirect_uri(RedirectUrl::from_url(redirect_url)); 86 | 87 | let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); 88 | 89 | let mut req = client 90 | .authorize_url(CsrfToken::new_random) 91 | .add_scopes( 92 | config 93 | .scopes 94 | .iter() 95 | .map(|s| Scope::new(s.to_string())) 96 | .collect::>(), 97 | ) 98 | .set_pkce_challenge(pkce_challenge); 99 | 100 | if let Some(audience) = &config.audience { 101 | req = req.add_extra_param("audience".to_string(), audience.clone()) 102 | } 103 | 104 | let (url, state) = req.url(); 105 | 106 | Ok(LoginContext { 107 | url, 108 | csrf_token: state.secret().clone(), 109 | state: LoginState { 110 | pkce_verifier: pkce_verifier.secret().clone(), 111 | }, 112 | }) 113 | } 114 | 115 | async fn exchange_code( 116 | &self, 117 | code: String, 118 | LoginState { pkce_verifier }: LoginState, 119 | ) -> Result<(OAuth2Context, Self::SessionState), OAuth2Error> { 120 | let pkce_verifier = PkceCodeVerifier::new(pkce_verifier); 121 | 122 | let result = self 123 | .client 124 | .exchange_code(AuthorizationCode::new(code)) 125 | .set_pkce_verifier(pkce_verifier) 126 | .request_async(async_http_client) 127 | .await 128 | .map_err(|err| OAuth2Error::LoginResult(format!("failed to exchange code: {err}")))?; 129 | 130 | log::debug!("Exchange code result: {:?}", result); 131 | 132 | Ok((Self::make_authenticated(result), ())) 133 | } 134 | 135 | async fn exchange_refresh_token( 136 | &self, 137 | refresh_token: String, 138 | session_state: Self::SessionState, 139 | ) -> Result<(OAuth2Context, Self::SessionState), OAuth2Error> { 140 | let result = self 141 | .client 142 | .exchange_refresh_token(&RefreshToken::new(refresh_token)) 143 | .request_async(async_http_client) 144 | .await 145 | .map_err(|err| { 146 | OAuth2Error::Refresh(format!("failed to exchange refresh token: {err}")) 147 | })?; 148 | 149 | Ok((Self::make_authenticated(result), session_state)) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/agent/client/openid.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | agent::{ 3 | client::{expires, Client, LoginContext}, 4 | InnerConfig, LogoutOptions, OAuth2Error, 5 | }, 6 | config::openid, 7 | context::{Authentication, OAuth2Context}, 8 | }; 9 | use async_trait::async_trait; 10 | use gloo_utils::window; 11 | use oauth2::TokenResponse; 12 | use openidconnect::{ 13 | core::{ 14 | CoreAuthDisplay, CoreAuthenticationFlow, CoreClaimName, CoreClaimType, CoreClient, 15 | CoreClientAuthMethod, CoreGenderClaim, CoreGrantType, CoreJsonWebKey, CoreJsonWebKeyType, 16 | CoreJsonWebKeyUse, CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, 17 | CoreJwsSigningAlgorithm, CoreResponseMode, CoreResponseType, CoreSubjectIdentifierType, 18 | CoreTokenResponse, 19 | }, 20 | reqwest::async_http_client, 21 | AuthorizationCode, ClientId, CsrfToken, EmptyAdditionalClaims, IdTokenClaims, IssuerUrl, Nonce, 22 | PkceCodeChallenge, PkceCodeVerifier, ProviderMetadata, RedirectUrl, RefreshToken, Scope, 23 | }; 24 | use reqwest::Url; 25 | use serde::{Deserialize, Serialize}; 26 | use std::{fmt::Debug, rc::Rc}; 27 | 28 | #[derive(Clone, Debug, Serialize, Deserialize)] 29 | pub struct OpenIdLoginState { 30 | pub pkce_verifier: String, 31 | pub nonce: String, 32 | } 33 | 34 | const DEFAULT_POST_LOGOUT_DIRECT_NAME: &str = "post_logout_redirect_uri"; 35 | 36 | /// An OpenID Connect based client implementation 37 | #[derive(Clone, Debug)] 38 | pub struct OpenIdClient { 39 | /// The client 40 | client: CoreClient, 41 | /// An override for the URL to end the session (logout) 42 | end_session_url: Option, 43 | /// A URL to direct to after the logout was performed 44 | after_logout_url: Option, 45 | /// The name of the query parameter sent to the issuer, containing the post-logout redirect URL 46 | post_logout_redirect_name: Option, 47 | /// Additional audiences of the ID token which are considered trustworthy 48 | additional_trusted_audiences: Vec, 49 | } 50 | 51 | /// Additional metadata read from the discovery endpoint 52 | #[derive(Clone, Debug, Serialize, Deserialize)] 53 | pub struct AdditionalProviderMetadata { 54 | #[serde(default, skip_serializing_if = "Option::is_none")] 55 | pub end_session_endpoint: Option, 56 | } 57 | 58 | impl openidconnect::AdditionalProviderMetadata for AdditionalProviderMetadata {} 59 | 60 | pub type ExtendedProviderMetadata = ProviderMetadata< 61 | AdditionalProviderMetadata, 62 | CoreAuthDisplay, 63 | CoreClientAuthMethod, 64 | CoreClaimName, 65 | CoreClaimType, 66 | CoreGrantType, 67 | CoreJweContentEncryptionAlgorithm, 68 | CoreJweKeyManagementAlgorithm, 69 | CoreJwsSigningAlgorithm, 70 | CoreJsonWebKeyType, 71 | CoreJsonWebKeyUse, 72 | CoreJsonWebKey, 73 | CoreResponseMode, 74 | CoreResponseType, 75 | CoreSubjectIdentifierType, 76 | >; 77 | 78 | #[async_trait(? Send)] 79 | impl Client for OpenIdClient { 80 | type TokenResponse = CoreTokenResponse; 81 | type Configuration = openid::Config; 82 | type LoginState = OpenIdLoginState; 83 | type SessionState = ( 84 | String, 85 | Rc>, 86 | ); 87 | 88 | async fn from_config(config: Self::Configuration) -> Result { 89 | let openid::Config { 90 | client_id, 91 | issuer_url, 92 | end_session_url, 93 | after_logout_url, 94 | post_logout_redirect_name, 95 | additional_trusted_audiences, 96 | } = config; 97 | 98 | let issuer = IssuerUrl::new(issuer_url) 99 | .map_err(|err| OAuth2Error::Configuration(format!("invalid issuer URL: {err}")))?; 100 | 101 | let metadata = ExtendedProviderMetadata::discover_async(issuer, async_http_client) 102 | .await 103 | .map_err(|err| { 104 | OAuth2Error::Configuration(format!("Failed to discover client: {err}")) 105 | })?; 106 | 107 | let end_session_url = end_session_url 108 | .map(|url| Url::parse(&url)) 109 | .transpose() 110 | .map_err(|err| { 111 | OAuth2Error::Configuration(format!("Unable to parse end_session_url: {err}")) 112 | })? 113 | .or_else(|| metadata.additional_metadata().end_session_endpoint.clone()); 114 | 115 | let client = CoreClient::from_provider_metadata(metadata, ClientId::new(client_id), None); 116 | 117 | Ok(Self { 118 | client, 119 | end_session_url, 120 | after_logout_url, 121 | post_logout_redirect_name, 122 | additional_trusted_audiences, 123 | }) 124 | } 125 | 126 | fn set_redirect_uri(mut self, url: Url) -> Self { 127 | self.client = self.client.set_redirect_uri(RedirectUrl::from_url(url)); 128 | self 129 | } 130 | 131 | fn make_login_context( 132 | &self, 133 | config: &InnerConfig, 134 | redirect_url: Url, 135 | ) -> Result, OAuth2Error> { 136 | let client = self 137 | .client 138 | .clone() 139 | .set_redirect_uri(RedirectUrl::from_url(redirect_url)); 140 | 141 | let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); 142 | 143 | let mut req = client.authorize_url( 144 | CoreAuthenticationFlow::AuthorizationCode, 145 | CsrfToken::new_random, 146 | Nonce::new_random, 147 | ); 148 | 149 | for scope in &config.scopes { 150 | req = req.add_scope(Scope::new(scope.clone())); 151 | } 152 | 153 | if let Some(audience) = &config.audience { 154 | req = req.add_extra_param("audience".to_string(), audience); 155 | } 156 | 157 | let (url, state, nonce) = req.set_pkce_challenge(pkce_challenge).url(); 158 | 159 | Ok(LoginContext { 160 | url, 161 | csrf_token: state.secret().clone(), 162 | state: OpenIdLoginState { 163 | pkce_verifier: pkce_verifier.secret().clone(), 164 | nonce: nonce.secret().clone(), 165 | }, 166 | }) 167 | } 168 | 169 | async fn exchange_code( 170 | &self, 171 | code: String, 172 | state: Self::LoginState, 173 | ) -> Result<(OAuth2Context, Self::SessionState), OAuth2Error> { 174 | let pkce_verifier = PkceCodeVerifier::new(state.pkce_verifier); 175 | 176 | let result = self 177 | .client 178 | .exchange_code(AuthorizationCode::new(code)) 179 | .set_pkce_verifier(pkce_verifier) 180 | .request_async(async_http_client) 181 | .await 182 | .map_err(|err| OAuth2Error::LoginResult(format!("failed to exchange code: {err}")))?; 183 | 184 | log::debug!("Exchange code result: {:?}", result); 185 | 186 | let id_token = result.extra_fields().id_token().ok_or_else(|| { 187 | OAuth2Error::LoginResult("Server did not return an ID token".to_string()) 188 | })?; 189 | 190 | let claims = Rc::new( 191 | id_token 192 | .clone() 193 | .into_claims( 194 | &self 195 | .client 196 | .id_token_verifier() 197 | .set_other_audience_verifier_fn(|aud| { 198 | self.additional_trusted_audiences.contains(aud) 199 | }), 200 | &Nonce::new(state.nonce), 201 | ) 202 | .map_err(|err| { 203 | OAuth2Error::LoginResult(format!("failed to verify ID token: {err}")) 204 | })?, 205 | ); 206 | 207 | Ok(( 208 | OAuth2Context::Authenticated(Authentication { 209 | access_token: result.access_token().secret().to_string(), 210 | refresh_token: result.refresh_token().map(|t| t.secret().to_string()), 211 | expires: expires(result.expires_in()), 212 | claims: Some(claims.clone()), 213 | }), 214 | (id_token.to_string(), claims), 215 | )) 216 | } 217 | 218 | async fn exchange_refresh_token( 219 | &self, 220 | refresh_token: String, 221 | session_state: Self::SessionState, 222 | ) -> Result<(OAuth2Context, Self::SessionState), OAuth2Error> { 223 | let result = self 224 | .client 225 | .exchange_refresh_token(&RefreshToken::new(refresh_token)) 226 | .request_async(async_http_client) 227 | .await 228 | .map_err(|err| { 229 | OAuth2Error::Refresh(format!("failed to exchange refresh token: {err}")) 230 | })?; 231 | 232 | Ok(( 233 | OAuth2Context::Authenticated(Authentication { 234 | access_token: result.access_token().secret().to_string(), 235 | refresh_token: result.refresh_token().map(|t| t.secret().to_string()), 236 | expires: expires(result.expires_in()), 237 | claims: Some(session_state.1.clone()), 238 | }), 239 | session_state, 240 | )) 241 | } 242 | 243 | fn logout(&self, session_state: Self::SessionState, options: LogoutOptions) { 244 | if let Some(url) = &self.end_session_url { 245 | let mut url = url.clone(); 246 | 247 | let name = self 248 | .post_logout_redirect_name 249 | .as_deref() 250 | .unwrap_or(DEFAULT_POST_LOGOUT_DIRECT_NAME); 251 | 252 | url.query_pairs_mut() 253 | .append_pair("id_token_hint", &session_state.0); 254 | 255 | if let Some(after) = options 256 | .target 257 | .map(|url| url.to_string()) 258 | .or_else(|| self.after_logout_url()) 259 | { 260 | url.query_pairs_mut().append_pair(name, &after); 261 | } 262 | 263 | log::info!("Navigating to: {url}"); 264 | 265 | window().location().replace(url.as_str()).ok(); 266 | } else { 267 | log::warn!("Found no session end URL"); 268 | } 269 | } 270 | } 271 | 272 | impl OpenIdClient { 273 | fn after_logout_url(&self) -> Option { 274 | if let Some(after) = &self.after_logout_url { 275 | if Url::parse(after).is_ok() { 276 | // test if this is an absolute URL 277 | return Some(after.to_string()); 278 | } 279 | 280 | window() 281 | .location() 282 | .href() 283 | .ok() 284 | .and_then(|url| { 285 | Url::parse(&url) 286 | .and_then(|current| current.join(after)) 287 | .ok() 288 | }) 289 | .map(|u| u.to_string()) 290 | } else { 291 | window().location().href().ok() 292 | } 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /src/agent/config.rs: -------------------------------------------------------------------------------- 1 | use super::{LoginOptions, LogoutOptions}; 2 | use crate::agent::Client; 3 | use std::time::Duration; 4 | 5 | #[doc(hidden)] 6 | #[derive(Clone, Debug)] 7 | pub struct AgentConfiguration { 8 | pub config: C::Configuration, 9 | pub scopes: Vec, 10 | pub grace_period: Duration, 11 | pub audience: Option, 12 | pub max_expiration: Option, 13 | 14 | pub default_login_options: Option, 15 | pub default_logout_options: Option, 16 | } 17 | 18 | impl PartialEq for AgentConfiguration { 19 | fn eq(&self, other: &Self) -> bool { 20 | self.config == other.config 21 | && self.scopes == other.scopes 22 | && self.grace_period == other.grace_period 23 | && self.audience == other.audience 24 | } 25 | } 26 | 27 | impl Eq for AgentConfiguration {} 28 | -------------------------------------------------------------------------------- /src/agent/error.rs: -------------------------------------------------------------------------------- 1 | use crate::context::OAuth2Context; 2 | use core::fmt::{Display, Formatter}; 3 | 4 | /// An error with the OAuth2 agent 5 | #[derive(Debug)] 6 | pub enum OAuth2Error { 7 | /// Not initialized 8 | NotInitialized, 9 | /// Configuration error 10 | Configuration(String), 11 | /// Failed to start login 12 | StartLogin(String), 13 | /// Failed to handle login result 14 | LoginResult(String), 15 | /// Failed to handle token refresh 16 | Refresh(String), 17 | /// Failing storing information 18 | Storage(String), 19 | /// Internal error 20 | Internal(String), 21 | } 22 | 23 | impl Display for OAuth2Error { 24 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 25 | match self { 26 | Self::NotInitialized => f.write_str("not initialized"), 27 | Self::Configuration(err) => write!(f, "configuration error: {err}"), 28 | Self::StartLogin(err) => write!(f, "start login error: {err}"), 29 | Self::LoginResult(err) => write!(f, "login result: {err}"), 30 | Self::Refresh(err) => write!(f, "refresh error: {err}"), 31 | Self::Storage(err) => write!(f, "storage error: {err}"), 32 | Self::Internal(err) => write!(f, "internal error: {err}"), 33 | } 34 | } 35 | } 36 | 37 | impl std::error::Error for OAuth2Error {} 38 | 39 | impl From for OAuth2Context { 40 | fn from(err: OAuth2Error) -> Self { 41 | OAuth2Context::Failed(err.to_string()) 42 | } 43 | } 44 | 45 | impl OAuth2Error { 46 | pub(crate) fn storage_key_empty(key: impl Display) -> Self { 47 | Self::Storage(format!("Missing value for key: {key}")) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/agent/mod.rs: -------------------------------------------------------------------------------- 1 | //! The agent, working in the background to manage the session and refresh tokens. 2 | pub mod client; 3 | 4 | mod config; 5 | mod error; 6 | mod ops; 7 | mod state; 8 | 9 | pub use client::*; 10 | pub use error::*; 11 | pub use ops::*; 12 | pub use state::LoginState; 13 | 14 | pub(crate) use config::*; 15 | 16 | use crate::context::{Authentication, OAuth2Context, Reason}; 17 | use gloo_storage::{SessionStorage, Storage}; 18 | use gloo_timers::callback::Timeout; 19 | use gloo_utils::{history, window}; 20 | use js_sys::Date; 21 | use log::error; 22 | use num_traits::cast::ToPrimitive; 23 | use reqwest::Url; 24 | use state::*; 25 | use std::{cmp::min, collections::HashMap, fmt::Debug, time::Duration}; 26 | use tokio::sync::mpsc::{channel, Receiver, Sender}; 27 | use wasm_bindgen::JsValue; 28 | use wasm_bindgen_futures::spawn_local; 29 | use yew::Callback; 30 | 31 | /// Options for the login process 32 | /// 33 | /// ## Non-exhaustive struct 34 | /// 35 | /// The struct is "non-exhaustive", which means that it is possible to add fields without breaking the API. 36 | /// 37 | /// In order to create an instance, follow the following pattern: 38 | /// 39 | /// ```rust 40 | /// # use reqwest::Url; 41 | /// # use yew_oauth2::prelude::LoginOptions; 42 | /// # let url = Url::parse("https://example.com").unwrap(); 43 | /// let opts = LoginOptions::default().with_redirect_url(url); 44 | /// ``` 45 | /// 46 | /// ## Redirect & Post login redirect 47 | /// 48 | /// By default, the login process will ask the issuer to redirect back the page that was active when starting the login 49 | /// process. In some cases, the issuer might require a more strict set of redirect URLs, and so can only redirect back 50 | /// to a single page. This can be enabled set setting a specific URL as `redirect_url`. 51 | /// 52 | /// Once the user comes back from the login flow, which might actually be without any user interaction if the session 53 | /// was still valid, users might find themselves on the redirect page. Therefore, it is advisable to forward/redirect 54 | /// back to the original page, the one where the user left off. 55 | /// 56 | /// While this crate does provide some assistance, the actual implementation on how to redirect is left to the user 57 | /// of this crate. If, while starting the login process, the currently active URL differs from the `redirect_url`, 58 | /// the agent will store the "current" URL and pass it to the provided "post login redirect callback" once the 59 | /// login process has completed. 60 | /// 61 | /// It could be argued, that the crate should just perform the redirect automatically, if no call back was provided. 62 | /// However, there can be different ways to redirect, and there is no common one. One might think just setting a new 63 | /// location in the browser should work, but that would actually cause a page reload, and would then start the login 64 | /// process again, since the tokens are only held in memory for security reasons. Also using the browser's History API 65 | /// won't work, as it does not notify listeners when pushing a new state. 66 | /// 67 | /// Therefore, it is necessary to set a "post login redirect callback", which will be triggered to handle the redirect, 68 | /// in order to allow the user of the crate to implement the needed logic. Having the `yew-nested-router` 69 | /// feature enabled, it is possible to just call [`LoginOptions::with_nested_router_redirect`] and let the 70 | /// router take care of this. 71 | /// 72 | /// **NOTE:** As a summary, setting only the `redirect_url` will not be sufficient. The "post login redirect callback" must 73 | /// also be implemented or the `yew-nested-router`feature used. Otherwise, the user would simply end up on the page defined by 74 | /// `redirect_url`, which in most cases is not what one would expect. 75 | #[derive(Debug, Clone, Default)] 76 | #[non_exhaustive] 77 | pub struct LoginOptions { 78 | /// Additional query parameters sent to the issuer. 79 | pub query: HashMap, 80 | 81 | /// Defines the redirect URL. See ["Redirect & Post login redirect"](#redirect--post-login-redirect) for more information. 82 | /// 83 | /// If this field is empty, the current URL is used as a redirect URL. 84 | pub redirect_url: Option, 85 | 86 | /// Defines callback used for post-login redirect. 87 | /// 88 | /// In cases where the issuer is asked to redirect to a different page than the one being active when starting 89 | /// the login flow, this callback will be called with the current (when starting) URL once the login handshake 90 | /// is complete. 91 | /// 92 | /// If `None`, disables post-login redirect. 93 | pub post_login_redirect_callback: Option>, 94 | } 95 | 96 | impl LoginOptions { 97 | pub fn new() -> Self { 98 | LoginOptions::default() 99 | } 100 | 101 | /// Set the query parameters for the login request 102 | pub fn with_query(mut self, query: impl IntoIterator) -> Self { 103 | self.query = HashMap::from_iter(query); 104 | self 105 | } 106 | 107 | /// Extend the current query parameters for the login request 108 | pub fn extend_query(mut self, query: impl IntoIterator) -> Self { 109 | self.query.extend(query); 110 | self 111 | } 112 | 113 | /// Add a query parameter for the login request 114 | pub fn add_query(mut self, key: impl Into, value: impl Into) -> Self { 115 | self.query.insert(key.into(), value.into()); 116 | self 117 | } 118 | 119 | /// Set the redirect URL 120 | pub fn with_redirect_url(mut self, redirect_url: impl Into) -> Self { 121 | self.redirect_url = Some(redirect_url.into()); 122 | self 123 | } 124 | 125 | /// Set a callback for post-login redirect 126 | pub fn with_redirect_callback(mut self, redirect_callback: Callback) -> Self { 127 | self.post_login_redirect_callback = Some(redirect_callback); 128 | self 129 | } 130 | 131 | /// Use `yew-nested-router` History API for post-login redirect callback 132 | #[cfg(feature = "yew-nested-router")] 133 | pub fn with_nested_router_redirect(mut self) -> Self { 134 | let callback = Callback::from(|url: String| { 135 | if yew_nested_router::History::push_state(JsValue::null(), &url).is_err() { 136 | error!("Unable to redirect"); 137 | } 138 | }); 139 | 140 | self.post_login_redirect_callback = Some(callback); 141 | self 142 | } 143 | } 144 | 145 | /// Options for the logout process 146 | /// 147 | ///**NOTE**: This is a non-exhaustive struct. See [`LoginOptions`] for an example on how to work with this. 148 | #[non_exhaustive] 149 | #[derive(Debug, Clone, Default, PartialEq, Eq)] 150 | pub struct LogoutOptions { 151 | /// An optional target to navigate to after the user was logged out. 152 | /// 153 | /// This would override any settings from the client configuration. 154 | pub target: Option, 155 | } 156 | 157 | impl LogoutOptions { 158 | pub fn new() -> Self { 159 | Self::default() 160 | } 161 | 162 | pub fn with_target(mut self, target: impl Into) -> Self { 163 | self.target = Some(target.into()); 164 | self 165 | } 166 | } 167 | 168 | #[doc(hidden)] 169 | pub enum Msg 170 | where 171 | C: Client, 172 | { 173 | Configure(AgentConfiguration), 174 | StartLogin(Option), 175 | Logout(Option), 176 | Refresh, 177 | } 178 | 179 | /// The agent handling the OAuth2/OIDC state 180 | #[derive(Clone, Debug)] 181 | pub struct Agent 182 | where 183 | C: Client, 184 | { 185 | tx: Sender>, 186 | } 187 | 188 | impl Agent 189 | where 190 | C: Client, 191 | { 192 | pub fn new(state_callback: F) -> Self 193 | where 194 | F: Fn(OAuth2Context) + 'static, 195 | { 196 | let (tx, rx) = channel(128); 197 | 198 | let inner = InnerAgent::new(tx.clone(), state_callback); 199 | inner.spawn(rx); 200 | 201 | Self { tx } 202 | } 203 | } 204 | 205 | #[doc(hidden)] 206 | pub struct InnerAgent 207 | where 208 | C: Client, 209 | { 210 | tx: Sender>, 211 | state_callback: Callback, 212 | config: Option, 213 | client: Option, 214 | state: OAuth2Context, 215 | session_state: Option, 216 | timeout: Option, 217 | } 218 | 219 | #[doc(hidden)] 220 | #[derive(Clone, Debug)] 221 | pub struct InnerConfig { 222 | scopes: Vec, 223 | grace_period: Duration, 224 | max_expiration: Option, 225 | audience: Option, 226 | default_login_options: Option, 227 | default_logout_options: Option, 228 | } 229 | 230 | impl InnerAgent 231 | where 232 | C: Client, 233 | { 234 | pub fn new(tx: Sender>, state_callback: F) -> Self 235 | where 236 | F: Fn(OAuth2Context) + 'static, 237 | { 238 | Self { 239 | tx, 240 | state_callback: Callback::from(state_callback), 241 | client: None, 242 | config: None, 243 | state: OAuth2Context::NotInitialized, 244 | session_state: None, 245 | timeout: None, 246 | } 247 | } 248 | 249 | fn spawn(self, rx: Receiver>) { 250 | spawn_local(async move { 251 | self.run(rx).await; 252 | }) 253 | } 254 | 255 | async fn run(mut self, mut rx: Receiver>) { 256 | loop { 257 | match rx.recv().await { 258 | Some(msg) => self.process(msg).await, 259 | None => { 260 | log::debug!("Agent channel closed"); 261 | break; 262 | } 263 | } 264 | } 265 | } 266 | 267 | async fn process(&mut self, msg: Msg) { 268 | match msg { 269 | Msg::Configure(config) => self.configure(config).await, 270 | Msg::StartLogin(login) => { 271 | if let Err(err) = self.start_login(login) { 272 | // FIXME: need to report this somehow 273 | log::info!("Failed to start login: {err}"); 274 | } 275 | } 276 | Msg::Logout(logout) => self.logout_opts(logout), 277 | Msg::Refresh => self.refresh().await, 278 | } 279 | } 280 | 281 | fn update_state(&mut self, state: OAuth2Context, session_state: Option) { 282 | log::debug!("update state: {state:?}"); 283 | 284 | if let OAuth2Context::Authenticated(Authentication { 285 | expires: Some(expires), 286 | .. 287 | }) = &state 288 | { 289 | let grace = self 290 | .config 291 | .as_ref() 292 | .map(|c| c.grace_period) 293 | .unwrap_or_default(); 294 | 295 | let mut expires = *expires; 296 | if let Some(max) = self.config.as_ref().and_then(|cfg| cfg.max_expiration) { 297 | // cap time the token expires by "max" 298 | expires = min(expires, max.as_secs()); 299 | } 300 | 301 | // get now as seconds 302 | let now = Date::now() / 1000f64; 303 | // get delta from now to expiration minus the grace period 304 | let diff = expires as f64 - now - grace.as_secs_f64(); 305 | 306 | let tx = self.tx.clone(); 307 | if diff > 0f64 { 308 | // while the API says millis is u32, internally it is i32 309 | let millis = (diff * 1000f64).to_i32().unwrap_or(i32::MAX); 310 | log::debug!("Starting timeout for: {}ms", millis); 311 | self.timeout = Some(Timeout::new(millis as u32, move || { 312 | let _ = tx.try_send(Msg::Refresh); 313 | })); 314 | } else { 315 | // token already expired 316 | let _ = tx.try_send(Msg::Refresh); 317 | } 318 | } else { 319 | self.timeout = None; 320 | } 321 | 322 | self.notify_state(state.clone()); 323 | 324 | self.state = state; 325 | self.session_state = session_state; 326 | } 327 | 328 | fn notify_state(&self, state: OAuth2Context) { 329 | self.state_callback.emit(state); 330 | } 331 | 332 | /// Called once the configuration process has finished, applying the outcome. 333 | async fn configured(&mut self, outcome: Result<(C, InnerConfig), OAuth2Error>) { 334 | match outcome { 335 | Ok((client, config)) => { 336 | log::debug!("Client created"); 337 | 338 | self.client = Some(client); 339 | self.config = Some(config); 340 | 341 | if matches!(self.state, OAuth2Context::NotInitialized) { 342 | let detected = self.detect_state().await; 343 | log::debug!("Detected state: {detected:?}"); 344 | match detected { 345 | Ok(true) => { 346 | if let Err(e) = self.post_login_redirect() { 347 | error!("Post-login redirect failed: {e}"); 348 | } 349 | } 350 | Ok(false) => { 351 | self.update_state( 352 | OAuth2Context::NotAuthenticated { 353 | reason: Reason::NewSession, 354 | }, 355 | None, 356 | ); 357 | } 358 | Err(err) => { 359 | self.update_state(err.into(), None); 360 | } 361 | } 362 | } 363 | } 364 | Err(err) => { 365 | log::debug!("Failed to configure client: {err}"); 366 | if matches!(self.state, OAuth2Context::NotInitialized) { 367 | self.update_state(err.into(), None); 368 | } 369 | } 370 | } 371 | } 372 | 373 | async fn make_client(config: AgentConfiguration) -> Result<(C, InnerConfig), OAuth2Error> { 374 | let AgentConfiguration { 375 | config, 376 | scopes, 377 | grace_period, 378 | audience, 379 | default_login_options, 380 | default_logout_options, 381 | max_expiration, 382 | } = config; 383 | 384 | let client = C::from_config(config).await?; 385 | 386 | let inner = InnerConfig { 387 | scopes, 388 | grace_period, 389 | audience, 390 | default_login_options, 391 | default_logout_options, 392 | max_expiration, 393 | }; 394 | 395 | Ok((client, inner)) 396 | } 397 | 398 | /// When initializing, try to detect the state from the URL and session state. 399 | /// 400 | /// Returns `false` if there is no authentication state found and the result is final. 401 | /// Otherwise, it returns `true` and spawns a request for e.g. a code exchange. 402 | async fn detect_state(&mut self) -> Result { 403 | let client = self.client.as_ref().ok_or(OAuth2Error::NotInitialized)?; 404 | 405 | let state = if let Some(state) = Self::find_query_state() { 406 | state 407 | } else { 408 | // unable to get location and query 409 | return Ok(false); 410 | }; 411 | 412 | log::debug!("Found state: {:?}", state); 413 | 414 | if let Some(error) = state.error { 415 | log::info!("Login error from server: {error}"); 416 | 417 | // cleanup URL 418 | Self::cleanup_url(); 419 | 420 | // error from the OAuth2 server 421 | return Err(OAuth2Error::LoginResult(error)); 422 | } 423 | 424 | if let Some(code) = state.code { 425 | // cleanup URL 426 | Self::cleanup_url(); 427 | 428 | match state.state { 429 | None => { 430 | return Err(OAuth2Error::LoginResult( 431 | "Missing state from server".to_string(), 432 | )) 433 | } 434 | Some(state) => { 435 | let stored_state = get_from_store(STORAGE_KEY_CSRF_TOKEN)?; 436 | 437 | if state != stored_state { 438 | return Err(OAuth2Error::LoginResult("State mismatch".to_string())); 439 | } 440 | } 441 | } 442 | 443 | let state: C::LoginState = 444 | SessionStorage::get(STORAGE_KEY_LOGIN_STATE).map_err(|err| { 445 | OAuth2Error::Storage(format!("Failed to load login state: {err}")) 446 | })?; 447 | 448 | log::debug!("Login state: {state:?}"); 449 | 450 | let redirect_url = get_from_store(STORAGE_KEY_REDIRECT_URL)?; 451 | log::debug!("Redirect URL: {redirect_url}"); 452 | let redirect_url = Url::parse(&redirect_url).map_err(|err| { 453 | OAuth2Error::LoginResult(format!("Failed to parse redirect URL: {err}")) 454 | })?; 455 | 456 | let client = client.clone().set_redirect_uri(redirect_url); 457 | 458 | let result = client.exchange_code(code, state).await; 459 | self.update_state_from_result(result); 460 | 461 | Ok(true) 462 | } else { 463 | log::debug!("Neither an error nor a code. Continue without applying state."); 464 | Ok(false) 465 | } 466 | } 467 | 468 | fn post_login_redirect(&self) -> Result<(), OAuth2Error> { 469 | let config = self.config.as_ref().ok_or(OAuth2Error::NotInitialized)?; 470 | let Some(redirect_callback) = config 471 | .default_login_options 472 | .as_ref() 473 | .and_then(|opts| opts.post_login_redirect_callback.clone()) 474 | else { 475 | return Ok(()); 476 | }; 477 | let Some(url) = get_from_store_optional(STORAGE_KEY_POST_LOGIN_URL)? else { 478 | return Ok(()); 479 | }; 480 | SessionStorage::delete(STORAGE_KEY_POST_LOGIN_URL); 481 | redirect_callback.emit(url); 482 | 483 | Ok(()) 484 | } 485 | 486 | fn update_state_from_result( 487 | &mut self, 488 | result: Result<(OAuth2Context, C::SessionState), OAuth2Error>, 489 | ) { 490 | match result { 491 | Ok((state, session_state)) => { 492 | self.update_state(state, Some(session_state)); 493 | } 494 | Err(err) => { 495 | self.update_state(err.into(), None); 496 | } 497 | } 498 | } 499 | 500 | async fn refresh(&mut self) { 501 | let (client, session_state) = 502 | if let (Some(client), Some(session_state)) = (&self.client, &self.session_state) { 503 | (client.clone(), session_state.clone()) 504 | } else { 505 | // we need to refresh but lost our client 506 | self.update_state( 507 | OAuth2Context::NotAuthenticated { 508 | reason: Reason::Expired, 509 | }, 510 | None, 511 | ); 512 | return; 513 | }; 514 | 515 | if let OAuth2Context::Authenticated(Authentication { 516 | refresh_token: Some(refresh_token), 517 | .. 518 | }) = &self.state 519 | { 520 | log::debug!("Triggering refresh"); 521 | 522 | let result = client 523 | .exchange_refresh_token(refresh_token.clone(), session_state) 524 | .await; 525 | 526 | if let Err(err) = &result { 527 | log::warn!("Failed to refresh token: {err}"); 528 | } 529 | 530 | self.update_state_from_result(result); 531 | } 532 | } 533 | 534 | /// Extract the state from the query. 535 | fn find_query_state() -> Option { 536 | if let Ok(url) = Self::current_url() { 537 | let query: HashMap<_, _> = url.query_pairs().collect(); 538 | 539 | Some(State { 540 | code: query.get("code").map(ToString::to_string), 541 | state: query.get("state").map(ToString::to_string), 542 | error: query.get("error").map(ToString::to_string), 543 | }) 544 | } else { 545 | None 546 | } 547 | } 548 | 549 | fn current_url() -> Result { 550 | let href = window().location().href().map_err(|err| { 551 | err.as_string() 552 | .unwrap_or_else(|| "unable to get current location".to_string()) 553 | })?; 554 | Url::parse(&href).map_err(|err| err.to_string()) 555 | } 556 | 557 | fn cleanup_url() { 558 | if let Ok(mut url) = Self::current_url() { 559 | url.set_query(None); 560 | let state = history().state().unwrap_or(JsValue::NULL); 561 | history() 562 | .replace_state_with_url(&state, "", Some(url.as_str())) 563 | .ok(); 564 | } 565 | } 566 | 567 | async fn configure(&mut self, config: AgentConfiguration) { 568 | self.configured(Self::make_client(config).await).await; 569 | } 570 | 571 | fn start_login(&mut self, options: Option) -> Result<(), OAuth2Error> { 572 | let client = self.client.as_ref().ok_or(OAuth2Error::NotInitialized)?; 573 | let config = self.config.as_ref().ok_or(OAuth2Error::NotInitialized)?; 574 | 575 | let options = 576 | options.unwrap_or_else(|| config.default_login_options.clone().unwrap_or_default()); 577 | 578 | let current_url = Self::current_url().map_err(OAuth2Error::StartLogin)?; 579 | 580 | // take the parameter value first, then the agent configured value, then fall back to the default 581 | let redirect_url = options 582 | .redirect_url 583 | .or_else(|| { 584 | config 585 | .default_login_options 586 | .as_ref() 587 | .and_then(|opts| opts.redirect_url.clone()) 588 | }) 589 | .unwrap_or_else(|| current_url.clone()); 590 | 591 | if redirect_url != current_url { 592 | SessionStorage::set(STORAGE_KEY_POST_LOGIN_URL, current_url) 593 | .map_err(|err| OAuth2Error::StartLogin(err.to_string()))?; 594 | } 595 | 596 | let login_context = client.make_login_context(config, redirect_url.clone())?; 597 | 598 | SessionStorage::set(STORAGE_KEY_CSRF_TOKEN, login_context.csrf_token) 599 | .map_err(|err| OAuth2Error::StartLogin(err.to_string()))?; 600 | 601 | SessionStorage::set(STORAGE_KEY_LOGIN_STATE, login_context.state) 602 | .map_err(|err| OAuth2Error::StartLogin(err.to_string()))?; 603 | 604 | SessionStorage::set(STORAGE_KEY_REDIRECT_URL, redirect_url) 605 | .map_err(|err| OAuth2Error::StartLogin(err.to_string()))?; 606 | 607 | let mut login_url = login_context.url; 608 | 609 | login_url.query_pairs_mut().extend_pairs(options.query); 610 | 611 | // the next call will most likely navigate away from this page 612 | 613 | window() 614 | .location() 615 | .set_href(login_url.as_str()) 616 | .map_err(|err| { 617 | OAuth2Error::StartLogin( 618 | err.as_string() 619 | .unwrap_or_else(|| "Unable to navigate to login page".to_string()), 620 | ) 621 | })?; 622 | 623 | Ok(()) 624 | } 625 | 626 | fn logout_opts(&mut self, options: Option) { 627 | if let Some(client) = &self.client { 628 | if let Some(session_state) = self.session_state.clone() { 629 | // let the client know that log out, clients may navigate to a different 630 | // page 631 | log::debug!("Notify client of logout"); 632 | let options = options 633 | .or_else(|| { 634 | self.config 635 | .as_ref() 636 | .and_then(|config| config.default_logout_options.clone()) 637 | }) 638 | .unwrap_or_default(); 639 | client.logout(session_state, options); 640 | } 641 | } 642 | 643 | // There is a bug in yew, which panics during re-rendering, which might be triggered 644 | // by the next step. Doing the update later, might not trigger the issue as it might 645 | // cause the application to navigate to a different page. 646 | self.update_state( 647 | OAuth2Context::NotAuthenticated { 648 | reason: Reason::Logout, 649 | }, 650 | None, 651 | ); 652 | } 653 | } 654 | 655 | impl OAuth2Operations for Agent 656 | where 657 | C: Client, 658 | { 659 | fn configure(&self, config: AgentConfiguration) -> Result<(), Error> { 660 | self.tx 661 | .try_send(Msg::Configure(config)) 662 | .map_err(|_| Error::NoAgent) 663 | } 664 | 665 | fn start_login(&self) -> Result<(), Error> { 666 | self.tx 667 | .try_send(Msg::StartLogin(None)) 668 | .map_err(|_| Error::NoAgent) 669 | } 670 | 671 | fn start_login_opts(&self, options: LoginOptions) -> Result<(), Error> { 672 | self.tx 673 | .try_send(Msg::StartLogin(Some(options))) 674 | .map_err(|_| Error::NoAgent) 675 | } 676 | 677 | fn logout(&self) -> Result<(), Error> { 678 | self.tx 679 | .try_send(Msg::Logout(None)) 680 | .map_err(|_| Error::NoAgent) 681 | } 682 | 683 | fn logout_opts(&self, options: LogoutOptions) -> Result<(), Error> { 684 | self.tx 685 | .try_send(Msg::Logout(Some(options))) 686 | .map_err(|_| Error::NoAgent) 687 | } 688 | } 689 | -------------------------------------------------------------------------------- /src/agent/ops.rs: -------------------------------------------------------------------------------- 1 | use super::{AgentConfiguration, Client, LoginOptions, LogoutOptions}; 2 | use std::fmt::{Display, Formatter}; 3 | 4 | /// Operation error 5 | #[derive(Clone, Debug)] 6 | pub enum Error { 7 | /// The agent cannot be reached. 8 | NoAgent, 9 | } 10 | 11 | impl Display for Error { 12 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 13 | match self { 14 | Self::NoAgent => write!(f, "no agent"), 15 | } 16 | } 17 | } 18 | 19 | impl std::error::Error for Error {} 20 | 21 | /// Operations for the OAuth2 agent 22 | pub trait OAuth2Operations { 23 | /// Configure the agent with a configuration. 24 | /// 25 | /// This is normally done by the [`crate::components::context::OAuth2`] context component. 26 | fn configure(&self, config: AgentConfiguration) -> Result<(), Error>; 27 | 28 | /// Start a login flow with default options. 29 | fn start_login(&self) -> Result<(), Error>; 30 | 31 | /// Start a login flow. 32 | fn start_login_opts(&self, options: LoginOptions) -> Result<(), Error>; 33 | 34 | /// Trigger the logout with default options. 35 | fn logout(&self) -> Result<(), Error>; 36 | 37 | /// Trigger the logout. 38 | fn logout_opts(&self, options: LogoutOptions) -> Result<(), Error>; 39 | } 40 | -------------------------------------------------------------------------------- /src/agent/state.rs: -------------------------------------------------------------------------------- 1 | use super::OAuth2Error; 2 | use gloo_storage::errors::StorageError; 3 | use gloo_storage::{SessionStorage, Storage}; 4 | use std::fmt::Display; 5 | 6 | pub(crate) const STORAGE_KEY_CSRF_TOKEN: &str = "ctron/oauth2/csrfToken"; 7 | pub(crate) const STORAGE_KEY_LOGIN_STATE: &str = "ctron/oauth2/loginState"; 8 | pub(crate) const STORAGE_KEY_REDIRECT_URL: &str = "ctron/oauth2/redirectUrl"; 9 | pub(crate) const STORAGE_KEY_POST_LOGIN_URL: &str = "ctron/oauth2/postLoginUrl"; 10 | 11 | #[derive(Debug)] 12 | pub(crate) struct State { 13 | pub code: Option, 14 | pub state: Option, 15 | pub error: Option, 16 | } 17 | 18 | pub(crate) fn get_from_store + Display>(key: K) -> Result { 19 | get_from_store_optional(&key)?.ok_or_else(|| OAuth2Error::storage_key_empty(key)) 20 | } 21 | 22 | pub(crate) fn get_from_store_optional + Display>( 23 | key: K, 24 | ) -> Result, OAuth2Error> { 25 | match SessionStorage::get::(key.as_ref()) { 26 | Err(StorageError::KeyNotFound(_)) => Ok(None), 27 | Err(err) => Err(OAuth2Error::Storage(err.to_string())), 28 | Ok(value) if value.is_empty() => Err(OAuth2Error::storage_key_empty(key)), 29 | Ok(value) => Ok(Some(value)), 30 | } 31 | } 32 | 33 | /// Login state, stored in the session 34 | #[derive(Clone, Debug, PartialEq, Eq)] 35 | pub struct LoginState { 36 | pub redirect_url: Option, 37 | pub post_login_url: Option, 38 | } 39 | 40 | impl LoginState { 41 | /// Read the state from the session 42 | pub fn from_storage() -> Result { 43 | Ok(Self { 44 | redirect_url: get_from_store_optional(STORAGE_KEY_REDIRECT_URL)?, 45 | post_login_url: get_from_store_optional(STORAGE_KEY_POST_LOGIN_URL)?, 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/authenticated.rs: -------------------------------------------------------------------------------- 1 | //! The [`Authenticated`] component 2 | 3 | use super::missing_context; 4 | use crate::context::OAuth2Context; 5 | use yew::prelude::*; 6 | 7 | /// Properties for the [`Authenticated`] component 8 | #[derive(Clone, Debug, PartialEq, Properties)] 9 | pub struct AuthenticatedProperties { 10 | /// The children to show then the context is authenticated. 11 | pub children: Children, 12 | } 13 | 14 | /// A Yew component, rendering when the agent is authenticated. 15 | #[function_component(Authenticated)] 16 | pub fn authenticated(props: &AuthenticatedProperties) -> Html { 17 | let auth = use_context::(); 18 | 19 | html!( 20 | if let Some(auth) = auth { 21 | if let OAuth2Context::Authenticated{..} = auth { 22 | { for props.children.iter() } 23 | } 24 | } else { 25 | { missing_context() } 26 | } 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/components/context/agent.rs: -------------------------------------------------------------------------------- 1 | use crate::agent::{self, Client}; 2 | use std::ops::{Deref, DerefMut}; 3 | use std::sync::atomic::{AtomicUsize, Ordering}; 4 | use yew::hook; 5 | 6 | /// A wrapper for the [`agent::Agent`]. 7 | /// 8 | /// Required as Yew has some requirements for the type of a context, like [`PartialEq`]. 9 | #[derive(Clone, Debug)] 10 | pub struct Agent(agent::Agent, usize); 11 | 12 | static COUNTER: AtomicUsize = AtomicUsize::new(0); 13 | 14 | impl Agent { 15 | pub fn new(agent: agent::Agent) -> Self { 16 | let id = COUNTER.fetch_add(1, Ordering::AcqRel); 17 | 18 | Self(agent, id) 19 | } 20 | } 21 | 22 | impl PartialEq for Agent { 23 | fn eq(&self, other: &Self) -> bool { 24 | self.1.eq(&other.1) 25 | } 26 | } 27 | 28 | impl Deref for Agent { 29 | type Target = agent::Agent; 30 | 31 | fn deref(&self) -> &Self::Target { 32 | &self.0 33 | } 34 | } 35 | 36 | impl DerefMut for Agent { 37 | fn deref_mut(&mut self) -> &mut Self::Target { 38 | &mut self.0 39 | } 40 | } 41 | 42 | /// Get the authentication agent. 43 | #[hook] 44 | pub fn use_auth_agent() -> Option> 45 | where 46 | C: Client, 47 | { 48 | yew::prelude::use_context() 49 | } 50 | -------------------------------------------------------------------------------- /src/components/context/mod.rs: -------------------------------------------------------------------------------- 1 | //! The main, wrapping [`OAuth2`] component 2 | 3 | mod agent; 4 | 5 | pub use agent::*; 6 | 7 | use crate::{ 8 | agent::{AgentConfiguration, Client, LoginOptions, LogoutOptions, OAuth2Operations}, 9 | context::{LatestAccessToken, OAuth2Context}, 10 | }; 11 | use agent::Agent as AgentContext; 12 | use std::time::Duration; 13 | use yew::prelude::*; 14 | 15 | /// Properties for the context component. 16 | #[derive(Clone, Debug, Properties)] 17 | pub struct OAuth2Properties { 18 | /// The client configuration 19 | pub config: C::Configuration, 20 | 21 | /// Scopes to request for the session 22 | #[prop_or_default] 23 | pub scopes: Vec, 24 | 25 | /// The grace period for the session timeout 26 | /// 27 | /// The amount of time before the token expiration when the agent will refresh it. 28 | #[prop_or(Duration::from_secs(30))] 29 | pub grace_period: Duration, 30 | 31 | /// A maximum expiration time. 32 | /// 33 | /// This can be used to limit the token timeout. If present, the token will be considered 34 | /// expired at the provided expiration or the configured maximum expiration, whatever is 35 | /// first. 36 | #[prop_or_default] 37 | pub max_expiration: Option, 38 | 39 | // The audience to be associated to the access tokens inside this context 40 | #[prop_or_default] 41 | pub audience: Option, 42 | 43 | /// Children which will have access to the [`OAuth2Context`]. 44 | #[prop_or_default] 45 | pub children: Children, 46 | 47 | /// Default [`LoginOptions`] that will be used unless more specific options have been requested. 48 | #[prop_or_default] 49 | pub login_options: Option, 50 | 51 | /// Default [`LogoutOptions`] that will be used unless more specific options have been requested. 52 | #[prop_or_default] 53 | pub logout_options: Option, 54 | } 55 | 56 | impl PartialEq for OAuth2Properties { 57 | fn eq(&self, other: &Self) -> bool { 58 | self.config == other.config 59 | && self.scopes == other.scopes 60 | && self.grace_period == other.grace_period 61 | && self.max_expiration == other.max_expiration 62 | && self.audience == other.audience 63 | && self.children == other.children 64 | } 65 | } 66 | 67 | /// Yew component providing the OAuth2 context and configuring the agent. 68 | /// 69 | /// All items making using of the OAuth2 or OpenID Connect context must be below this element. 70 | pub struct OAuth2 { 71 | context: OAuth2Context, 72 | latest_access_token: LatestAccessToken, 73 | agent: AgentContext, 74 | config: AgentConfiguration, 75 | } 76 | 77 | #[doc(hidden)] 78 | pub enum Msg { 79 | Context(OAuth2Context), 80 | } 81 | 82 | impl Component for OAuth2 { 83 | type Message = Msg; 84 | type Properties = OAuth2Properties; 85 | 86 | fn create(ctx: &Context) -> Self { 87 | let config = Self::make_config(ctx.props()); 88 | let callback = ctx.link().callback(Msg::Context); 89 | 90 | let agent = crate::agent::Agent::new(move |s| callback.emit(s)); 91 | let _ = agent.configure(config.clone()); 92 | 93 | Self { 94 | context: OAuth2Context::NotInitialized, 95 | latest_access_token: LatestAccessToken { 96 | access_token: Default::default(), 97 | }, 98 | agent: AgentContext::new(agent), 99 | config, 100 | } 101 | } 102 | 103 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 104 | match msg { 105 | Self::Message::Context(context) => { 106 | if self.context != context { 107 | self.latest_access_token 108 | .set_access_token(context.access_token()); 109 | self.context = context; 110 | return true; 111 | } 112 | } 113 | } 114 | false 115 | } 116 | 117 | fn changed(&mut self, ctx: &Context, _: &Self::Properties) -> bool { 118 | let config = Self::make_config(ctx.props()); 119 | if self.config != config { 120 | // only reconfigure agent when necessary 121 | let _ = self.agent.configure(config.clone()); 122 | self.config = config; 123 | } 124 | 125 | true 126 | } 127 | 128 | fn view(&self, ctx: &Context) -> Html { 129 | html!( 130 | <> 131 | context={self.context.clone()} > 132 | > context={self.agent.clone()}> 133 | context={self.latest_access_token.clone()}> 134 | { for ctx.props().children.iter() } 135 | > 136 | >> 137 | > 138 | 139 | ) 140 | } 141 | } 142 | 143 | impl OAuth2 { 144 | fn make_config(props: &OAuth2Properties) -> AgentConfiguration { 145 | AgentConfiguration { 146 | config: props.config.clone(), 147 | scopes: props.scopes.clone(), 148 | grace_period: props.grace_period, 149 | max_expiration: props.max_expiration, 150 | audience: props.audience.clone(), 151 | default_login_options: props.login_options.clone(), 152 | default_logout_options: props.logout_options.clone(), 153 | } 154 | } 155 | } 156 | 157 | #[cfg(feature = "openid")] 158 | pub mod openid { 159 | //! Convenient access to OpenID Connect context 160 | pub type OAuth2 = super::OAuth2; 161 | } 162 | 163 | pub mod oauth2 { 164 | //! Convenient access to OAuth2 context 165 | pub type OAuth2 = super::OAuth2; 166 | } 167 | -------------------------------------------------------------------------------- /src/components/failure.rs: -------------------------------------------------------------------------------- 1 | //! The [`Failure`] component 2 | 3 | use super::missing_context; 4 | use crate::context::OAuth2Context; 5 | use yew::prelude::*; 6 | 7 | /// Properties for the [`Failure`] component 8 | #[derive(Clone, Debug, PartialEq, Properties)] 9 | pub struct FailureProps { 10 | #[prop_or_default] 11 | pub id: Option, 12 | #[prop_or_default] 13 | pub style: Option, 14 | #[prop_or_default] 15 | pub class: Option, 16 | #[prop_or_default] 17 | pub element: Option, 18 | #[prop_or_default] 19 | pub children: Children, 20 | } 21 | 22 | #[function_component(Failure)] 23 | pub fn failure(props: &FailureProps) -> Html { 24 | let auth = use_context::(); 25 | 26 | let element = props.element.as_deref().unwrap_or("div").to_string(); 27 | 28 | match auth { 29 | None => missing_context(), 30 | Some(OAuth2Context::Failed(..)) => { 31 | html!( 32 | <@{element} 33 | id={ props.id.clone() } 34 | style={ props.style.clone() } 35 | class={ &props.class } 36 | > 37 | { for props.children.iter() } 38 | 39 | ) 40 | } 41 | Some(_) => html!(), 42 | } 43 | } 44 | 45 | #[derive(Clone, Debug, PartialEq, Eq, Properties)] 46 | pub struct FailureMessageProps { 47 | #[prop_or_default] 48 | pub id: Option, 49 | #[prop_or_default] 50 | pub style: Option, 51 | #[prop_or_default] 52 | pub class: Option, 53 | #[prop_or_default] 54 | pub element: Option, 55 | } 56 | 57 | #[function_component(FailureMessage)] 58 | pub fn failure_message(props: &FailureMessageProps) -> Html { 59 | let auth = use_context::(); 60 | 61 | let element = props.element.as_deref().unwrap_or("span").to_string(); 62 | 63 | match auth { 64 | None => missing_context(), 65 | Some(OAuth2Context::Failed(message)) => { 66 | html!( 67 | <@{element} 68 | id={ props.id.clone() } 69 | style={ props.style.clone() } 70 | class={ &props.class } 71 | > 72 | { message } 73 | 74 | ) 75 | } 76 | Some(_) => html!(), 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/components/mod.rs: -------------------------------------------------------------------------------- 1 | //! Components used when rendering HTML 2 | 3 | pub mod authenticated; 4 | pub mod context; 5 | pub mod failure; 6 | pub mod noauth; 7 | pub mod redirect; 8 | pub mod use_authentication; 9 | 10 | // only put pub use for common components 11 | 12 | pub use authenticated::*; 13 | pub use failure::*; 14 | pub use noauth::*; 15 | pub use use_authentication::*; 16 | 17 | use yew::prelude::*; 18 | 19 | fn missing_context() -> Html { 20 | html!(
{ "Unable to find OAuth2 context! This element needs to be wrapped into an `OAuth2` component somewhere in the hierarchy"}
) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/noauth.rs: -------------------------------------------------------------------------------- 1 | //! The [`NotAuthenticated`] component 2 | 3 | use super::missing_context; 4 | use crate::context::OAuth2Context; 5 | use yew::prelude::*; 6 | 7 | /// Properties for the [`NotAuthenticated`] component 8 | #[derive(Clone, Debug, PartialEq, Properties)] 9 | pub struct Props { 10 | pub children: Children, 11 | } 12 | 13 | /// Yew component, rendering children when the agent is not authenticated. 14 | #[function_component(NotAuthenticated)] 15 | pub fn not_authenticated(props: &Props) -> Html { 16 | let auth = use_context::(); 17 | 18 | match auth { 19 | None => missing_context(), 20 | Some(OAuth2Context::NotInitialized) => html!(), 21 | Some(OAuth2Context::NotAuthenticated { .. } | OAuth2Context::Failed(..)) => { 22 | html!({ for props.children.iter() }) 23 | } 24 | Some(OAuth2Context::Authenticated { .. }) => { 25 | html!() 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/redirect/location.rs: -------------------------------------------------------------------------------- 1 | //! Redirect by setting the browser's location directly. 2 | 3 | use super::{Redirect, Redirector, RedirectorProperties}; 4 | use gloo_utils::window; 5 | use yew::prelude::*; 6 | 7 | /// A redirector using the browser's location. 8 | pub struct LocationRedirector; 9 | 10 | impl Redirector for LocationRedirector { 11 | type Properties = LocationProperties; 12 | 13 | fn new(_: &Context) -> Self { 14 | Self {} 15 | } 16 | 17 | fn logout(&self, props: &Self::Properties) { 18 | log::debug!("Navigate due to logout: {}", props.logout_href); 19 | window().location().set_href(&props.logout_href).ok(); 20 | } 21 | } 22 | 23 | #[derive(Clone, Debug, PartialEq, Properties)] 24 | pub struct LocationProperties { 25 | /// The content to show when being logged in. 26 | #[prop_or_default] 27 | pub children: Html, 28 | 29 | /// The logout URL to redirect to 30 | pub logout_href: String, 31 | } 32 | 33 | impl RedirectorProperties for LocationProperties { 34 | fn children(&self) -> &Html { 35 | &self.children 36 | } 37 | } 38 | 39 | pub mod oauth2 { 40 | //! Convenient access for the OAuth2 variant 41 | use super::*; 42 | use crate::agent::client::OAuth2Client as Client; 43 | pub type LocationRedirect = Redirect; 44 | } 45 | 46 | #[cfg(feature = "openid")] 47 | pub mod openid { 48 | //! Convenient access for the Open ID Connect variant 49 | use super::*; 50 | use crate::agent::client::OpenIdClient as Client; 51 | pub type LocationRedirect = Redirect; 52 | } 53 | -------------------------------------------------------------------------------- /src/components/redirect/mod.rs: -------------------------------------------------------------------------------- 1 | //! Components for redirecting the user 2 | 3 | pub mod location; 4 | #[cfg(feature = "yew-nested-router")] 5 | pub mod router; 6 | 7 | use super::missing_context; 8 | use crate::agent::{Client, OAuth2Operations}; 9 | use crate::components::context::Agent; 10 | use crate::context::{OAuth2Context, Reason}; 11 | use yew::{context::ContextHandle, prelude::*}; 12 | 13 | pub trait Redirector: 'static { 14 | type Properties: RedirectorProperties; 15 | 16 | fn new(ctx: &Context) -> Self; 17 | 18 | fn logout(&self, props: &Self::Properties); 19 | } 20 | 21 | pub trait RedirectorProperties: yew::Properties { 22 | fn children(&self) -> &Html; 23 | } 24 | 25 | #[derive(Debug, Clone)] 26 | pub enum Msg { 27 | Context(OAuth2Context), 28 | Agent(Agent), 29 | } 30 | 31 | /// A component which redirect the user in case the context is not authenticated. 32 | pub struct Redirect 33 | where 34 | C: Client, 35 | R: Redirector, 36 | { 37 | auth: Option, 38 | agent: Option>, 39 | 40 | _auth_handler: Option>, 41 | _agent_handler: Option>>, 42 | 43 | redirector: R, 44 | } 45 | 46 | impl Component for Redirect 47 | where 48 | C: Client, 49 | R: Redirector, 50 | { 51 | type Message = Msg; 52 | type Properties = R::Properties; 53 | 54 | fn create(ctx: &Context) -> Self { 55 | let (auth, auth_handler) = match ctx 56 | .link() 57 | .context::(ctx.link().callback(Msg::Context)) 58 | { 59 | Some((auth, handler)) => (Some(auth), Some(handler)), 60 | None => (None, None), 61 | }; 62 | let (agent, agent_handler) = match ctx 63 | .link() 64 | .context::>(ctx.link().callback(Msg::Agent)) 65 | { 66 | Some((agent, handler)) => (Some(agent), Some(handler)), 67 | None => (None, None), 68 | }; 69 | 70 | log::debug!("Initial state: {auth:?}"); 71 | 72 | let mut result = Self { 73 | auth: None, 74 | agent, 75 | _auth_handler: auth_handler, 76 | _agent_handler: agent_handler, 77 | redirector: R::new(ctx), 78 | }; 79 | 80 | if let Some(auth) = auth { 81 | result.apply_state(ctx, auth); 82 | } 83 | 84 | result 85 | } 86 | 87 | fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { 88 | log::debug!("update: {msg:?}"); 89 | 90 | match msg { 91 | Self::Message::Context(auth) => { 92 | let changed = self.auth.as_ref() != Some(&auth); 93 | self.apply_state(ctx, auth); 94 | changed 95 | } 96 | Self::Message::Agent(agent) => { 97 | self.agent = Some(agent); 98 | // we never re-render based on an agent change 99 | false 100 | } 101 | } 102 | } 103 | 104 | fn view(&self, ctx: &Context) -> Html { 105 | match self.auth { 106 | None => missing_context(), 107 | Some(OAuth2Context::Authenticated(..)) => ctx.props().children().clone(), 108 | _ => html!(), 109 | } 110 | } 111 | } 112 | 113 | impl Redirect 114 | where 115 | C: Client, 116 | R: Redirector, 117 | { 118 | fn apply_state(&mut self, ctx: &Context, auth: OAuth2Context) { 119 | if self.auth.as_ref() == Some(&auth) { 120 | return; 121 | } 122 | 123 | log::debug!("Current state: {:?}, new state: {:?}", self.auth, auth); 124 | 125 | match &auth { 126 | OAuth2Context::NotInitialized 127 | | OAuth2Context::Failed(..) 128 | | OAuth2Context::Authenticated { .. } => { 129 | // nothing that we should handle 130 | } 131 | OAuth2Context::NotAuthenticated { reason } => match reason { 132 | Reason::NewSession => { 133 | // new session, then start the login 134 | if let Some(agent) = &mut self.agent { 135 | let _ = agent.start_login(); 136 | } 137 | } 138 | Reason::Expired | Reason::Logout => { 139 | match self.auth { 140 | None | Some(OAuth2Context::NotInitialized) => { 141 | if let Some(agent) = &mut self.agent { 142 | let _ = agent.start_login(); 143 | } 144 | } 145 | _ => { 146 | // expired or logged out explicitly, then redirect to the logout page 147 | self.logout(ctx.props()); 148 | } 149 | } 150 | } 151 | }, 152 | } 153 | 154 | self.auth = Some(auth); 155 | } 156 | 157 | fn logout(&self, props: &R::Properties) { 158 | self.redirector.logout(props); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/components/redirect/router.rs: -------------------------------------------------------------------------------- 1 | //! Redirect by pushing a new [`yew_nested_router::prelude::Target`]. 2 | 3 | use super::{Redirect, Redirector, RedirectorProperties}; 4 | use yew::prelude::*; 5 | use yew_nested_router::prelude::*; 6 | 7 | /// A redirector using Yew's Router, and the Browser's History API. 8 | pub struct RouterRedirector 9 | where 10 | R: Target + 'static, 11 | { 12 | router: Option>, 13 | _handle: Option>>, 14 | } 15 | 16 | impl Redirector for RouterRedirector 17 | where 18 | R: Target + 'static, 19 | { 20 | type Properties = RouterProperties; 21 | 22 | fn new(ctx: &Context) -> Self { 23 | // while the "route" can change, the "router" itself does not. 24 | let cb = Callback::from(|_| {}); 25 | let (router, handle) = match ctx.link().context::>(cb) { 26 | Some((router, handle)) => (Some(router), Some(handle)), 27 | None => (None, None), 28 | }; 29 | 30 | Self { 31 | router, 32 | _handle: handle, 33 | } 34 | } 35 | 36 | fn logout(&self, props: &Self::Properties) { 37 | let route = props.logout.clone(); 38 | log::debug!("ChangeRoute due to logout: {:?}", route); 39 | 40 | if let Some(router) = &self.router { 41 | router.push(route); 42 | } 43 | } 44 | } 45 | 46 | /// Properties for the [`RouterRedirector`] component. 47 | #[derive(Clone, Debug, PartialEq, Properties)] 48 | pub struct RouterProperties 49 | where 50 | R: Target + 'static, 51 | { 52 | #[prop_or_default] 53 | pub children: Html, 54 | pub logout: R, 55 | } 56 | 57 | impl RedirectorProperties for RouterProperties 58 | where 59 | R: Target + 'static, 60 | { 61 | fn children(&self) -> &Html { 62 | &self.children 63 | } 64 | } 65 | 66 | pub mod oauth2 { 67 | //! Convenient access for the OAuth2 variant 68 | use super::*; 69 | use crate::agent::client::OAuth2Client; 70 | pub type RouterRedirect = Redirect>; 71 | } 72 | 73 | #[cfg(feature = "openid")] 74 | pub mod openid { 75 | //! Convenient access for the Open ID Connect variant 76 | use super::*; 77 | use crate::agent::client::OpenIdClient; 78 | pub type RouterRedirect = Redirect>; 79 | } 80 | -------------------------------------------------------------------------------- /src/components/use_authentication.rs: -------------------------------------------------------------------------------- 1 | //! The [`UseAuthentication`] component 2 | 3 | use super::missing_context; 4 | use crate::{ 5 | context::{Authentication, OAuth2Context}, 6 | hook::use_auth_state, 7 | }; 8 | use std::rc::Rc; 9 | use yew::prelude::*; 10 | 11 | /// A trait which component's properties must implement in order to receive the 12 | /// context. 13 | pub trait UseAuthenticationProperties: Clone { 14 | fn set_authentication(&mut self, auth: Authentication); 15 | } 16 | 17 | /// Properties for the [`UseAuthentication`] component 18 | #[derive(Clone, Debug, Properties)] 19 | pub struct UseAuthenticationComponentProperties 20 | where 21 | C: BaseComponent, 22 | C::Properties: UseAuthenticationProperties, 23 | { 24 | pub children: ChildrenWithProps, 25 | } 26 | 27 | impl PartialEq for UseAuthenticationComponentProperties 28 | where 29 | C: BaseComponent, 30 | C::Properties: UseAuthenticationProperties, 31 | { 32 | fn eq(&self, other: &Self) -> bool { 33 | self.children == other.children 34 | } 35 | } 36 | 37 | /// A component which injects the authentication into the properties of component. 38 | /// 39 | /// The component's properties must implement the trait [`UseAuthenticationProperties`]. 40 | /// 41 | /// ## Example 42 | /// 43 | /// ```rust 44 | /// use yew_oauth2::prelude::*; 45 | /// use yew::prelude::*; 46 | /// 47 | /// #[derive(Clone, Debug, PartialEq, Properties)] 48 | /// pub struct Props { 49 | /// #[prop_or_default] 50 | /// pub auth: Option, 51 | /// } 52 | /// 53 | /// impl UseAuthenticationProperties for Props { 54 | /// fn set_authentication(&mut self, auth: Authentication) { 55 | /// self.auth = Some(auth); 56 | /// } 57 | /// } 58 | /// 59 | /// #[function_component(ViewUseAuth)] 60 | /// pub fn view_use_auth(props: &Props) -> Html { 61 | /// html!( 62 | /// <> 63 | ///

{ "Use authentication example"}

64 | ///
{ format!("Auth: {:?}", props.auth) }
65 | /// 66 | /// ) 67 | /// } 68 | /// ``` 69 | #[function_component(UseAuthentication)] 70 | pub fn use_authentication(props: &UseAuthenticationComponentProperties) -> Html 71 | where 72 | C: BaseComponent, 73 | C::Properties: UseAuthenticationProperties, 74 | { 75 | let auth = use_auth_state(); 76 | 77 | html!( 78 | if let Some(auth) = auth { 79 | if let OAuth2Context::Authenticated(auth) = auth { 80 | { for props.children.iter().map(|mut c|{ 81 | let props = Rc::make_mut(&mut c.props); 82 | props.set_authentication(auth.clone()); 83 | c 84 | }) } 85 | } 86 | } else { 87 | { missing_context() } 88 | } 89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | //! Configuration 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | /// Configuration for OpenID Connect 6 | pub mod openid { 7 | use super::*; 8 | 9 | /// OpenID Connect client configuration 10 | /// 11 | /// ## Non-exhaustive 12 | /// 13 | /// This struct is `#[non_exhaustive]`, so it is not possible to directly create a struct, creating a new struct 14 | /// is done using the [`Config::new`] function. Additional properties are set using the `with_*` functions. 15 | #[non_exhaustive] 16 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 17 | pub struct Config { 18 | /// The client ID 19 | pub client_id: String, 20 | /// The OpenID connect issuer URL. 21 | pub issuer_url: String, 22 | /// An override for the end session URL. 23 | pub end_session_url: Option, 24 | /// The URL to navigate to after the logout has been completed. 25 | pub after_logout_url: Option, 26 | /// The name of the query parameter for the post logout redirect. 27 | /// 28 | /// The defaults to `post_logout_redirect_uri` for OpenID RP initiated logout. 29 | /// However, e.g. older Keycloak instances, require this to be `redirect_uri`. 30 | pub post_logout_redirect_name: Option, 31 | /// Additional audiences of the ID token which are considered trustworthy. 32 | /// 33 | /// Those audiences are allowed in addition to the client ID. 34 | pub additional_trusted_audiences: Vec, 35 | } 36 | 37 | impl Config { 38 | /// Create a new configuration 39 | pub fn new(client_id: impl Into, issuer_url: impl Into) -> Self { 40 | Self { 41 | client_id: client_id.into(), 42 | issuer_url: issuer_url.into(), 43 | 44 | end_session_url: None, 45 | after_logout_url: None, 46 | post_logout_redirect_name: None, 47 | additional_trusted_audiences: vec![], 48 | } 49 | } 50 | 51 | /// Set an override for the URL for ending the session. 52 | pub fn with_end_session_url(mut self, end_session_url: impl Into) -> Self { 53 | self.end_session_url = Some(end_session_url.into()); 54 | self 55 | } 56 | 57 | /// Set the URL the issuer should redirect to after the logout 58 | pub fn with_after_logout_url(mut self, after_logout_url: impl Into) -> Self { 59 | self.after_logout_url = Some(after_logout_url.into()); 60 | self 61 | } 62 | 63 | /// Set the name of the post logout redirect query parameter 64 | pub fn with_post_logout_redirect_name( 65 | mut self, 66 | post_logout_redirect_name: impl Into, 67 | ) -> Self { 68 | self.post_logout_redirect_name = Some(post_logout_redirect_name.into()); 69 | self 70 | } 71 | 72 | /// Set the additionally trusted audiences 73 | pub fn with_additional_trusted_audiences( 74 | mut self, 75 | additional_trusted_audiences: impl IntoIterator>, 76 | ) -> Self { 77 | self.additional_trusted_audiences = additional_trusted_audiences 78 | .into_iter() 79 | .map(|s| s.into()) 80 | .collect(); 81 | self 82 | } 83 | 84 | /// Extend the additionally trusted audiences. 85 | pub fn extend_additional_trusted_audiences( 86 | mut self, 87 | additional_trusted_audiences: impl IntoIterator>, 88 | ) -> Self { 89 | self.additional_trusted_audiences 90 | .extend(additional_trusted_audiences.into_iter().map(|s| s.into())); 91 | self 92 | } 93 | 94 | /// Add an additionally trusted audience. 95 | pub fn add_additional_trusted_audience( 96 | mut self, 97 | additional_trusted_audience: impl Into, 98 | ) -> Self { 99 | self.additional_trusted_audiences 100 | .push(additional_trusted_audience.into()); 101 | self 102 | } 103 | } 104 | } 105 | 106 | /// Configuration for OAuth2 107 | pub mod oauth2 { 108 | use super::*; 109 | 110 | /// Plain OAuth2 client configuration 111 | /// 112 | /// ## Non-exhaustive 113 | /// 114 | /// This struct is `#[non_exhaustive]`, so it is not possible to directly create a struct, creating a new struct 115 | /// is done using the [`crate::openid::Config::new`] function. 116 | #[non_exhaustive] 117 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 118 | pub struct Config { 119 | /// The client ID 120 | pub client_id: String, 121 | /// The authentication URL 122 | pub auth_url: String, 123 | /// The token exchange URL 124 | pub token_url: String, 125 | } 126 | 127 | impl Config { 128 | /// Create a new configuration 129 | pub fn new( 130 | client_id: impl Into, 131 | auth_url: impl Into, 132 | token_url: impl Into, 133 | ) -> Self { 134 | Self { 135 | client_id: client_id.into(), 136 | auth_url: auth_url.into(), 137 | token_url: token_url.into(), 138 | } 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/context/mod.rs: -------------------------------------------------------------------------------- 1 | //! The Authentication Context 2 | 3 | mod utils; 4 | 5 | use std::cell::RefCell; 6 | use std::rc::Rc; 7 | pub use utils::*; 8 | 9 | #[cfg(feature = "openid")] 10 | pub type Claims = openidconnect::IdTokenClaims< 11 | openidconnect::EmptyAdditionalClaims, 12 | openidconnect::core::CoreGenderClaim, 13 | >; 14 | 15 | /// The authentication information 16 | #[derive(Clone, Debug, Default, PartialEq)] 17 | #[cfg_attr(not(feature = "openid"), derive(Eq))] 18 | pub struct Authentication { 19 | /// The access token 20 | pub access_token: String, 21 | /// An optional refresh token 22 | pub refresh_token: Option, 23 | /// OpenID claims 24 | #[cfg(feature = "openid")] 25 | pub claims: Option>, 26 | /// Expiration timestamp in seconds 27 | pub expires: Option, 28 | } 29 | 30 | /// The authentication context 31 | #[derive(Clone, Debug, PartialEq)] 32 | #[cfg_attr(not(feature = "openid"), derive(Eq))] 33 | pub enum OAuth2Context { 34 | /// The agent is not initialized yet. 35 | NotInitialized, 36 | /// Not authenticated. 37 | NotAuthenticated { 38 | /// Reason why it is not authenticated. 39 | reason: Reason, 40 | }, 41 | /// Session is authenticated. 42 | Authenticated(Authentication), 43 | /// Something failed. 44 | Failed(String), 45 | } 46 | 47 | impl OAuth2Context { 48 | /// Get the optional authentication. 49 | /// 50 | /// Allows easy access to the authentication information. Will return [`None`] if the 51 | /// context is not authenticated. 52 | pub fn authentication(&self) -> Option<&Authentication> { 53 | match self { 54 | Self::Authenticated(auth) => Some(auth), 55 | _ => None, 56 | } 57 | } 58 | 59 | /// Get the access token, if the context is [`OAuth2Context::Authenticated`] 60 | pub fn access_token(&self) -> Option<&str> { 61 | self.authentication().map(|auth| auth.access_token.as_str()) 62 | } 63 | 64 | /// Get the claims, if the context is [`OAuth2Context::Authenticated`] 65 | #[cfg(feature = "openid")] 66 | pub fn claims(&self) -> Option<&Claims> { 67 | self.authentication() 68 | .and_then(|auth| auth.claims.as_ref().map(|claims| claims.as_ref())) 69 | } 70 | } 71 | 72 | /// The reason why the context is un-authenticated. 73 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 74 | pub enum Reason { 75 | /// Because the user didn't log in so far. 76 | NewSession, 77 | /// Because there was a session, but now it expired. 78 | Expired, 79 | /// Because the user chose to log out. 80 | Logout, 81 | } 82 | 83 | /// A handle to access the latest access token. 84 | #[derive(Clone)] 85 | pub struct LatestAccessToken { 86 | pub(crate) access_token: Rc>>, 87 | } 88 | 89 | impl PartialEq for LatestAccessToken { 90 | fn eq(&self, other: &Self) -> bool { 91 | Rc::ptr_eq(&self.access_token, &other.access_token) 92 | } 93 | } 94 | 95 | impl LatestAccessToken { 96 | /// The latest access token, if there is any. 97 | pub fn access_token(&self) -> Option { 98 | match self.access_token.as_ref().try_borrow() { 99 | Ok(token) => (*token).clone(), 100 | Err(_) => None, 101 | } 102 | } 103 | 104 | pub(crate) fn set_access_token(&self, access_token: Option>) { 105 | *self.access_token.borrow_mut() = access_token.map(|s| s.into()); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/context/utils.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use yew::{context::ContextHandle, html::Scope, prelude::*}; 3 | 4 | /// Helper to get an unzipped version of the context. 5 | pub trait UnzippedWith { 6 | fn unzipped_with( 7 | &self, 8 | callback: Callback, 9 | ) -> (Option, Option>); 10 | } 11 | 12 | /// Helper to get an unzipped version of the context. 13 | pub trait Unzipped { 14 | type Message; 15 | 16 | fn unzipped(&self, f: F) -> (Option, Option>) 17 | where 18 | F: Fn(OAuth2Context) -> Self::Message + 'static; 19 | } 20 | 21 | impl UnzippedWith for Context 22 | where 23 | C: Component, 24 | { 25 | fn unzipped_with( 26 | &self, 27 | callback: Callback, 28 | ) -> (Option, Option>) { 29 | self.link().unzipped_with(callback) 30 | } 31 | } 32 | 33 | impl UnzippedWith for Scope 34 | where 35 | C: Component, 36 | { 37 | fn unzipped_with( 38 | &self, 39 | callback: Callback, 40 | ) -> (Option, Option>) { 41 | match self.context(callback) { 42 | Some((auth, handle)) => (Some(auth), Some(handle)), 43 | None => (None, None), 44 | } 45 | } 46 | } 47 | 48 | impl Unzipped for Context 49 | where 50 | C: Component, 51 | { 52 | type Message = C::Message; 53 | 54 | fn unzipped(&self, f: F) -> (Option, Option>) 55 | where 56 | F: Fn(OAuth2Context) -> Self::Message + 'static, 57 | { 58 | self.link().unzipped(f) 59 | } 60 | } 61 | 62 | impl Unzipped for Scope 63 | where 64 | C: Component, 65 | { 66 | type Message = C::Message; 67 | 68 | fn unzipped(&self, f: F) -> (Option, Option>) 69 | where 70 | F: Fn(OAuth2Context) -> Self::Message + 'static, 71 | { 72 | self.unzipped_with(self.callback(f)) 73 | } 74 | } 75 | 76 | /// Functional component for using the context. 77 | pub trait UseContext { 78 | type Message; 79 | 80 | fn use_context(&self, f: F) -> ContextValue 81 | where 82 | T: 'static + Clone + PartialEq, 83 | F: Fn(T) -> Self::Message + 'static; 84 | } 85 | 86 | impl UseContext for Scope 87 | where 88 | C: Component, 89 | { 90 | type Message = C::Message; 91 | 92 | fn use_context(&self, f: F) -> ContextValue 93 | where 94 | T: 'static + Clone + PartialEq, 95 | F: Fn(T) -> Self::Message + 'static, 96 | { 97 | self.context::(self.callback(f)).into() 98 | } 99 | } 100 | 101 | impl UseContext for Context 102 | where 103 | C: Component, 104 | { 105 | type Message = C::Message; 106 | 107 | fn use_context(&self, f: F) -> ContextValue 108 | where 109 | T: 'static + Clone + PartialEq, 110 | F: Fn(T) -> Self::Message + 'static, 111 | { 112 | self.link().use_context(f) 113 | } 114 | } 115 | 116 | /// A helper which holds both value and handle in a way that it can easily be updated if it 117 | /// is present. 118 | pub enum ContextValue 119 | where 120 | T: 'static + Clone + PartialEq, 121 | { 122 | Some(T, ContextHandle), 123 | None, 124 | } 125 | 126 | impl From)>> for ContextValue 127 | where 128 | T: 'static + Clone + PartialEq, 129 | { 130 | fn from(value: Option<(T, ContextHandle)>) -> Self { 131 | match value { 132 | Some(value) => Self::Some(value.0, value.1), 133 | None => Self::None, 134 | } 135 | } 136 | } 137 | 138 | impl ContextValue 139 | where 140 | T: 'static + Clone + PartialEq, 141 | { 142 | /// Set a new value, only if the handle is present. 143 | pub fn set(&mut self, new_value: T) { 144 | match self { 145 | Self::Some(value, _) => *value = new_value, 146 | Self::None => {} 147 | } 148 | } 149 | 150 | /// Get the current value. 151 | pub fn get(&self) -> Option<&T> { 152 | match &self { 153 | Self::Some(value, _) => Some(value), 154 | Self::None => None, 155 | } 156 | } 157 | 158 | pub fn as_ref(&self) -> Option<&T> { 159 | self.get() 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/hook.rs: -------------------------------------------------------------------------------- 1 | //! Hooks for Yew 2 | 3 | use crate::{context::LatestAccessToken, prelude::OAuth2Context}; 4 | use yew::prelude::*; 5 | 6 | #[cfg(feature = "openid")] 7 | pub mod openid { 8 | pub use crate::agent::client::OpenIdClient as Client; 9 | 10 | #[yew::hook] 11 | pub fn use_auth_agent() -> Option> { 12 | crate::components::context::use_auth_agent::() 13 | } 14 | } 15 | 16 | pub mod oauth2 { 17 | pub use crate::agent::client::OAuth2Client as Client; 18 | 19 | #[yew::hook] 20 | pub fn use_auth_agent() -> Option> { 21 | crate::components::context::use_auth_agent::() 22 | } 23 | } 24 | 25 | /// Get the authentication state. 26 | #[hook] 27 | pub fn use_auth_state() -> Option { 28 | use_context() 29 | } 30 | 31 | /// Get a handle to retrieve the latest access token 32 | #[hook] 33 | pub fn use_latest_access_token() -> Option { 34 | use_context() 35 | } 36 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(docsrs, feature(doc_auto_cfg))] 2 | //! Yew components to implement OAuth2 and OpenID Connect logins. 3 | //! 4 | //! ## OAuth2 or Open ID Connect 5 | //! 6 | //! This crate supports both plain OAuth2 and Open ID Connect (OIDC). OIDC layers a few features 7 | //! on top of OAuth2 (like logout URLs, discovery, …). 8 | //! 9 | //! In order to use OIDC, you will need to enable the feature `openid`. 10 | //! 11 | //! ## Example 12 | //! 13 | //! **NOTE:** Also see the [readme](https://github.com/ctron/yew-oauth2/blob/main/README.md#examples) for more examples. 14 | //! 15 | //! The following is a basic example: 16 | //! 17 | //! ```rust 18 | //! use yew::prelude::*; 19 | //! use yew_oauth2::prelude::*; 20 | //! use yew_oauth2::oauth2::*; // use `openid::*` when using OpenID connect 21 | //! 22 | //! #[function_component(MyApplication)] 23 | //! fn my_app() -> Html { 24 | //! let config = Config::new( 25 | //! "my-client", 26 | //! "https://my-sso/auth/realms/my-realm/protocol/openid-connect/auth", 27 | //! "https://my-sso/auth/realms/my-realm/protocol/openid-connect/token" 28 | //! ); 29 | //! 30 | //! html!( 31 | //! 32 | //! 33 | //! 34 | //! ) 35 | //! } 36 | //! 37 | //! #[function_component(MyApplicationMain)] 38 | //! fn my_app_main() -> Html { 39 | //! let agent = use_auth_agent().expect("Must be nested inside an OAuth2 component"); 40 | //! 41 | //! let login = use_callback(agent.clone(), |_, agent| { 42 | //! let _ = agent.start_login(); 43 | //! }); 44 | //! let logout = use_callback(agent, |_, agent| { 45 | //! let _ = agent.logout(); 46 | //! }); 47 | //! 48 | //! html!( 49 | //! <> 50 | //! 51 | //! 52 | //! 53 | //! 54 | //! 55 | //! 56 | //! 57 | //! 58 | //! ) 59 | //! } 60 | //! ``` 61 | 62 | pub mod agent; 63 | pub mod components; 64 | pub mod config; 65 | pub mod context; 66 | pub mod hook; 67 | pub mod prelude; 68 | 69 | #[cfg(feature = "openid")] 70 | pub mod openid { 71 | //! Common used Open ID Connect features 72 | pub use crate::agent::client::OpenIdClient as Client; 73 | pub use crate::components::context::openid::*; 74 | pub use crate::components::redirect::location::openid::*; 75 | #[cfg(feature = "yew-nested-router")] 76 | pub use crate::components::redirect::router::openid::*; 77 | pub use crate::config::openid::*; 78 | 79 | #[yew::hook] 80 | pub fn use_auth_agent() -> Option> { 81 | crate::components::context::use_auth_agent::() 82 | } 83 | } 84 | 85 | pub mod oauth2 { 86 | //! Common used OAuth2 features 87 | pub use crate::agent::client::OAuth2Client as Client; 88 | pub use crate::components::context::oauth2::*; 89 | pub use crate::components::redirect::location::oauth2::*; 90 | #[cfg(feature = "yew-nested-router")] 91 | pub use crate::components::redirect::router::oauth2::*; 92 | pub use crate::config::oauth2::*; 93 | 94 | #[yew::hook] 95 | pub fn use_auth_agent() -> Option> { 96 | crate::components::context::use_auth_agent::() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/prelude.rs: -------------------------------------------------------------------------------- 1 | //! The prelude, includes most things you will need. 2 | 3 | pub use crate::agent::{LoginOptions, OAuth2Error, OAuth2Operations}; 4 | pub use crate::components::*; 5 | pub use crate::context::*; 6 | pub use crate::hook::*; 7 | 8 | pub use crate::oauth2; 9 | #[cfg(feature = "openid")] 10 | pub use crate::openid; 11 | -------------------------------------------------------------------------------- /yew-oauth2-example/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /yew-oauth2-example/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yew-oauth2-example" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | yew-oauth2 = { path = ".." } 8 | 9 | gloo-timers = "0.3" 10 | humantime = "2" 11 | log = { version = "0.4", features = [] } 12 | serde_json = "1" 13 | time = "0.3" 14 | wasm-bindgen = "0.2.92" 15 | wasm-logger = "0.2" 16 | yew = { version = "0.21.0", features = ["csr"] } 17 | yew-nested-router = "0.7.0" 18 | 19 | openidconnect = { version = "3.0", optional = true } 20 | 21 | [features] 22 | default = ["openid"] 23 | openid = ["openidconnect", "yew-oauth2/openid"] 24 | -------------------------------------------------------------------------------- /yew-oauth2-example/assets/style.scss: -------------------------------------------------------------------------------- 1 | a { 2 | text-decoration: underline; 3 | cursor: pointer; 4 | } -------------------------------------------------------------------------------- /yew-oauth2-example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OAuth2 example 7 | 8 | 9 | 10 | 11 |
12 | 13 | -------------------------------------------------------------------------------- /yew-oauth2-example/src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::components::*; 2 | use yew::prelude::*; 3 | use yew_nested_router::{components::*, prelude::*}; 4 | use yew_oauth2::prelude::*; 5 | 6 | #[cfg(not(feature = "openid"))] 7 | use yew_oauth2::oauth2::*; 8 | #[cfg(feature = "openid")] 9 | use yew_oauth2::openid::*; 10 | 11 | #[derive(Target, Debug, Clone, PartialEq, Eq)] 12 | pub enum AppRoute { 13 | Component, 14 | Function, 15 | UseLatestToken, 16 | UseAuthentication, 17 | #[cfg(feature = "openid")] 18 | Identity, 19 | #[target(index)] 20 | Index, 21 | } 22 | 23 | #[function_component(Content)] 24 | pub fn content() -> Html { 25 | let agent = use_auth_agent().expect("Requires OAuth2Context component in parent hierarchy"); 26 | 27 | let login = { 28 | let agent = agent.clone(); 29 | Callback::from(move |_: MouseEvent| { 30 | if let Err(err) = agent.start_login() { 31 | log::warn!("Failed to start login: {err}"); 32 | } 33 | }) 34 | }; 35 | let logout = Callback::from(move |_: MouseEvent| { 36 | if let Err(err) = agent.logout() { 37 | log::warn!("Failed to logout: {err}"); 38 | } 39 | }); 40 | 41 | #[cfg(feature = "openid")] 42 | let openid_routes = html! ( 43 |
  • to={AppRoute::Identity}> { "Identity" } >
  • 44 | ); 45 | #[cfg(not(feature = "openid"))] 46 | let openid_routes = html!(); 47 | 48 | html!( 49 | <> 50 | > 51 | 52 |
      53 |
    • 54 |
    55 |
    56 | 57 |

    58 | 59 |

    60 |
      61 |
    • to={AppRoute::Index}> { "Index" } >
    • 62 |
    • to={AppRoute::Component}> { "Component" } >
    • 63 |
    • to={AppRoute::Function}> { "Function" } >
    • 64 |
    • to={AppRoute::UseAuthentication}> { "Use" } >
    • 65 |
    • to={AppRoute::UseLatestToken}> { "Latest Token" } >
    • 66 | { openid_routes } 67 |
    68 | 69 | render={|switch| match switch { 70 | AppRoute::Index => html!(

    { "You are logged in"}

    ), 71 | AppRoute::Component => html!(), 72 | AppRoute::Function => html!(), 73 | AppRoute::UseLatestToken => html!(), 74 | AppRoute::UseAuthentication => html!( 75 | > 76 | 77 | > 78 | ), 79 | #[cfg(feature = "openid")] 80 | AppRoute::Identity => html!(), 81 | }}/> 82 |
    83 | 84 | render={move |switch| match switch { 85 | AppRoute::Index => html!( 86 | <> 87 |

    88 | { "You need to log in" } 89 |

    90 |

    91 | 92 |

    93 | 94 | ), 95 | _ => html!(), 96 | }} /> 97 |
    98 |
    > 99 | 100 | ) 101 | } 102 | 103 | #[function_component(Application)] 104 | pub fn app() -> Html { 105 | #[cfg(not(feature = "openid"))] 106 | let config = Config::new( 107 | "example", 108 | "http://localhost:8081/realms/master/protocol/openid-connect/auth", 109 | "http://localhost:8081/realms/master/protocol/openid-connect/token", 110 | ); 111 | 112 | #[cfg(feature = "openid")] 113 | let config = Config::new("example", "http://localhost:8081/realms/master"); 114 | 115 | let mode = if cfg!(feature = "openid") { 116 | "OpenID Connect" 117 | } else { 118 | "pure OAuth2" 119 | }; 120 | 121 | html!( 122 | <> 123 |

    { "Login example (" } {mode} { ")"}

    124 | 125 | 129 | 130 | 131 | 132 | ) 133 | } 134 | -------------------------------------------------------------------------------- /yew-oauth2-example/src/components/component.rs: -------------------------------------------------------------------------------- 1 | use crate::components::ViewAuthContext; 2 | use yew::context::ContextHandle; 3 | use yew::prelude::*; 4 | use yew_oauth2::prelude::*; 5 | 6 | pub enum Msg { 7 | Update(OAuth2Context), 8 | } 9 | 10 | pub struct ViewAuthInfoComponent { 11 | auth: Option, 12 | _handle: Option>, 13 | } 14 | 15 | impl Component for ViewAuthInfoComponent { 16 | type Message = Msg; 17 | type Properties = (); 18 | 19 | fn create(ctx: &Context) -> Self { 20 | let (auth, handle) = match ctx 21 | .link() 22 | .context::(ctx.link().callback(Msg::Update)) 23 | { 24 | Some((auth, handle)) => (Some(auth), Some(handle)), 25 | None => (None, None), 26 | }; 27 | 28 | Self { 29 | auth, 30 | _handle: handle, 31 | } 32 | } 33 | 34 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 35 | match msg { 36 | Self::Message::Update(auth) => self.auth = Some(auth), 37 | } 38 | true 39 | } 40 | 41 | fn view(&self, _ctx: &Context) -> Html { 42 | html!( 43 | if let Some(auth) = self.auth.clone() { 44 |

    { "Component example"}

    45 | 46 | } else { 47 | { "OAuth2 context not found." } 48 | } 49 | ) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /yew-oauth2-example/src/components/expiration.rs: -------------------------------------------------------------------------------- 1 | use gloo_timers::callback::Interval; 2 | use std::time::Duration; 3 | use time::OffsetDateTime; 4 | use yew::{context::ContextHandle, prelude::*}; 5 | use yew_oauth2::prelude::*; 6 | 7 | pub enum Msg { 8 | Context(OAuth2Context), 9 | Update, 10 | } 11 | 12 | pub struct Expiration { 13 | auth: Option, 14 | _handle: Option>, 15 | _interval: Interval, 16 | } 17 | 18 | impl Component for Expiration { 19 | type Message = Msg; 20 | type Properties = (); 21 | 22 | fn create(ctx: &Context) -> Self { 23 | let (auth, handle) = match ctx 24 | .link() 25 | .context::(ctx.link().callback(Msg::Context)) 26 | { 27 | Some((auth, handle)) => (Some(auth), Some(handle)), 28 | None => (None, None), 29 | }; 30 | 31 | let cb = ctx.link().callback(|()| Msg::Update); 32 | let interval = Interval::new(1_000, move || cb.emit(())); 33 | 34 | Self { 35 | auth, 36 | _handle: handle, 37 | _interval: interval, 38 | } 39 | } 40 | 41 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 42 | match msg { 43 | Self::Message::Context(auth) => self.auth = Some(auth), 44 | Self::Message::Update => { 45 | // just trigger re-render 46 | } 47 | } 48 | true 49 | } 50 | 51 | fn view(&self, _ctx: &Context) -> Html { 52 | if let Some(OAuth2Context::Authenticated(Authentication { 53 | expires: Some(expires), 54 | .. 55 | })) = self.auth 56 | { 57 | if let Ok(expires) = OffsetDateTime::from_unix_timestamp(expires as i64) { 58 | let rem = expires - OffsetDateTime::now_utc(); 59 | let rem = Duration::from_secs(rem.whole_seconds() as u64); 60 | let rem = humantime::Duration::from(rem); 61 | 62 | html!(
    { "Expires: "} { expires } { format!(" (remaining: {})", rem) }
    ) 63 | } else { 64 | html!(
    {"Failed to convert unix timestamp"}
    ) 65 | } 66 | } else { 67 | html!() 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /yew-oauth2-example/src/components/functional.rs: -------------------------------------------------------------------------------- 1 | use super::ViewAuthContext; 2 | use yew::prelude::*; 3 | use yew_oauth2::prelude::*; 4 | 5 | #[function_component(ViewAuthInfoFunctional)] 6 | pub fn view_info() -> Html { 7 | let auth = use_context::(); 8 | 9 | html!( 10 | if let Some(auth) = auth { 11 |

    { "Function component example"}

    12 | 13 | } else { 14 | { "OAuth2 context not found." } 15 | } 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /yew-oauth2-example/src/components/identity.rs: -------------------------------------------------------------------------------- 1 | use yew::prelude::*; 2 | use yew_oauth2::prelude::*; 3 | 4 | #[function_component(ViewIdentity)] 5 | pub fn view_identity() -> Html { 6 | let auth = use_context::(); 7 | 8 | html!( 9 | <> 10 |

    { "Claims"}

    11 | if let Some(OAuth2Context::Authenticated (Authentication {claims: Some(claims) , ..})) = auth { 12 |
    13 |                     { serde_json::to_string_pretty(claims.as_ref()).unwrap_or_default() }
    14 |                 
    15 | } else { 16 | { "No claims." } 17 | } 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /yew-oauth2-example/src/components/mod.rs: -------------------------------------------------------------------------------- 1 | mod component; 2 | mod expiration; 3 | mod functional; 4 | #[cfg(feature = "openid")] 5 | mod identity; 6 | mod use_auth; 7 | mod use_latest_token; 8 | mod view; 9 | 10 | pub use component::*; 11 | pub use expiration::*; 12 | pub use functional::*; 13 | #[cfg(feature = "openid")] 14 | pub use identity::*; 15 | pub use use_auth::*; 16 | pub use use_latest_token::*; 17 | pub use view::*; 18 | -------------------------------------------------------------------------------- /yew-oauth2-example/src/components/use_auth.rs: -------------------------------------------------------------------------------- 1 | use super::ViewAuthInfo; 2 | use yew::prelude::*; 3 | use yew_oauth2::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, Properties)] 6 | pub struct Props { 7 | #[prop_or(dummy())] 8 | pub auth: Authentication, 9 | } 10 | 11 | impl UseAuthenticationProperties for Props { 12 | fn set_authentication(&mut self, auth: Authentication) { 13 | self.auth = auth; 14 | } 15 | } 16 | 17 | fn dummy() -> Authentication { 18 | Authentication { 19 | access_token: "".to_string(), 20 | refresh_token: None, 21 | #[cfg(feature = "openid")] 22 | claims: None, 23 | expires: None, 24 | } 25 | } 26 | 27 | #[function_component(ViewUseAuth)] 28 | pub fn view_use_auth(props: &Props) -> Html { 29 | html!( 30 | <> 31 |

    { "Use authentication example"}

    32 | 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /yew-oauth2-example/src/components/use_latest_token.rs: -------------------------------------------------------------------------------- 1 | use yew::prelude::*; 2 | use yew_oauth2::prelude::*; 3 | 4 | #[function_component(UseLatestToken)] 5 | pub fn use_latest_token() -> Html { 6 | let latest_token = use_latest_access_token().unwrap(); 7 | 8 | let node_ref = use_node_ref(); 9 | let onclick = use_callback( 10 | (node_ref.clone(), latest_token), 11 | |_, (node_ref, latest_token)| { 12 | if let Some(node) = node_ref.get() { 13 | node.set_text_content(Some(&format!("{:?}", latest_token.access_token()))); 14 | } 15 | }, 16 | ); 17 | 18 | html!( 19 | <> 20 |

    { "Use latest example"}

    21 |

    {"A hook which gets a handle to the latest access token, but not re-render the component based on its change. You can actively get the token from it. Click on the button to retrieve the most recent valid token."}

    22 | 23 | 24 | 25 |
    26 | {"Token:"} 27 |
    28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /yew-oauth2-example/src/components/view.rs: -------------------------------------------------------------------------------- 1 | use yew::prelude::*; 2 | use yew_oauth2::context::{Authentication, OAuth2Context}; 3 | 4 | #[derive(Clone, Debug, PartialEq, Properties)] 5 | pub struct ContextProps { 6 | pub auth: OAuth2Context, 7 | } 8 | 9 | #[function_component(ViewAuthContext)] 10 | pub fn view_context(props: &ContextProps) -> Html { 11 | html!( 12 |
    13 |
    { "Context" }
    14 |
    15 |
    16 |                     { format!("{:#?}", props.auth) }
    17 |                 
    18 |
    19 |
    20 | ) 21 | } 22 | 23 | #[derive(Clone, Debug, PartialEq, Properties)] 24 | pub struct AuthProps { 25 | pub auth: Authentication, 26 | } 27 | 28 | #[function_component(ViewAuthInfo)] 29 | pub fn view_auth(props: &AuthProps) -> Html { 30 | html!( 31 |
    32 |
    { "Context" }
    33 |
    34 |
    35 |                     { format!("{:#?}", props.auth) }
    36 |                 
    37 |
    38 |
    39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /yew-oauth2-example/src/main.rs: -------------------------------------------------------------------------------- 1 | #![recursion_limit = "1024"] 2 | 3 | mod app; 4 | mod components; 5 | 6 | use wasm_bindgen::prelude::*; 7 | 8 | #[cfg(not(debug_assertions))] 9 | const LOG_LEVEL: log::Level = log::Level::Info; 10 | #[cfg(debug_assertions)] 11 | const LOG_LEVEL: log::Level = log::Level::Trace; 12 | 13 | pub fn main() -> Result<(), JsValue> { 14 | wasm_logger::init(wasm_logger::Config::new(LOG_LEVEL)); 15 | log::info!("Starting application"); 16 | yew::Renderer::::new().render(); 17 | Ok(()) 18 | } 19 | -------------------------------------------------------------------------------- /yew-oauth2-redirect-example/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /yew-oauth2-redirect-example/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yew-oauth2-redirect-example" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | yew-oauth2 = { path = "..", features = ["yew-nested-router"] } 8 | 9 | gloo-timers = "0.3" 10 | humantime = "2" 11 | log = { version = "0.4", features = [] } 12 | serde_json = "1" 13 | time = "0.3" 14 | wasm-bindgen = "0.2.92" 15 | wasm-logger = "0.2" 16 | yew = { version = "0.21.0", features = ["csr"] } 17 | yew-nested-router = "0.7.0" 18 | 19 | openidconnect = { version = "3.0", optional = true } 20 | 21 | [features] 22 | default = ["openid"] 23 | openid = ["openidconnect", "yew-oauth2/openid"] 24 | -------------------------------------------------------------------------------- /yew-oauth2-redirect-example/assets/style.scss: -------------------------------------------------------------------------------- 1 | a { 2 | text-decoration: underline; 3 | cursor: pointer; 4 | } -------------------------------------------------------------------------------- /yew-oauth2-redirect-example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OAuth2 example 7 | 8 | 9 | 10 | 11 |
    12 | 13 | -------------------------------------------------------------------------------- /yew-oauth2-redirect-example/src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::components::*; 2 | use yew::prelude::*; 3 | use yew_nested_router::{components::*, prelude::*}; 4 | use yew_oauth2::prelude::*; 5 | 6 | #[cfg(not(feature = "openid"))] 7 | use yew_oauth2::oauth2::*; 8 | #[cfg(feature = "openid")] 9 | use yew_oauth2::openid::*; 10 | 11 | #[derive(Target, Debug, Clone, PartialEq, Eq)] 12 | pub enum AppRoute { 13 | Authenticated(AuthenticatedRoute), 14 | #[target(index)] 15 | Index, 16 | } 17 | 18 | #[derive(Target, Debug, Clone, PartialEq, Eq)] 19 | pub enum AuthenticatedRoute { 20 | Component, 21 | Function, 22 | UseAuthentication, 23 | #[target(index)] 24 | Index, 25 | } 26 | 27 | #[function_component(Content)] 28 | pub fn content() -> Html { 29 | let agent = use_auth_agent().expect("Requires OAuth2Context component in parent hierarchy"); 30 | 31 | let login = use_callback(agent.clone(), |_, agent| { 32 | if let Err(err) = agent.start_login() { 33 | log::warn!("Failed to start login: {err}"); 34 | } 35 | }); 36 | let logout = use_callback(agent, |_, agent| { 37 | if let Err(err) = agent.logout() { 38 | log::warn!("Failed to logout: {err}"); 39 | } 40 | }); 41 | 42 | html!( 43 | > 44 | 45 |
      46 |
    • 47 |
    48 |
    49 | 50 | /* We show the full menu structure here */ 51 |
      52 |
    • to={AppRoute::Index}> { "Public" } >
    • 53 |
    • to={AppRoute::Authenticated(AuthenticatedRoute::Index)}> { "Authenticated" } > 54 |
        55 |
      • to={AppRoute::Authenticated(AuthenticatedRoute::Component)}> { "Component" } >
      • 56 |
      • to={AppRoute::Authenticated(AuthenticatedRoute::Function)}> { "Function" } >
      • 57 |
      • to={AppRoute::Authenticated(AuthenticatedRoute::UseAuthentication)}> { "Use" } >
      • 58 |
      59 |
    • 60 |
    61 | 62 | 68 | 69 | 83 | 84 | render={|switch| match switch { 85 | AppRoute::Index => html!(

    { "Welcome"}

    ), 86 | /* 87 | When the user requests an authenticated page, we hide this behind 88 | the `RouterRedirect` component. It will trigger a login when 89 | required, but not show any children when not logged in, but forward 90 | to the logout route instead. 91 | 92 | **NOTE:** For OpenID Connect it is important to also set the 93 | `after_logout_url` in the client configuration to some public route. 94 | */ 95 | AppRoute::Authenticated(authenticated) => html!( 96 | logout={ AppRoute::Index }> 97 | { 98 | match authenticated { 99 | AuthenticatedRoute::Index => html!(

    { "You are logged in"}

    ), 100 | AuthenticatedRoute::Component => html!(), 101 | AuthenticatedRoute::Function => html!(), 102 | AuthenticatedRoute::UseAuthentication => html!( 103 | > 104 | 105 | > 106 | ), 107 | } 108 | } 109 |
    > 110 | ) 111 | }}/> 112 |
    > 113 | ) 114 | } 115 | 116 | #[function_component(Application)] 117 | pub fn app() -> Html { 118 | #[cfg(not(feature = "openid"))] 119 | let config = Config::new( 120 | "example", 121 | "http://localhost:8081/realms/master/protocol/openid-connect/auth", 122 | "http://localhost:8081/realms/master/protocol/openid-connect/token", 123 | ); 124 | 125 | #[cfg(feature = "openid")] 126 | let config = Config::new("example", "http://localhost:8081/realms/master") 127 | /* 128 | Set the after logout URL to a public URL. Otherwise, the SSO server will redirect 129 | back to the current page, which is detected as a new session, and will try to log in 130 | again, if the page requires this. 131 | */ 132 | .with_after_logout_url("/"); 133 | 134 | let mode = if cfg!(feature = "openid") { 135 | "OpenID Connect" 136 | } else { 137 | "pure OAuth2" 138 | }; 139 | 140 | html!( 141 | <> 142 |

    { "Redirect example (" } {mode} { ")"}

    143 | 144 | 148 | 149 | 150 | 151 | ) 152 | } 153 | -------------------------------------------------------------------------------- /yew-oauth2-redirect-example/src/components/component.rs: -------------------------------------------------------------------------------- 1 | use crate::components::ViewAuthContext; 2 | use yew::context::ContextHandle; 3 | use yew::prelude::*; 4 | use yew_oauth2::prelude::*; 5 | 6 | pub enum Msg { 7 | Update(OAuth2Context), 8 | } 9 | 10 | pub struct ViewAuthInfoComponent { 11 | auth: Option, 12 | _handle: Option>, 13 | } 14 | 15 | impl Component for ViewAuthInfoComponent { 16 | type Message = Msg; 17 | type Properties = (); 18 | 19 | fn create(ctx: &Context) -> Self { 20 | let (auth, handle) = match ctx 21 | .link() 22 | .context::(ctx.link().callback(Msg::Update)) 23 | { 24 | Some((auth, handle)) => (Some(auth), Some(handle)), 25 | None => (None, None), 26 | }; 27 | 28 | Self { 29 | auth, 30 | _handle: handle, 31 | } 32 | } 33 | 34 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 35 | match msg { 36 | Self::Message::Update(auth) => self.auth = Some(auth), 37 | } 38 | true 39 | } 40 | 41 | fn view(&self, _ctx: &Context) -> Html { 42 | html!( 43 | if let Some(auth) = self.auth.clone() { 44 |

    { "Component example"}

    45 | 46 | } else { 47 | { "OAuth2 context not found." } 48 | } 49 | ) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /yew-oauth2-redirect-example/src/components/debug.rs: -------------------------------------------------------------------------------- 1 | use yew::prelude::*; 2 | use yew_oauth2::prelude::*; 3 | 4 | #[function_component(Debug)] 5 | pub fn debug() -> Html { 6 | let auth = use_context::(); 7 | 8 | html!( 9 |
    10 | if let Some(auth) = auth { 11 |
    { format!("{auth:#?}") }
    12 | } else { 13 | { "OAuth2 context not found." } 14 | } 15 |
    16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /yew-oauth2-redirect-example/src/components/expiration.rs: -------------------------------------------------------------------------------- 1 | use gloo_timers::callback::Interval; 2 | use std::time::Duration; 3 | use time::OffsetDateTime; 4 | use yew::{context::ContextHandle, prelude::*}; 5 | use yew_oauth2::prelude::*; 6 | 7 | pub enum Msg { 8 | Context(OAuth2Context), 9 | Update, 10 | } 11 | 12 | pub struct Expiration { 13 | auth: Option, 14 | _handle: Option>, 15 | _interval: Interval, 16 | } 17 | 18 | impl Component for Expiration { 19 | type Message = Msg; 20 | type Properties = (); 21 | 22 | fn create(ctx: &Context) -> Self { 23 | let (auth, handle) = match ctx 24 | .link() 25 | .context::(ctx.link().callback(Msg::Context)) 26 | { 27 | Some((auth, handle)) => (Some(auth), Some(handle)), 28 | None => (None, None), 29 | }; 30 | 31 | let cb = ctx.link().callback(|()| Msg::Update); 32 | let interval = Interval::new(1_000, move || cb.emit(())); 33 | 34 | Self { 35 | auth, 36 | _handle: handle, 37 | _interval: interval, 38 | } 39 | } 40 | 41 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 42 | match msg { 43 | Self::Message::Context(auth) => self.auth = Some(auth), 44 | Self::Message::Update => { 45 | // just trigger re-render 46 | } 47 | } 48 | true 49 | } 50 | 51 | fn view(&self, _ctx: &Context) -> Html { 52 | if let Some(OAuth2Context::Authenticated(Authentication { 53 | expires: Some(expires), 54 | .. 55 | })) = self.auth 56 | { 57 | if let Ok(expires) = OffsetDateTime::from_unix_timestamp(expires as i64) { 58 | let rem = expires - OffsetDateTime::now_utc(); 59 | let rem = Duration::from_secs(rem.whole_seconds() as u64); 60 | let rem = humantime::Duration::from(rem); 61 | 62 | html!(
    { "Expires: "} { expires } { format!(" (remaining: {})", rem) }
    ) 63 | } else { 64 | html!(
    {"Failed to convert unix timestamp"}
    ) 65 | } 66 | } else { 67 | html!() 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /yew-oauth2-redirect-example/src/components/functional.rs: -------------------------------------------------------------------------------- 1 | use super::ViewAuthContext; 2 | use yew::prelude::*; 3 | use yew_oauth2::prelude::*; 4 | 5 | #[function_component(ViewAuthInfoFunctional)] 6 | pub fn view_info() -> Html { 7 | let auth = use_context::(); 8 | 9 | html!( 10 | if let Some(auth) = auth { 11 |

    { "Function component example"}

    12 | 13 | } else { 14 | { "OAuth2 context not found." } 15 | } 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /yew-oauth2-redirect-example/src/components/mod.rs: -------------------------------------------------------------------------------- 1 | mod component; 2 | mod debug; 3 | mod expiration; 4 | mod functional; 5 | mod use_auth; 6 | mod view; 7 | 8 | pub use component::*; 9 | pub use debug::*; 10 | pub use expiration::*; 11 | pub use functional::*; 12 | pub use use_auth::*; 13 | pub use view::*; 14 | -------------------------------------------------------------------------------- /yew-oauth2-redirect-example/src/components/use_auth.rs: -------------------------------------------------------------------------------- 1 | use super::ViewAuthInfo; 2 | use yew::prelude::*; 3 | use yew_oauth2::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, Properties)] 6 | pub struct Props { 7 | #[prop_or(dummy())] 8 | pub auth: Authentication, 9 | } 10 | 11 | impl UseAuthenticationProperties for Props { 12 | fn set_authentication(&mut self, auth: Authentication) { 13 | self.auth = auth; 14 | } 15 | } 16 | 17 | fn dummy() -> Authentication { 18 | Authentication { 19 | access_token: "".to_string(), 20 | refresh_token: None, 21 | #[cfg(feature = "openid")] 22 | claims: None, 23 | expires: None, 24 | } 25 | } 26 | 27 | #[function_component(ViewUseAuth)] 28 | pub fn view_use_auth(props: &Props) -> Html { 29 | html!( 30 | <> 31 |

    { "Use authentication example"}

    32 | 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /yew-oauth2-redirect-example/src/components/view.rs: -------------------------------------------------------------------------------- 1 | use yew::prelude::*; 2 | use yew_oauth2::context::{Authentication, OAuth2Context}; 3 | 4 | #[derive(Clone, Debug, PartialEq, Properties)] 5 | pub struct ContextProps { 6 | pub auth: OAuth2Context, 7 | } 8 | 9 | #[function_component(ViewAuthContext)] 10 | pub fn view_context(props: &ContextProps) -> Html { 11 | html!( 12 |
    13 |
    { "Context" }
    14 |
    15 |
    16 |                     { format!("{:#?}", props.auth) }
    17 |                 
    18 |
    19 |
    20 | ) 21 | } 22 | 23 | #[derive(Clone, Debug, PartialEq, Properties)] 24 | pub struct AuthProps { 25 | pub auth: Authentication, 26 | } 27 | 28 | #[function_component(ViewAuthInfo)] 29 | pub fn view_auth(props: &AuthProps) -> Html { 30 | html!( 31 |
    32 |
    { "Context" }
    33 |
    34 |
    35 |                     { format!("{:#?}", props.auth) }
    36 |                 
    37 |
    38 |
    39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /yew-oauth2-redirect-example/src/main.rs: -------------------------------------------------------------------------------- 1 | #![recursion_limit = "1024"] 2 | 3 | mod app; 4 | mod components; 5 | 6 | use wasm_bindgen::prelude::*; 7 | 8 | #[cfg(not(debug_assertions))] 9 | const LOG_LEVEL: log::Level = log::Level::Info; 10 | #[cfg(debug_assertions)] 11 | const LOG_LEVEL: log::Level = log::Level::Trace; 12 | 13 | pub fn main() -> Result<(), JsValue> { 14 | wasm_logger::init(wasm_logger::Config::new(LOG_LEVEL)); 15 | log::info!("Starting application"); 16 | yew::Renderer::::new().render(); 17 | Ok(()) 18 | } 19 | --------------------------------------------------------------------------------