├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── build-and-test.yml │ ├── pack-and-publish.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── Changelog.md ├── Directory.Build.props ├── Directory.Build.targets ├── LICENSE.txt ├── README.md ├── WeixinAuth.sln ├── delete_all_bin_and_obj.cmd ├── global.json ├── src └── WeixinAuth │ ├── Apis │ ├── IWeixinAuthApi.cs │ └── WeixinAuthApi.cs │ ├── ClaimActions │ ├── ClaimActionCollectionMapExtensions.cs │ └── JsonKeyArrayClaimAction.cs │ ├── Extensions │ ├── AuthenticationPropertiesExtensions.cs │ ├── ClaimsExtensions.cs │ ├── JsonDocumentAuthExtensions.cs │ ├── LoggingExtensions.cs │ └── OAuthTokenResponseExtensions.cs │ ├── Helpers │ ├── CompressionExtensions.cs │ ├── WeixinAuthAuthenticationPropertiesHelper.cs │ ├── WeixinAuthHandlerHelper.cs │ └── Zipper.cs │ ├── WeixinAuth.csproj │ ├── WeixinAuthAuthenticationBuilderExtensions.cs │ ├── WeixinAuthClaimTypes.cs │ ├── WeixinAuthDefaults.cs │ ├── WeixinAuthHandler.cs │ ├── WeixinAuthLanguageCodes.cs │ ├── WeixinAuthOptions.cs │ ├── WeixinAuthPostConfigureOptions.cs │ ├── WeixinAuthScopes.cs │ └── WeixinAuthenticationTokenNames.cs └── test └── WeixinAuth.UnitTest ├── TestServers ├── TestExtensions.cs ├── TestHandlers.cs ├── TestHttpMessageHandler.cs ├── TestServerBuilder.cs └── TestTransaction.cs ├── WeixinAuth.UnitTest.csproj └── WeixinAuthTests.cs /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | #* text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | # Look for workflow files stored in the default location of `/.github/workflows` 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | 14 | - package-ecosystem: "nuget" 15 | directories: 16 | - "/src/*" 17 | - "/test/*" 18 | schedule: 19 | interval: "weekly" 20 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a .NET project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net 3 | 4 | name: build-and-test (reusable workflow) 5 | 6 | on: 7 | workflow_call: 8 | inputs: 9 | dotnet-version: 10 | description: "required dotnet version. The default is '8.0.x'." 11 | required: true 12 | type: string 13 | default: '8.0.x' 14 | dotnet-framework: 15 | description: "target framework for dotnet test. The default is 'net8.0'." 16 | required: true 17 | type: string 18 | default: 'net8.0' 19 | configuration: 20 | description: "The configuration to use for building the package. The default is 'Release'." 21 | required: true 22 | type: string 23 | default: 'Release' 24 | 25 | jobs: 26 | build-and-test: 27 | runs-on: ubuntu-latest 28 | env: 29 | NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages 30 | steps: 31 | - run: echo "The inputs.dotnet version is ${{ inputs.dotnet-version }}, framework is ${{ inputs.dotnet-framework }}." 32 | - run: echo "The inputs.configuration is ${{ inputs.configuration }}." 33 | - run: echo "The job was automatically triggered by a ${{ github.event_name }} event." 34 | - run: echo "This job is now running on a ${{ runner.os }} server hosted by GitHub!" 35 | - run: echo "The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}." 36 | 37 | - name: Check out repository code 38 | uses: actions/checkout@v4 39 | - run: echo "The ${{ github.repository }} repository has been cloned to the runner." 40 | - run: echo "The workflow is now ready to test your code on the runner." 41 | - name: List files in the repository 42 | run: | 43 | ls -la ${{ github.workspace }} 44 | 45 | - name: Setup dotnet 46 | uses: actions/setup-dotnet@v4 47 | with: 48 | dotnet-version: ${{ inputs.dotnet-version }} 49 | global-json-file: global.json 50 | cache: false 51 | - name: Display dotnet version 52 | run: dotnet --version 53 | 54 | - name: Install dependencies 55 | run: dotnet restore 56 | - name: Build 57 | run: dotnet build --no-restore --configuration ${{ inputs.configuration }} 58 | 59 | - name: Test with the dotnet CLI 60 | run: dotnet test --no-build --framework ${{ inputs.dotnet-framework }} --configuration ${{ inputs.configuration }} --verbosity normal --logger trx --results-directory "TestResults-${{ inputs.configuration }}-${{ inputs.dotnet-framework }}" 61 | - name: Upload dotnet test results 62 | uses: actions/upload-artifact@v4 63 | with: 64 | name: dontet-results-${{ inputs.configuration }}-${{ inputs.dotnet-framework }} 65 | path: TestResults-${{ inputs.configuration }}-${{ inputs.dotnet-framework }} 66 | if: ${{ always() }} 67 | 68 | - run: echo "This job's status is ${{ job.status }}." 69 | -------------------------------------------------------------------------------- /.github/workflows/pack-and-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a .NET project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net 3 | 4 | name: pack-and-publish (reusable workflow) 5 | 6 | on: 7 | workflow_call: 8 | secrets: 9 | NUGET_TOKEN: 10 | description: 'A nuget token to publish to nuget.org' 11 | required: true 12 | 13 | jobs: 14 | pack-and-publish: 15 | runs-on: ubuntu-latest 16 | env: 17 | NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages 18 | CONFIGURATION: 'Release' 19 | steps: 20 | - run: echo "The job was automatically triggered by a ${{ github.event_name }} event." 21 | - run: echo "This job is now running on a ${{ runner.os }} server hosted by GitHub!" 22 | - run: echo "The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}." 23 | 24 | - name: Check out repository code 25 | uses: actions/checkout@v4 26 | - run: echo "The ${{ github.repository }} repository has been cloned to the runner." 27 | - run: echo "The workflow is now ready to pack your code on the runner." 28 | - name: List files in the repository 29 | run: | 30 | ls -la ${{ github.workspace }} 31 | 32 | - name: Setup dotnet 33 | uses: actions/setup-dotnet@v4 34 | with: 35 | global-json-file: global.json 36 | cache: false 37 | - name: Display dotnet version 38 | run: dotnet --version 39 | 40 | - name: Install dependencies 41 | run: dotnet restore 42 | - name: Build 43 | run: dotnet build --no-restore --configuration ${{ env.CONFIGURATION }} 44 | 45 | - name: Create the NuGet package (.nupkg) 46 | run: dotnet pack --configuration Release 47 | - name: Publish the NuGet package to nuget.org 48 | run: dotnet nuget push "**/bin/Release/*.nupkg" -k $NUGET_AUTH_TOKEN -s https://api.nuget.org/v3/index.json --skip-duplicate 49 | env: 50 | NUGET_AUTH_TOKEN: ${{ secrets.NUGET_TOKEN }} 51 | # You should create this repository secret on https://github.com/myvas/AspNetCore.Email/settings/secrets/actions 52 | 53 | - run: echo "This job's status is ${{ job.status }}." 54 | 55 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will publish NuGet packages to nuget.org 2 | # For more information see: https://docs.github.com/en/actions/use-cases-and-examples/publishing-packages/about-packaging-with-github-actions 3 | 4 | name: publish 5 | 6 | on: 7 | workflow_dispatch: 8 | release: 9 | types: [ published ] 10 | 11 | jobs: 12 | build-and-test: 13 | strategy: 14 | matrix: 15 | dotnet: 16 | - version: '6.0.x' 17 | framework: 'net6.0' 18 | - version: '7.0.x' 19 | framework: 'net7.0' 20 | - version: '8.0.x' 21 | framework: 'net8.0' 22 | - version: '9.0.x' 23 | framework: 'net9.0' 24 | configuration: [ 'Release' ] 25 | uses: ./.github/workflows/build-and-test.yml 26 | with: 27 | dotnet-version: ${{ matrix.dotnet.version }} 28 | dotnet-framework: ${{ matrix.dotnet.framework }} 29 | configuration: ${{ matrix.configuration }} 30 | 31 | pack-and-publish: 32 | needs: [ 'build-and-test' ] 33 | uses: ./.github/workflows/pack-and-publish.yml 34 | secrets: 35 | NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a .NET project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net 3 | 4 | name: test 5 | on: 6 | workflow_dispatch: 7 | push: 8 | branches: [ master ] 9 | paths-ignore: 10 | - "*.md" 11 | - ".github/**" 12 | pull_request: 13 | branches: [ master ] 14 | paths-ignore: 15 | - "*.md" 16 | - ".github/**" 17 | 18 | jobs: 19 | build-and-test: 20 | strategy: 21 | matrix: 22 | dotnet: 23 | - version: '6.0.x' 24 | framework: 'net6.0' 25 | - version: '7.0.x' 26 | framework: 'net7.0' 27 | - version: '8.0.x' 28 | framework: 'net8.0' 29 | - version: '9.0.x' 30 | framework: 'net9.0' 31 | configuration: [ 'Debug', 'Release' ] 32 | uses: ./.github/workflows/build-and-test.yml 33 | with: 34 | dotnet-version: ${{ matrix.dotnet.version }} 35 | dotnet-framework: ${{ matrix.dotnet.framework }} 36 | configuration: ${{ matrix.configuration }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/logs/ 2 | **/bower_components/ 3 | **/wwwroot/lib/ 4 | **/wwwroot/js/*.min.js 5 | **/wwwroot/js/*.min.css 6 | 7 | ## Ignore Visual Studio temporary files, build results, and 8 | ## files generated by popular Visual Studio add-ons. 9 | 10 | # User-specific files 11 | *.suo 12 | *.user 13 | *.userosscache 14 | *.sln.docstates 15 | 16 | # User-specific files (MonoDevelop/Xamarin Studio) 17 | *.userprefs 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | build/ 27 | bld/ 28 | [Bb]in/ 29 | [Oo]bj/ 30 | 31 | # Visual Studio 2015 cache/options directory 32 | .vs/ 33 | 34 | # MSTest test Results 35 | [Tt]est[Rr]esult*/ 36 | [Bb]uild[Ll]og.* 37 | 38 | # NUNIT 39 | *.VisualState.xml 40 | TestResult.xml 41 | 42 | # Build Results of an ATL Project 43 | [Dd]ebugPS/ 44 | [Rr]eleasePS/ 45 | dlldata.c 46 | 47 | # DNX 48 | project.lock.json 49 | artifacts/ 50 | 51 | *_i.c 52 | *_p.c 53 | *_i.h 54 | *.ilk 55 | *.meta 56 | *.obj 57 | *.pch 58 | *.pdb 59 | *.pgc 60 | *.pgd 61 | *.rsp 62 | *.sbr 63 | *.tlb 64 | *.tli 65 | *.tlh 66 | *.tmp 67 | *.tmp_proj 68 | *.log 69 | *.vspscc 70 | *.vssscc 71 | .builds 72 | *.pidb 73 | *.svclog 74 | *.scc 75 | 76 | # Chutzpah Test files 77 | _Chutzpah* 78 | 79 | # Visual C++ cache files 80 | ipch/ 81 | *.aps 82 | *.ncb 83 | *.opendb 84 | *.opensdf 85 | *.sdf 86 | *.cachefile 87 | 88 | # Visual Studio profiler 89 | *.psess 90 | *.vsp 91 | *.vspx 92 | *.sap 93 | 94 | # TFS 2012 Local Workspace 95 | $tf/ 96 | 97 | # Guidance Automation Toolkit 98 | *.gpState 99 | 100 | # ReSharper is a .NET coding add-in 101 | _ReSharper*/ 102 | *.[Rr]e[Ss]harper 103 | *.DotSettings.user 104 | 105 | # JustCode is a .NET coding add-in 106 | .JustCode 107 | 108 | # TeamCity is a build add-in 109 | _TeamCity* 110 | 111 | # DotCover is a Code Coverage Tool 112 | *.dotCover 113 | 114 | # NCrunch 115 | _NCrunch_* 116 | .*crunch*.local.xml 117 | nCrunchTemp_* 118 | 119 | # MightyMoose 120 | *.mm.* 121 | AutoTest.Net/ 122 | 123 | # Web workbench (sass) 124 | .sass-cache/ 125 | 126 | # Installshield output folder 127 | [Ee]xpress/ 128 | 129 | # DocProject is a documentation generator add-in 130 | DocProject/buildhelp/ 131 | DocProject/Help/*.HxT 132 | DocProject/Help/*.HxC 133 | DocProject/Help/*.hhc 134 | DocProject/Help/*.hhk 135 | DocProject/Help/*.hhp 136 | DocProject/Help/Html2 137 | DocProject/Help/html 138 | 139 | # Click-Once directory 140 | publish/ 141 | 142 | # Publish Web Output 143 | *.[Pp]ublish.xml 144 | *.azurePubxml 145 | # TODO: Comment the next line if you want to checkin your web deploy settings 146 | # but database connection strings (with potential passwords) will be unencrypted 147 | *.pubxml 148 | *.publishproj 149 | 150 | # NuGet Packages 151 | *.nupkg 152 | # The packages folder can be ignored because of Package Restore 153 | **/packages/* 154 | # except build/, which is used as an MSBuild target. 155 | !**/packages/build/ 156 | # Uncomment if necessary however generally it will be regenerated when needed 157 | #!**/packages/repositories.config 158 | 159 | # Microsoft Azure Build Output 160 | csx/ 161 | *.build.csdef 162 | 163 | # Microsoft Azure Emulator 164 | ecf/ 165 | rcf/ 166 | 167 | # Microsoft Azure ApplicationInsights config file 168 | ApplicationInsights.config 169 | 170 | # Windows Store app package directory 171 | AppPackages/ 172 | BundleArtifacts/ 173 | 174 | # Visual Studio cache files 175 | # files ending in .cache can be ignored 176 | *.[Cc]ache 177 | # but keep track of directories ending in .cache 178 | !*.[Cc]ache/ 179 | 180 | # Others 181 | ClientBin/ 182 | ~$* 183 | *~ 184 | *.dbmdl 185 | *.dbproj.schemaview 186 | *.pfx 187 | *.publishsettings 188 | node_modules/ 189 | orleans.codegen.cs 190 | 191 | # RIA/Silverlight projects 192 | Generated_Code/ 193 | 194 | # Backup & report files from converting an old project file 195 | # to a newer Visual Studio version. Backup files are not needed, 196 | # because we have git ;-) 197 | _UpgradeReport_Files/ 198 | Backup*/ 199 | UpgradeLog*.XML 200 | UpgradeLog*.htm 201 | 202 | # SQL Server files 203 | *.mdf 204 | *.ldf 205 | 206 | # Business Intelligence projects 207 | *.rdl.data 208 | *.bim.layout 209 | *.bim_*.settings 210 | 211 | # Microsoft Fakes 212 | FakesAssemblies/ 213 | 214 | # GhostDoc plugin setting file 215 | *.GhostDoc.xml 216 | 217 | # Node.js Tools for Visual Studio 218 | .ntvs_analysis.dat 219 | 220 | # Visual Studio 6 build log 221 | *.plg 222 | 223 | # Visual Studio 6 workspace options file 224 | *.opt 225 | 226 | # Visual Studio LightSwitch build output 227 | **/*.HTMLClient/GeneratedArtifacts 228 | **/*.DesktopClient/GeneratedArtifacts 229 | **/*.DesktopClient/ModelManifest.xml 230 | **/*.Server/GeneratedArtifacts 231 | **/*.Server/ModelManifest.xml 232 | _Pvt_Extensions 233 | 234 | # Paket dependency manager 235 | .paket/paket.exe 236 | 237 | # FAKE - F# Make 238 | .fake/ -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 9.0.0 (2024-12-06) 4 | - Added target framework: net9.0 5 | 6 | ## 8.0.0 (2023-11-17) 7 | - Added target framework: net8.0 8 | - Added GitHub actions to test and deploy. 9 | 10 | ## 7.0.11 (2023-09-15) 11 | - Added target framework: net7.0 12 | 13 | ## 6.0.3 14 | - Update to net6.0 15 | 16 | ## 2.1.505 (2019-03-27) 17 | - Update to dotnet-sdk-2.1.505 18 | 19 | ## 2.1.504 (2019-03-09) 20 | - Update to dotnet-sdk-2.1.504 21 | 22 | ## 2.1.412 (2018-10-12) 23 | - Fix [state-128-bytes-limitation-problem](https://github.com/myvas/AspNetCore.Authentication/issues/2) in WeixinAuth 24 | 25 | ## 2.1.408 (2018-10-10) 26 | - Add new 3 unit tests. 27 | 28 | ## 2.1.406 (2018-10-08) 29 | - Update to dotnet-sdk-2.1.403 30 | - Split WeixinOAuth into [WeixinOpen](https://github.com/myvas/AspNetcore.Authentication.WeixnOpen) and [WeixinAuth](https://github.com/myvas/AspNetCore.Authentication.WeixinAuth) 31 | - Add new feature [QQConnect](https://github.com/myvas/AspNetcore.Authentication.QQConnect) 32 | 33 | ## 2.1.301 (2018-06-12) 34 | - Update to dotnet-sdk-2.1.300 35 | 36 | ## 2.0.0-beta-11203 (2017-12-03) 37 | - Use [ViewDivert](https://github.com/myvas/AspNetCore.ViewDivert) in Demo to adapt MicroMessenger browser to its dedicated views 38 | 39 | ## 2.0.0-alpha-71117 (2017-11-19) 40 | - Update to aspnetcore 2.0 41 | 42 | ## 1.1.1-alpha-70325 (2017-03-26) 43 | - Initial release -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 |  2 | 3 | https://github.com/myvas/AspNetCore.Authentication.WeixinAuth 4 | git 5 | latest 6 | Myvas.AspNetCore.Authentication 7 | © $([System.DateTime]::Now.Year) Myvas Foundation 8 | 9 | 10 | 11 | 9.0 12 | v 13 | alpha 14 | false 15 | true 16 | 17 | 18 | 19 | $(MinVerMajor).$(MinVerMinor).$(MinVerPatch).$([System.DateTime]::Now.AddYears(-2021).ToString("yMMdd")) 20 | 21 | 22 | 23 | 24 | $(MSBuildThisFileDirectory) 25 | 26 | 27 | 28 | 29 | none 30 | false 31 | 32 | 33 | 39 | 40 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /Directory.Build.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Myvas Foundation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Myvas.AspNetCore.Authentication Family 2 | 3 | * QQConnect: [Here](https://github.com/myvas/AspNetCore.Authentication.QQConnect) 4 | 5 | [![GitHub (Pre-)Release Date](https://img.shields.io/github/release-date-pre/myvas/AspNetCore.Authentication.QQConnect?label=github)](https://github.com/myvas/AspNetCore.Authentication.QQConnect) 6 | [![test](https://github.com/myvas/AspNetCore.Authentication.QQConnect/actions/workflows/test.yml/badge.svg)](https://github.com/myvas/AspNetCore.Authentication.QQConnect/actions/workflows/test.yml) 7 | [![deploy](https://github.com/myvas/AspNetCore.Authentication.QQConnect/actions/workflows/publish.yml/badge.svg)](https://github.com/myvas/AspNetCore.Authentication.QQConnect/actions/workflows/publish.yml) 8 | [![NuGet](https://img.shields.io/nuget/v/Myvas.AspNetCore.Authentication.QQConnect.svg)](https://www.nuget.org/packages/Myvas.AspNetCore.Authentication.QQConnect) 9 | 10 | * WeixinOpen: [Here](https://github.com/myvas/AspNetCore.Authentication.WeixinOpen) 11 | 12 | [![GitHub (Pre-)Release Date](https://img.shields.io/github/release-date-pre/myvas/AspNetCore.Authentication.WeixinOpen?label=github)](https://github.com/myvas/AspNetCore.Authentication.WeixinOpen) 13 | [![test](https://github.com/myvas/AspNetCore.Authentication.WeixinOpen/actions/workflows/test.yml/badge.svg)](https://github.com/myvas/AspNetCore.Authentication.WeixinOpen/actions/workflows/test.yml) 14 | [![deploy](https://github.com/myvas/AspNetCore.Authentication.WeixinOpen/actions/workflows/publish.yml/badge.svg)](https://github.com/myvas/AspNetCore.Authentication.WeixinOpen/actions/workflows/publish.yml) 15 | [![NuGet](https://img.shields.io/nuget/v/Myvas.AspNetCore.Authentication.WeixinOpen.svg)](https://www.nuget.org/packages/Myvas.AspNetCore.Authentication.WeixinOpen) 16 | 17 | * WeixinAuth: _this repo_ 18 | 19 | [![GitHub (Pre-)Release Date](https://img.shields.io/github/release-date-pre/myvas/AspNetCore.Authentication.WeixinAuth?label=github)](https://github.com/myvas/AspNetCore.Authentication.WeixinAuth) 20 | [![test](https://github.com/myvas/AspNetCore.Authentication.WeixinAuth/actions/workflows/test.yml/badge.svg)](https://github.com/myvas/AspNetCore.Authentication.WeixinAuth/actions/workflows/test.yml) 21 | [![deploy](https://github.com/myvas/AspNetCore.Authentication.WeixinAuth/actions/workflows/publish.yml/badge.svg)](https://github.com/myvas/AspNetCore.Authentication.WeixinAuth/actions/workflows/publish.yml) 22 | [![NuGet](https://img.shields.io/nuget/v/Myvas.AspNetCore.Authentication.WeixinAuth.svg)](https://www.nuget.org/packages/Myvas.AspNetCore.Authentication.WeixinAuth) 23 | 24 | 25 | # What's this? 26 | An ASP.NET Core authentication middleware for https://mp.weixin.qq.com (微信公众平台/网页授权登录) 27 | * 须微信公众平台(mp.weixin.qq.com)已认证的服务号(或测试号)。 28 | * 用户可在微信客户端访问网站时自动登入网站。换而言之,用户在微信客户端中访问网页时,可以通过此组件Challenge获取用户的OpenId或UnionId,据此可以识别用户。 29 | 30 | # How to Use? 31 | ## 0.Create account 32 | (1)在微信公众平台(https://mp.weixin.qq.com)上创建账号。 33 | 34 | 微信公众平台/网页授权获取用户信息,须在微信公众平台(mp.weixin.qq.com)上开通服务号,并认证。 35 | ___注意:订阅号无网页授权权限,即使是已认证的订阅号也不行!___ 36 | 37 | (2)配置功能权限:微信公众平台-已认证服务号/开发/接口权限/... 38 | - 开通功能:网页服务/网页授权获取用户基本信息。 39 | - 设置网页授权域名:例如,auth.myvas.com。 40 | - 将文件MP_verify_xxxxxxxxx.txt上传至`wwwroot`目录下。 41 | 42 | (3)当然,也可以在公众平台测试号上测试:微信公众平台-测试账号/开发/开发者工具/公众平台测试号/... 43 | - 开通功能:网页服务/网页授权获取用户基本信息。 44 | - 设置授权回调页面域名:例如,auth.myvas.com。 45 | 46 | ## 1.nuget 47 | * [Myvas.AspNetCore.Authentication.WeixinAuth](https://www.nuget.org/packages/Myvas.AspNetCore.Authentication.WeixinAuth) 48 | 49 | ## 2.Configure 50 | ```csharp 51 | app.UseAuthentication(); 52 | ``` 53 | 54 | 55 | ## 3.ConfigureServices 56 | ```csharp 57 | services.AddAuthentication() 58 | // using Myvas.AspNetCore.Authentication; 59 | .AddWeixinAuth(options => 60 | { 61 | options.AppId = Configuration["WeixinAuth:AppId"]; 62 | options.AppSecret = Configuration["WeixinAuth:AppSecret"]; 63 | 64 | options.SilentMode = false; // default is true 65 | }; 66 | ``` 67 | 68 | 69 | ``` 70 | 说明: 71 | (1)同一用户在同一微信公众号即使重复多次订阅/退订,其OpenId也不会改变。 72 | (2)同一用户在不同微信公众号中的OpenId是不一样的。 73 | (3)若同时运营了多个微信公众号,可以在微信开放平台上开通开发者账号,并在“管理中心/公众账号”中将这些公众号添加进去,就可以获取到同一用户在这些公众号中保持一致的UnionId。 74 | ``` 75 | 76 | # Dev 77 | * [Visual Studio 2022](https://visualstudio.microsoft.com) 78 | * [.NET 9.0, 8.0, 7.0, 6.0, 5.0, 3.1](https://dotnet.microsoft.com/en-us/download/dotnet) 79 | * [微信开发者工具](https://mp.weixin.qq.com/debug/wxadoc/dev/devtools/download.html) 80 | 81 | # Demo 82 | * [Here](https://demo.auth.myvas.com) 83 | -------------------------------------------------------------------------------- /WeixinAuth.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.1.32319.34 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{672FEA18-F072-4549-9C4C-DBD1F9CDC7BB}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_", "_", "{E9754587-13F5-4E3A-9F3F-71C98EF97990}" 9 | ProjectSection(SolutionItems) = preProject 10 | .gitignore = .gitignore 11 | Changelog.md = Changelog.md 12 | delete_all_bin_and_obj.cmd = delete_all_bin_and_obj.cmd 13 | Directory.Build.props = Directory.Build.props 14 | Directory.Build.targets = Directory.Build.targets 15 | global.json = global.json 16 | LICENSE.txt = LICENSE.txt 17 | README.md = README.md 18 | EndProjectSection 19 | EndProject 20 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{73FCFCF4-3A1C-4D4D-939A-9CABDC2341DC}" 21 | EndProject 22 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WeixinAuth", "src\WeixinAuth\WeixinAuth.csproj", "{009C886C-3B18-44F3-8509-5EAF6731E276}" 23 | EndProject 24 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WeixinAuth.UnitTest", "test\WeixinAuth.UnitTest\WeixinAuth.UnitTest.csproj", "{94ABBE67-3755-4DD1-A25E-2407FB32C60E}" 25 | EndProject 26 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" 27 | ProjectSection(SolutionItems) = preProject 28 | .github\workflows\build-and-test.yml = .github\workflows\build-and-test.yml 29 | .github\dependabot.yml = .github\dependabot.yml 30 | .github\workflows\pack-and-publish.yml = .github\workflows\pack-and-publish.yml 31 | .github\workflows\publish.yml = .github\workflows\publish.yml 32 | .github\workflows\test.yml = .github\workflows\test.yml 33 | EndProjectSection 34 | EndProject 35 | Global 36 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 37 | Debug|Any CPU = Debug|Any CPU 38 | Release|Any CPU = Release|Any CPU 39 | EndGlobalSection 40 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 41 | {009C886C-3B18-44F3-8509-5EAF6731E276}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 42 | {009C886C-3B18-44F3-8509-5EAF6731E276}.Debug|Any CPU.Build.0 = Debug|Any CPU 43 | {009C886C-3B18-44F3-8509-5EAF6731E276}.Release|Any CPU.ActiveCfg = Release|Any CPU 44 | {009C886C-3B18-44F3-8509-5EAF6731E276}.Release|Any CPU.Build.0 = Release|Any CPU 45 | {94ABBE67-3755-4DD1-A25E-2407FB32C60E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 46 | {94ABBE67-3755-4DD1-A25E-2407FB32C60E}.Debug|Any CPU.Build.0 = Debug|Any CPU 47 | {94ABBE67-3755-4DD1-A25E-2407FB32C60E}.Release|Any CPU.ActiveCfg = Release|Any CPU 48 | {94ABBE67-3755-4DD1-A25E-2407FB32C60E}.Release|Any CPU.Build.0 = Release|Any CPU 49 | EndGlobalSection 50 | GlobalSection(SolutionProperties) = preSolution 51 | HideSolutionNode = FALSE 52 | EndGlobalSection 53 | GlobalSection(NestedProjects) = preSolution 54 | {009C886C-3B18-44F3-8509-5EAF6731E276} = {672FEA18-F072-4549-9C4C-DBD1F9CDC7BB} 55 | {94ABBE67-3755-4DD1-A25E-2407FB32C60E} = {73FCFCF4-3A1C-4D4D-939A-9CABDC2341DC} 56 | {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {E9754587-13F5-4E3A-9F3F-71C98EF97990} 57 | EndGlobalSection 58 | GlobalSection(ExtensibilityGlobals) = postSolution 59 | SolutionGuid = {2AEDFD1F-BBE1-4727-9978-2FB04DCE84AF} 60 | EndGlobalSection 61 | EndGlobal 62 | -------------------------------------------------------------------------------- /delete_all_bin_and_obj.cmd: -------------------------------------------------------------------------------- 1 | @REM https://stackoverflow.com/questions/755382/i-want-to-delete-all-bin-and-obj-folders-to-force-all-projects-to-rebuild-everyt 2 | @ECHO ************* 3 | @ECHO ** WARNING! 4 | @ECHO ** This will delete all bin and obj folders! 5 | @ECHO ** Press Ctrl-C to Cancel 6 | @ECHO ************* 7 | @ECHO. 8 | @PAUSE 9 | @ECHO ************* 10 | @ECHO. 11 | 12 | FOR /F "tokens=*" %%G IN ('DIR /B /AD /S bin') DO RMDIR /S /Q "%%G" 13 | FOR /F "tokens=*" %%G IN ('DIR /B /AD /S obj') DO RMDIR /S /Q "%%G" 14 | 15 | @ECHO. 16 | @ECHO ************* 17 | @ECHO ** Completed! All bin and obj folders are deleted. 18 | @ECHO ************* 19 | @ECHO. 20 | @PAUSE -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "9.0.100", 4 | "rollForward": "latestFeature", 5 | "allowPrerelease": false 6 | } 7 | } -------------------------------------------------------------------------------- /src/WeixinAuth/Apis/IWeixinAuthApi.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication.OAuth; 2 | using System.Net.Http; 3 | using System.Text.Json; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace Myvas.AspNetCore.Authentication.WeixinAuth.Internal 8 | { 9 | internal interface IWeixinAuthApi 10 | { 11 | Task GetToken(HttpClient backchannel, string tokenEndpoint, string appId, string appSecret, string code, CancellationToken cancellationToken); 12 | Task GetUserInfo(HttpClient backchannel, string userInformationEndpoint, string accessToken, string openid, CancellationToken cancellationToken, WeixinAuthLanguageCodes languageCode = WeixinAuthLanguageCodes.zh_CN); 13 | } 14 | } -------------------------------------------------------------------------------- /src/WeixinAuth/Apis/WeixinAuthApi.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication.OAuth; 2 | using Microsoft.AspNetCore.WebUtilities; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.Extensions.Options; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Net.Http; 8 | using System.Text; 9 | using System.Text.Json; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | 13 | namespace Myvas.AspNetCore.Authentication.WeixinAuth.Internal 14 | { 15 | internal class WeixinAuthApi : IWeixinAuthApi 16 | { 17 | protected ILogger Logger { get; } 18 | protected IOptionsMonitor OptionsMonitor; 19 | 20 | public WeixinAuthApi(IOptionsMonitor optionsMonitor, ILoggerFactory loggerFactory) 21 | { 22 | Logger = loggerFactory?.CreateLogger() ?? throw new ArgumentNullException(nameof(loggerFactory)); 23 | OptionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); 24 | } 25 | 26 | private static async Task Display(HttpResponseMessage response) 27 | { 28 | var output = new StringBuilder(); 29 | output.Append("Status: " + response.StatusCode + ";"); 30 | output.Append("Headers: " + response.Headers.ToString() + ";"); 31 | output.Append("Body: " + await response.Content.ReadAsStringAsync() + ";"); 32 | return output.ToString(); 33 | } 34 | 35 | /// 36 | /// 通过code换取网页授权access_token。通过code换取的是一个特殊的网页授权access_token,与基础支持中的access_token(该access_token用于调用其他接口)不同。 37 | /// 38 | /// refresh_token拥有较长的有效期(30天),当refresh_token失效的后,需要用户重新授权,所以,请开发者在refresh_token即将过期时(如第29天时),进行定时的自动刷新并保存好它。 39 | /// 尤其注意:由于公众号的secret和获取到的access_token安全级别都非常高,必须只保存在服务器,不允许传给客户端。后续刷新access_token、通过access_token获取用户信息等步骤,也必须从服务器发起。 40 | /// 41 | public async Task GetToken(HttpClient backchannel, string tokenEndpoint, string appId, string appSecret, string code, CancellationToken cancellationToken) 42 | { 43 | var tokenRequestParameters = new Dictionary() 44 | { 45 | ["appid"] = appId, 46 | ["secret"] = appSecret, 47 | ["code"] = code, 48 | ["grant_type"] = "authorization_code" 49 | }; 50 | 51 | var requestUrl = QueryHelpers.AddQueryString(tokenEndpoint, tokenRequestParameters); 52 | 53 | var response = await backchannel.GetAsync(requestUrl, cancellationToken); 54 | if (!response.IsSuccessStatusCode) 55 | { 56 | var error = "OAuth token endpoint failure: " + await Display(response); 57 | Logger.LogError(error); 58 | return OAuthTokenResponse.Failed(new Exception(error)); 59 | } 60 | 61 | var content = await response.Content.ReadAsStringAsync(); 62 | // { 63 | // "access_token":"ACCESS_TOKEN", 64 | // "expires_in":7200, 65 | // "refresh_token":"REFRESH_TOKEN", 66 | // "openid":"OPENID", 67 | // "scope":"SCOPE", 68 | // "unionid": "o6_bmasdasdsad6_2sgVt7hMZOPfL" 69 | //} 70 | var payload = JsonDocument.Parse(content); 71 | int errorCode = WeixinAuthHandlerHelper.GetErrorCode(payload); 72 | if (errorCode != 0) 73 | { 74 | var error = "OAuth token endpoint failure: " + await Display(response); 75 | Logger.LogError(error); 76 | return OAuthTokenResponse.Failed(new Exception(error)); 77 | } 78 | 79 | //payload.Add("token_type", ""); 80 | return OAuthTokenResponse.Success(payload); 81 | } 82 | 83 | 84 | /// 85 | /// 刷新或续期access_token使用。由于access_token有效期(目前为2个小时)较短,当access_token超时后,可以使用refresh_token进行刷新。 86 | /// 87 | /// refresh_token拥有较长的有效期(30天),当refresh_token失效的后,需要用户重新授权,所以,请开发者在refresh_token即将过期时(如第29天时),进行定时的自动刷新并保存好它。 88 | /// 89 | public async Task RefreshToken(HttpClient backchannel, string refreshTokenEndpoint, string appId, string refreshToken, CancellationToken cancellationToken) 90 | { 91 | var tokenRequestParameters = new Dictionary() 92 | { 93 | ["appid"] = appId, 94 | ["grant_type"] = "refresh_token", 95 | ["refresh_token"] = refreshToken 96 | }; 97 | 98 | var requestUrl = QueryHelpers.AddQueryString(refreshTokenEndpoint, tokenRequestParameters); 99 | 100 | var response = await backchannel.GetAsync(requestUrl, cancellationToken); 101 | if (!response.IsSuccessStatusCode) 102 | { 103 | var error = "OAuth refresh token endpoint failure: " + await Display(response); 104 | Logger.LogError(error); 105 | return OAuthTokenResponse.Failed(new Exception(error)); 106 | } 107 | 108 | var content = await response.Content.ReadAsStringAsync(); 109 | //{ 110 | // "access_token":"ACCESS_TOKEN", 111 | // "expires_in":7200, 112 | // "refresh_token":"REFRESH_TOKEN", 113 | // "openid":"OPENID", 114 | // "scope":"SCOPE" 115 | //} 116 | var payload = JsonDocument.Parse(content); 117 | int errorCode = WeixinAuthHandlerHelper.GetErrorCode(payload); 118 | if (errorCode != 0) 119 | { 120 | var error = "OAuth refresh token endpoint failure: " + await Display(response); 121 | Logger.LogError(error); 122 | return OAuthTokenResponse.Failed(new Exception(error)); 123 | } 124 | 125 | return OAuthTokenResponse.Success(payload); 126 | } 127 | 128 | /// 129 | /// 检验授权凭证(access_token)是否有效。 130 | /// 131 | /// 132 | /// 133 | public async Task ValidateToken(HttpClient backchannel, string validateTokenEndpoint, string appId, string accessToken, CancellationToken cancellationToken) 134 | { 135 | var tokenRequestParameters = new Dictionary() 136 | { 137 | ["appid"] = appId, 138 | ["access_token"] = accessToken 139 | }; 140 | 141 | var requestUrl = QueryHelpers.AddQueryString(validateTokenEndpoint, tokenRequestParameters); 142 | 143 | var response = await backchannel.GetAsync(requestUrl, cancellationToken); 144 | if (!response.IsSuccessStatusCode) 145 | { 146 | var error = "OAuth validate token endpoint failure: " + await Display(response); 147 | Logger.LogError(error); 148 | return false; 149 | } 150 | 151 | var content = await response.Content.ReadAsStringAsync(); 152 | var payload = JsonDocument.Parse(content); 153 | try 154 | { 155 | var errcode = payload.RootElement.GetInt32("errcode", 0); 156 | return (errcode == 0); 157 | } 158 | catch { } 159 | return false; 160 | } 161 | 162 | /// 163 | /// 获取用户个人信息(UnionID机制) 164 | /// 165 | /// 166 | /// 167 | public async Task GetUserInfo(HttpClient backchannel, string userInformationEndpoint, string accessToken, string openid, CancellationToken cancellationToken, WeixinAuthLanguageCodes languageCode = WeixinAuthLanguageCodes.zh_CN) 168 | { 169 | var tokenRequestParameters = new Dictionary() 170 | { 171 | ["access_token"] = accessToken, 172 | ["openid"] = openid, 173 | ["lang"] = languageCode.ToString() 174 | }; 175 | 176 | var requestUrl = QueryHelpers.AddQueryString(userInformationEndpoint, tokenRequestParameters); 177 | 178 | var response = await backchannel.GetAsync(requestUrl, cancellationToken); 179 | if (!response.IsSuccessStatusCode) 180 | { 181 | var error = "OAuth userinformation endpoint failure: " + await Display(response); 182 | Logger.LogError(error); 183 | return null; 184 | } 185 | 186 | var content = await response.Content.ReadAsStringAsync(); 187 | //{ 188 | // "openid":"OPENID", 189 | // "nickname":"NICKNAME", 190 | // "sex":1, 191 | // "province":"PROVINCE", 192 | // "city":"CITY", 193 | // "country":"COUNTRY", 194 | // "headimgurl": "http://wx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0", 195 | // "privilege":[ 196 | // "PRIVILEGE1", 197 | // "PRIVILEGE2" 198 | // ], 199 | // "unionid": " o6_bmasdasdsad6_2sgVt7hMZOPfL" 200 | //} 201 | var payload = JsonDocument.Parse(content); 202 | 203 | int errorCode = WeixinAuthHandlerHelper.GetErrorCode(payload); 204 | if (errorCode != 0) 205 | { 206 | var error = "OAuth user information endpoint failure: " + await Display(response); 207 | Logger.LogError(error); 208 | return null; 209 | } 210 | 211 | return payload; 212 | } 213 | } 214 | } -------------------------------------------------------------------------------- /src/WeixinAuth/ClaimActions/ClaimActionCollectionMapExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication.OAuth.Claims; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | 6 | namespace Myvas.AspNetCore.Authentication.WeixinAuth.Internal 7 | { 8 | internal static class ClaimActionCollectionMapExtensions 9 | { 10 | public static void MapJsonKeyArray(this ClaimActionCollection collection, string claimType, string jsonKey) 11 | { 12 | collection.Add(new JsonKeyArrayClaimAction(claimType, null, jsonKey)); 13 | } 14 | 15 | public static void MapJsonKeyArray(this ClaimActionCollection collection, string claimType, string jsonKey, string valueType) 16 | { 17 | collection.Add(new JsonKeyArrayClaimAction(claimType, valueType, jsonKey)); 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/WeixinAuth/ClaimActions/JsonKeyArrayClaimAction.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication.OAuth.Claims; 2 | using System.Security.Claims; 3 | using System.Text.Json; 4 | 5 | namespace Myvas.AspNetCore.Authentication.WeixinAuth.Internal 6 | { 7 | internal class JsonKeyArrayClaimAction : ClaimAction 8 | { 9 | public JsonKeyArrayClaimAction(string claimType, string valueType) 10 | : base(claimType, valueType) 11 | { 12 | JsonKey = claimType.ToLower(); 13 | } 14 | 15 | /// 16 | /// Creates a new JsonKeyArrayClaimAction. 17 | /// 18 | /// The value to use for Claim.Type when creating a Claim. 19 | /// The value to use for Claim.ValueType when creating a Claim. 20 | /// The top level key to look for in the json user data. 21 | public JsonKeyArrayClaimAction(string claimType, string valueType, string jsonKey) : base(claimType, valueType) 22 | { 23 | JsonKey = jsonKey; 24 | } 25 | 26 | /// 27 | /// The top level key to look for in the json user data. 28 | /// 29 | public string JsonKey { get; } 30 | 31 | #region removed from 3.0, JObject replaced by JsonElement 32 | //public override void Run(JObject userData, ClaimsIdentity identity, string issuer) 33 | //{ 34 | // var values = userData?[JsonKey]; 35 | // if (!(values is JArray)) return; 36 | 37 | // foreach (var value in values) 38 | // { 39 | // identity.AddClaim(new Claim(ClaimType, value.ToString(), ValueType, issuer)); 40 | // } 41 | //} 42 | #endregion 43 | 44 | public override void Run(JsonElement userData, ClaimsIdentity identity, string issuer) 45 | { 46 | var isArray = userData.GetArrayLength() > 0; 47 | if (isArray) 48 | { 49 | var arr = userData.GetStringArray(JsonKey); 50 | foreach (var value in arr) 51 | identity.AddClaim(new Claim(ClaimType, value.ToString(), ValueType, issuer)); 52 | } 53 | else 54 | { 55 | var s = userData.GetString(JsonKey); 56 | identity.AddClaim(new Claim(ClaimType, s, ValueType, issuer)); 57 | } 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/WeixinAuth/Extensions/AuthenticationPropertiesExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | using System.Threading.Tasks; 3 | 4 | namespace Myvas.AspNetCore.Authentication.WeixinAuth.Internal 5 | { 6 | internal static class AuthenticationPropertiesExtensions 7 | { 8 | public static string GetCorrelationId(this AuthenticationProperties properties) 9 | { 10 | return WeixinAuthAuthenticationPropertiesHelper.GetCorrelationId(properties); 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/WeixinAuth/Extensions/ClaimsExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Claims; 3 | 4 | namespace Myvas.AspNetCore.Authentication.WeixinAuth.Internal 5 | { 6 | internal static class WeixinAuthClaimsExtensions 7 | { 8 | public static ClaimsIdentity AddOptionalClaim(this ClaimsIdentity identity, 9 | string type, string value, string issuer) 10 | { 11 | if (identity == null) 12 | { 13 | throw new ArgumentNullException(nameof(identity)); 14 | } 15 | 16 | // Don't update the identity if the claim cannot be safely added. 17 | if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(value)) 18 | { 19 | return identity; 20 | } 21 | 22 | identity.AddClaim(new Claim(type, value, ClaimValueTypes.String, issuer ?? ClaimsIdentity.DefaultIssuer)); 23 | return identity; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/WeixinAuth/Extensions/JsonDocumentAuthExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Text; 3 | using System.Text.Json; 4 | 5 | namespace Myvas.AspNetCore.Authentication.WeixinAuth.Internal 6 | { 7 | internal static class JsonDocumentAuthExtensions 8 | { 9 | public static string GetString(this JsonElement element, string key) 10 | { 11 | if (element.TryGetProperty(key, out var property) && property.ValueKind != JsonValueKind.Null) 12 | { 13 | return property.ToString(); 14 | } 15 | 16 | return null; 17 | } 18 | 19 | public static string GetString(this JsonDocument doc, string key) 20 | { 21 | return doc.RootElement.GetString(key); 22 | } 23 | 24 | public static int GetInt32(this JsonElement element, string key, int defaultValue = 0) 25 | { 26 | var s = element.GetString(key); 27 | try { return int.Parse(s); } catch { return defaultValue; } 28 | } 29 | 30 | public static string[] GetStringArray(this JsonElement element, string key) 31 | { 32 | var s = element.GetString(key); 33 | try { return s.Split(',', System.StringSplitOptions.RemoveEmptyEntries); } catch { return null; } 34 | } 35 | 36 | public static JsonDocument AppendElement(this JsonDocument doc, string name, string value) 37 | { 38 | using (var ms = new MemoryStream()) 39 | { 40 | using (var writer = new Utf8JsonWriter(ms)) 41 | { 42 | writer.WriteStartObject(); 43 | 44 | foreach (var existElement in doc.RootElement.EnumerateObject()) 45 | { 46 | existElement.WriteTo(writer); 47 | } 48 | 49 | // Append new element 50 | writer.WritePropertyName(name); 51 | writer.WriteStringValue(value); 52 | 53 | writer.WriteEndObject(); 54 | } 55 | 56 | var resultJson = Encoding.UTF8.GetString(ms.ToArray()); 57 | return JsonDocument.Parse(resultJson); 58 | } 59 | } 60 | public static JsonDocument AppendElement(this JsonDocument doc, JsonElement element) 61 | { 62 | using (var ms = new MemoryStream()) 63 | { 64 | using (var writer = new Utf8JsonWriter(ms)) 65 | { 66 | writer.WriteStartObject(); 67 | 68 | foreach (var existElement in doc.RootElement.EnumerateObject()) 69 | { 70 | existElement.WriteTo(writer); 71 | } 72 | 73 | element.WriteTo(writer); 74 | 75 | writer.WriteEndObject(); 76 | } 77 | 78 | var resultJson = Encoding.UTF8.GetString(ms.ToArray()); 79 | return JsonDocument.Parse(resultJson); 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/WeixinAuth/Extensions/LoggingExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using System; 3 | 4 | namespace Myvas.AspNetCore.Authentication.WeixinAuth.Internal 5 | { 6 | internal static class LoggingExtensions 7 | { 8 | private static Action _handleChallenge; 9 | 10 | static LoggingExtensions() 11 | { 12 | _handleChallenge = LoggerMessage.Define( 13 | eventId: new EventId(1, "HandleChallenge"), 14 | logLevel: LogLevel.Debug, 15 | formatString: "HandleChallenge with Location: {Location}; and Set-Cookie: {Cookie}."); 16 | } 17 | 18 | public static void HandleChallenge(this ILogger logger, string location, string cookie) 19 | => _handleChallenge(logger, location, cookie, null); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/WeixinAuth/Extensions/OAuthTokenResponseExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication.OAuth; 2 | 3 | namespace Myvas.AspNetCore.Authentication.WeixinAuth.Internal 4 | { 5 | internal static class OAuthTokenResponseExtensions 6 | { 7 | public static string GetUnionId(this OAuthTokenResponse response) 8 | { 9 | return response.Response.RootElement.GetString("unionid"); 10 | } 11 | 12 | public static string GetOpenId (this OAuthTokenResponse response) 13 | { 14 | return response.Response.RootElement.GetString("openid"); 15 | } 16 | 17 | public static string GetScope(this OAuthTokenResponse response) 18 | { 19 | return response.Response.RootElement.GetString("scope"); 20 | } 21 | 22 | public static string GetErrorCode(this OAuthTokenResponse response) 23 | { 24 | return response.Response.RootElement.GetString("errcode"); 25 | } 26 | 27 | public static string GetErrorMsg(this OAuthTokenResponse response) 28 | { 29 | return response.Response.RootElement.GetString("errmsg"); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/WeixinAuth/Helpers/CompressionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.IO.Compression; 5 | using System.Linq; 6 | using System.Runtime.Serialization.Formatters.Binary; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using System.Xml.Serialization; 10 | 11 | namespace Myvas.AspNetCore.Authentication.WeixinAuth.Internal 12 | { 13 | /// 14 | /// ref. https://stackoverflow.com/questions/7343465/compression-decompression-string-with-c-sharp 15 | /// 16 | static internal class CompressionExtensions 17 | { 18 | public static async Task> Zip(this object obj) 19 | { 20 | byte[] bytes = obj.Serialize(); 21 | 22 | using (MemoryStream msi = new MemoryStream(bytes)) 23 | using (MemoryStream mso = new MemoryStream()) 24 | { 25 | using (var gs = new GZipStream(mso, CompressionMode.Compress)) 26 | await msi.CopyToAsync(gs); 27 | 28 | return mso.ToArray().AsEnumerable(); 29 | } 30 | } 31 | 32 | public static async Task Unzip(this byte[] bytes) 33 | { 34 | using (MemoryStream msi = new MemoryStream(bytes)) 35 | using (MemoryStream mso = new MemoryStream()) 36 | { 37 | using (var gs = new GZipStream(msi, CompressionMode.Decompress)) 38 | { 39 | //gs.CopyTo(mso); 40 | await gs.CopyToAsync(mso); 41 | } 42 | 43 | return mso.ToArray().Deserialize(); 44 | } 45 | } 46 | } 47 | 48 | internal static class SerializerExtensions 49 | { 50 | /// 51 | /// Writes the given object instance to a binary file. 52 | /// Object type (and all child types) must be decorated with the [Serializable] attribute. 53 | /// To prevent a variable from being serialized, decorate it with the [NonSerialized] attribute; cannot be applied to properties. 54 | /// 55 | /// The type of object being written to the XML file. 56 | /// The file path to write the object instance to. 57 | /// The object instance to write to the XML file. 58 | /// If false the file will be overwritten if it already exists. If true the contents will be appended to the file. 59 | public static byte[] Serialize(this T objectToWrite) 60 | { 61 | using var stream = new MemoryStream(); 62 | var serializer = new XmlSerializer(typeof(T)); 63 | serializer.Serialize(stream, objectToWrite); 64 | return stream.GetBuffer(); 65 | } 66 | 67 | /// 68 | /// Reads an object instance from a binary file. 69 | /// 70 | /// The type of object to read from the XML. 71 | /// The file path to read the object instance from. 72 | /// Returns a new instance of the object read from the binary file. 73 | public static async Task _Deserialize(this byte[] arr) 74 | { 75 | using var stream = new MemoryStream(arr); 76 | var serializer = new XmlSerializer(typeof(T)); 77 | return await Task.FromResult((T)serializer.Deserialize(stream)); 78 | } 79 | 80 | public static async Task Deserialize(this byte[] arr) 81 | { 82 | object obj = await arr._Deserialize(); 83 | return obj; 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/WeixinAuth/Helpers/WeixinAuthAuthenticationPropertiesHelper.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text.RegularExpressions; 6 | 7 | namespace Myvas.AspNetCore.Authentication 8 | { 9 | public static class WeixinAuthAuthenticationPropertiesHelper 10 | { 11 | public const string CorrelationProperty = ".xsrf"; 12 | public const string CorrelationMarker = "N"; 13 | 14 | public static string GetCorrelationId(AuthenticationProperties properties) 15 | { 16 | return properties.Items[CorrelationProperty]; 17 | } 18 | 19 | public static AuthenticationProperties GetByCorrelationId(PropertiesDataFormat stateFormat, IList cookies, string correlationId, string schemeName, string correlationCookieName = ".AspNetCore.Correlation") 20 | { 21 | var state = correlationId; 22 | var fullCookieValue = cookies.FirstOrDefault(x => x.StartsWith($"{correlationCookieName}.{schemeName}.{CorrelationMarker}.{state}")); 23 | var cookieValue = GetCookieValue(fullCookieValue, state); 24 | var stateProperties = stateFormat.Unprotect(cookieValue); 25 | return stateProperties; 26 | } 27 | 28 | public static string GetCookieValue(string fullCookieString, string key) 29 | { 30 | if (!fullCookieString.StartsWith(key)) 31 | { 32 | var trimedFullCookieString = fullCookieString.Substring(key.Length); 33 | 34 | var regexPattern = "=(?[^;]+);"; 35 | var regex = new Regex(regexPattern, RegexOptions.Compiled | RegexOptions.Multiline, TimeSpan.FromSeconds(10)); 36 | var match = regex.Match(trimedFullCookieString); 37 | if (match.Success && match.Groups["Value"].Success) 38 | { 39 | return match.Groups["Value"].Value; 40 | } 41 | } 42 | return ""; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/WeixinAuth/Helpers/WeixinAuthHandlerHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text.Json; 4 | 5 | namespace Myvas.AspNetCore.Authentication.WeixinAuth.Internal 6 | { 7 | internal static class WeixinAuthHandlerHelper 8 | { 9 | #region 错误时微信会返回含有两个字段的JSON数据包 10 | /// 11 | /// errcode 12 | /// 13 | public static int GetErrorCode(JsonDocument payload) 14 | { 15 | if (payload == null) 16 | { 17 | throw new ArgumentNullException(nameof(payload)); 18 | } 19 | return payload.RootElement.GetInt32("errcode", 0); 20 | } 21 | 22 | /// 23 | /// errmsg 24 | /// 25 | public static string GetErrorMessage(JsonDocument payload) 26 | { 27 | if (payload == null) 28 | { 29 | throw new ArgumentNullException(nameof(payload)); 30 | } 31 | return payload.RootElement.GetString("errmsg"); 32 | } 33 | #endregion 34 | 35 | /// 36 | /// openid 37 | /// 38 | public static string GetOpenId(JsonDocument payload) 39 | { 40 | if (payload == null) 41 | { 42 | throw new ArgumentNullException(nameof(payload)); 43 | } 44 | return payload.RootElement.GetString("openid"); 45 | } 46 | 47 | /// 48 | /// nickname 微信用户昵称。 49 | /// 50 | public static string GetNickName(JsonDocument payload) 51 | { 52 | if (payload == null) 53 | { 54 | throw new ArgumentNullException(nameof(payload)); 55 | } 56 | return payload.RootElement.GetString("nickname"); 57 | } 58 | 59 | /// 60 | /// headimgurl 微信头像。 61 | /// 62 | /// 63 | /// 64 | public static string GetHeadImageUrl(JsonDocument payload) 65 | { 66 | if (payload == null) 67 | { 68 | throw new ArgumentNullException(nameof(payload)); 69 | } 70 | return payload.RootElement.GetString("headimgurl"); 71 | } 72 | 73 | /// 74 | /// sex 姓别。 75 | /// 76 | /// 77 | /// 78 | public static string GetGender(JsonDocument payload) 79 | { 80 | if (payload == null) 81 | { 82 | throw new ArgumentNullException(nameof(payload)); 83 | } 84 | return payload.RootElement.GetString("sex"); 85 | } 86 | 87 | /// 88 | /// country 国家。 89 | /// 90 | /// 91 | /// 92 | public static string GetCountry(JsonDocument payload) 93 | { 94 | if (payload == null) 95 | { 96 | throw new ArgumentNullException(nameof(payload)); 97 | } 98 | return payload.RootElement.GetString("country"); 99 | } 100 | 101 | /// 102 | /// province 省份。 103 | /// 104 | /// 105 | /// 106 | public static string GetProvince(JsonDocument payload) 107 | { 108 | if (payload == null) 109 | { 110 | throw new ArgumentNullException(nameof(payload)); 111 | } 112 | return payload.RootElement.GetString("province"); 113 | } 114 | 115 | /// 116 | /// city 城市。 117 | /// 118 | /// 119 | /// 120 | public static string GetCity(JsonDocument payload) 121 | { 122 | if (payload == null) 123 | { 124 | throw new ArgumentNullException(nameof(payload)); 125 | } 126 | return payload.RootElement.GetString("city"); 127 | } 128 | 129 | /// 130 | /// unionid 用户统一标识。针对一个微信开放平台帐号下的应用,同一用户的unionid是唯一的。 131 | /// 132 | /// 133 | /// 134 | public static string GetUnionId(JsonDocument payload) 135 | { 136 | if (payload == null) 137 | { 138 | throw new ArgumentNullException(nameof(payload)); 139 | } 140 | return payload.RootElement.GetString("unionid"); 141 | } 142 | 143 | /// 144 | /// privilege 用户特权信息,json数组,如微信沃卡用户为(chinaunicom)。 145 | /// 146 | /// 147 | /// 148 | public static IEnumerable GetPrivileges(JsonDocument payload) 149 | { 150 | if (payload == null) 151 | { 152 | throw new ArgumentNullException(nameof(payload)); 153 | } 154 | return payload.RootElement.GetStringArray("privilege"); 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/WeixinAuth/Helpers/Zipper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Data; 4 | using System.IO; 5 | using System.IO.Compression; 6 | using System.Text; 7 | 8 | namespace Myvas.AspNetCore.Authentication.WeixinAuth.Internal 9 | { 10 | internal class Zipper 11 | { 12 | public static void CopyTo(Stream src, Stream dest) 13 | { 14 | byte[] bytes = new byte[src.Length]; 15 | 16 | int cnt; 17 | 18 | while ((cnt = src.Read(bytes, 0, bytes.Length)) != 0) 19 | { 20 | dest.Write(bytes, 0, cnt); 21 | } 22 | } 23 | 24 | public static byte[] Zip(string str) 25 | { 26 | var bytes = Encoding.UTF8.GetBytes(str); 27 | 28 | using (var msi = new MemoryStream(bytes)) 29 | using (var mso = new MemoryStream()) 30 | { 31 | using (var gs = new GZipStream(mso, CompressionMode.Compress)) 32 | { 33 | //msi.CopyTo(gs); 34 | CopyTo(msi, gs); 35 | } 36 | 37 | return mso.ToArray(); 38 | } 39 | } 40 | 41 | public static string Unzip(byte[] bytes) 42 | { 43 | using (var msi = new MemoryStream(bytes)) 44 | using (var mso = new MemoryStream()) 45 | { 46 | using (var gs = new GZipStream(msi, CompressionMode.Decompress)) 47 | { 48 | //gs.CopyTo(mso); 49 | CopyTo(gs, mso); 50 | } 51 | 52 | return Encoding.UTF8.GetString(mso.ToArray()); 53 | } 54 | } 55 | 56 | static void Main(string[] args) 57 | { 58 | byte[] r1 = Zip("StringStringStringStringStringStringStringStringStringStringStringStringStringString"); 59 | string r2 = Unzip(r1); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/WeixinAuth/WeixinAuth.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net9.0;net8.0;net7.0;net6.0;net5.0;netcoreapp3.1 4 | enable 5 | 6 | Myvas.AspNetCore.Authentication.WeixinAuth 7 | true 8 | Myvas.AspNetCore.Authentication.WeixinAuth 9 | Myvas,AspNetCore, Authentication, WeixinAuth, Tencent 10 | https://github.com/myvas/AspNetCore.Authentication.WeixinAuth 11 | 12 | An ASP.NET Core authentication middleware: WeixinAuth for https://mp.weixin.qq.com (微信公众平台/网页授权登录) 13 | 14 | 使用说明:须微信公众平台(mp.weixin.qq.com)已认证的服务号(或测试号),用户在微信客户端访问网站时自动登入网站。 15 | 16 | Myvas.AspNetCore.Authentication.WeixinAuth 17 | Myvas.AspNetCore.Authentication 18 | README.md 19 | LICENSE.txt 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/WeixinAuth/WeixinAuthAuthenticationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | using Microsoft.Extensions.DependencyInjection.Extensions; 3 | using Microsoft.Extensions.Options; 4 | using Myvas.AspNetCore.Authentication; 5 | using Myvas.AspNetCore.Authentication.WeixinAuth.Internal; 6 | using System; 7 | 8 | namespace Microsoft.Extensions.DependencyInjection 9 | { 10 | /// 11 | /// 微信公众平台@微信网页授权机制 12 | /// https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421140842 13 | /// 14 | public static class WeixinAuthAuthenticationBuilderExtensions 15 | { 16 | public static AuthenticationBuilder AddWeixinAuth(this AuthenticationBuilder builder) 17 | => builder.AddWeixinAuth(WeixinAuthDefaults.AuthenticationScheme, _ => { }); 18 | 19 | public static AuthenticationBuilder AddWeixinAuth(this AuthenticationBuilder builder, Action setupAction) 20 | => builder.AddWeixinAuth(WeixinAuthDefaults.AuthenticationScheme, setupAction); 21 | 22 | public static AuthenticationBuilder AddWeixinAuth(this AuthenticationBuilder builder, string authenticationScheme, Action setupAction) 23 | => builder.AddWeixinAuth(authenticationScheme, WeixinAuthDefaults.DisplayName, setupAction); 24 | 25 | public static AuthenticationBuilder AddWeixinAuth( 26 | this AuthenticationBuilder builder, 27 | string authenticationScheme, 28 | string displayName, 29 | Action setupAction) 30 | { 31 | if (builder == null) 32 | { 33 | throw new ArgumentNullException(nameof(builder)); 34 | } 35 | 36 | builder.Services.TryAddTransient(); 37 | //return builder.AddOAuth(authenticationScheme, displayName, setupAction); 38 | builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, WeixinAuthPostConfigureOptions>()); 39 | return builder.AddRemoteScheme(authenticationScheme, displayName, setupAction); 40 | 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/WeixinAuth/WeixinAuthClaimTypes.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace Myvas.AspNetCore.Authentication 7 | { 8 | /// 9 | /// Defines constants for the well-known claim types that can be assigned to a subject. 10 | /// This class cannot be inherited. 11 | /// 12 | public static class WeixinAuthClaimTypes 13 | { 14 | #region snsapi_base 15 | /// 16 | /// urn:weixin:openid, should be always equal to ClaimTypes.NameIdentifier 17 | /// 18 | public const string OpenId = "urn:weixin:openid"; 19 | 20 | /// 21 | /// urn:weixin:scope 22 | /// 23 | public const string Scope = "urn:weixin:scope"; 24 | #endregion 25 | #region snsapi_userinfo 26 | /// 27 | /// urn:weixin:nickname, should be always equal to ClaimTypes.Name 28 | /// 29 | public const string NickName = "urn:weixin:nickname"; 30 | 31 | /// 32 | /// urn:weixin:headimgurl 33 | /// 34 | public const string HeadImageUrl = "urn:weixin:headimgurl"; 35 | 36 | /// 37 | /// urn:weixin:sex 38 | /// 39 | public const string Sex = "urn:weixin:sex"; 40 | 41 | /// 42 | /// urn:weixin:country 43 | /// 44 | public const string Country = "urn:weixin:country"; 45 | 46 | /// 47 | /// urn:weixin:province 48 | /// 49 | public const string Province = "urn:weixin:province"; 50 | 51 | /// 52 | /// urn:weixin:city 53 | /// 54 | public const string City = "urn:weixin:city"; 55 | 56 | /// 57 | /// urn:weixin:unionid 58 | /// 59 | public const string UnionId = "urn:weixin:unionid"; 60 | 61 | /// 62 | /// urn:weixin:privilege,可能有多个Claims。 63 | /// 64 | public const string Privilege = "urn:weixin:privilege"; 65 | #endregion 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/WeixinAuth/WeixinAuthDefaults.cs: -------------------------------------------------------------------------------- 1 | namespace Myvas.AspNetCore.Authentication 2 | { 3 | public static class WeixinAuthDefaults 4 | { 5 | /// 6 | /// WeixinAuth 7 | /// 8 | public const string AuthenticationScheme = "WeixinAuth"; 9 | 10 | /// 11 | /// WeixinAuth 12 | /// 13 | public const string DisplayName = "WeixinAuth"; 14 | 15 | /// 16 | /// WeixinAuth 17 | /// 18 | public const string ClaimsIssuer = "WeixinAuth"; 19 | 20 | /// 21 | /// /signin-weixinauth 22 | /// 23 | public const string CallbackPath = "/signin-weixinauth"; 24 | 25 | /// 26 | /// https://open.weixin.qq.com/connect/oauth2/authorize, different from WeixinOpen 27 | /// 28 | public const string AuthorizationEndpoint = "https://open.weixin.qq.com/connect/oauth2/authorize"; 29 | 30 | /// 31 | /// https://api.weixin.qq.com/sns/oauth2/access_token 32 | /// 33 | public const string TokenEndpoint = "https://api.weixin.qq.com/sns/oauth2/access_token"; 34 | 35 | /// 36 | /// https://api.weixin.qq.com/sns/userinfo 37 | /// 38 | public const string UserInformationEndpoint = "https://api.weixin.qq.com/sns/userinfo"; 39 | 40 | /// 41 | /// https://api.weixin.qq.com/sns/oauth2/refresh_token 42 | /// 43 | public const string RefreshTokenEndpoint = "https://api.weixin.qq.com/sns/oauth2/refresh_token"; 44 | 45 | /// 46 | /// https://api.weixin.qq.com/sns/auth 47 | /// 48 | public const string ValidateTokenEndpoint = "https://api.weixin.qq.com/sns/auth"; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/WeixinAuth/WeixinAuthHandler.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | using Microsoft.AspNetCore.Authentication.OAuth; 3 | using Microsoft.AspNetCore.WebUtilities; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Options; 6 | using Microsoft.Extensions.Primitives; 7 | using Microsoft.Net.Http.Headers; 8 | using Myvas.AspNetCore.Authentication.WeixinAuth.Internal; 9 | using System; 10 | using System.Collections.Generic; 11 | using System.Globalization; 12 | using System.Linq; 13 | using System.Net.Http; 14 | using System.Security.Claims; 15 | using System.Security.Cryptography; 16 | using System.Text; 17 | using System.Text.Encodings.Web; 18 | using System.Text.Json; 19 | using System.Threading.Tasks; 20 | using Base64UrlTextEncoder = Microsoft.AspNetCore.Authentication.Base64UrlTextEncoder; 21 | 22 | namespace Myvas.AspNetCore.Authentication 23 | { 24 | internal class WeixinAuthHandler : RemoteAuthenticationHandler 25 | { 26 | protected HttpClient Backchannel => Options.Backchannel; 27 | 28 | /// 29 | /// The handler calls methods on the events which give the application control at certain points where processing is occurring. 30 | /// If it is not provided a default instance is supplied which does nothing when the methods are called. 31 | /// 32 | protected new OAuthEvents Events 33 | { 34 | get { return (OAuthEvents)base.Events; } 35 | set { base.Events = value; } 36 | } 37 | 38 | private readonly IWeixinAuthApi _api; 39 | 40 | //protected const string CorrelationPrefix = ".AspNetCore.Correlation."; 41 | protected const string CorrelationProperty = ".xsrf"; 42 | private const string CorrelationMarker = "N"; 43 | //protected const string AuthSchemeKey = ".AuthScheme"; 44 | 45 | //protected static readonly RandomNumberGenerator CryptoRandom = RandomNumberGenerator.Create(); 46 | #if NET8_0_OR_GREATER 47 | /// 48 | /// Initializes a new instance of . 49 | /// 50 | /// 51 | [Obsolete("ISystemClock is obsolete, use TimeProvider on AuthenticationSchemeOptions instead.")] 52 | public WeixinAuthHandler( 53 | IWeixinAuthApi api, 54 | IOptionsMonitor optionsAccessor, 55 | ILoggerFactory loggerFactory, 56 | UrlEncoder encoder, 57 | ISystemClock clock) 58 | : base(optionsAccessor, loggerFactory, encoder, clock) 59 | { 60 | _api = api ?? throw new ArgumentNullException(nameof(api)); 61 | } 62 | 63 | /// 64 | /// Initializes a new instance of . 65 | /// 66 | /// 67 | public WeixinAuthHandler( 68 | IWeixinAuthApi api, 69 | IOptionsMonitor optionsAccessor, 70 | ILoggerFactory loggerFactory, 71 | UrlEncoder encoder) 72 | : base(optionsAccessor, loggerFactory, encoder) 73 | { 74 | _api = api ?? throw new ArgumentNullException(nameof(api)); 75 | } 76 | #else 77 | public WeixinAuthHandler( 78 | IWeixinAuthApi api, 79 | IOptionsMonitor optionsAccessor, 80 | ILoggerFactory loggerFactory, 81 | UrlEncoder encoder, 82 | ISystemClock clock) 83 | : base(optionsAccessor, loggerFactory, encoder, clock) 84 | { 85 | _api = api ?? throw new ArgumentNullException(nameof(api)); 86 | } 87 | #endif 88 | 89 | protected override async Task HandleChallengeAsync(AuthenticationProperties properties) 90 | { 91 | if (string.IsNullOrEmpty(properties.RedirectUri)) 92 | { 93 | properties.RedirectUri = OriginalPathBase + OriginalPath + Request.QueryString; 94 | } 95 | 96 | // OAuth2 10.12 CSRF 97 | GenerateCorrelationId(properties); 98 | 99 | var authorizationEndpoint = BuildChallengeUrl(properties, BuildRedirectUri(Options.CallbackPath)); 100 | var redirectContext = new RedirectContext( 101 | Context, Scheme, Options, 102 | properties, authorizationEndpoint); 103 | await Events.RedirectToAuthorizationEndpoint(redirectContext); 104 | 105 | var location = Context.Response.Headers[HeaderNames.Location]; 106 | if (location == StringValues.Empty) 107 | { 108 | location = "(not set)"; 109 | } 110 | var cookie = Context.Response.Headers[HeaderNames.SetCookie]; 111 | if (cookie == StringValues.Empty) 112 | { 113 | cookie = "(not set)"; 114 | } 115 | Logger.HandleChallenge(location, cookie); 116 | } 117 | 118 | /// 119 | /// 生成网页授权调用URL,用于获取code。(然后可以用此code换取网页授权access_token) 120 | /// 121 | /// 客户端Challenge时,(1)可以通过properties.Items["scope"]去替换请求参数scope; 122 | /// (2)可以自定义额外关联数据,例如,Identity中使用的LoginProvider和XsrfId等等。 123 | /// (3)Challenge成功后,将跳转到由properties.Items[".redirect"]指定的url。 124 | /// 供Challenge使用的回调地址。由HandleChallengeAsyc在调用本函数时将Options.Callback处理成AbsoluteUri。 125 | /// 126 | protected virtual string BuildChallengeUrl(AuthenticationProperties properties, string redirectUri) 127 | { 128 | //注意:参数只有五个,顺序不能改变!微信对该链接做了正则强匹配校验,如果链接的参数顺序不对,授权页面将无法正常访问!!! 129 | var queryStrings = new Dictionary(StringComparer.OrdinalIgnoreCase); 130 | queryStrings.Add("appid", Options.AppId); 131 | queryStrings.Add("redirect_uri", redirectUri); 132 | queryStrings.Add("response_type", "code"); 133 | 134 | var scope = PickAuthenticationProperty(properties, OAuthChallengeProperties.ScopeKey, FormatScope, Options.Scope); 135 | queryStrings.Add(OAuthChallengeProperties.ScopeKey, scope); 136 | 137 | // 测试表明properties添加returnUrl和scheme后,state为1264字符,此时报错:state参数过长。 138 | // 所以properties只能存放在Cookie中,state作为Cookie值的索引键。 139 | // 腾讯规定state最长128字节,所以properties只能存放在Cookie中,state作为Cookie值的索引键。 140 | // https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421140842 141 | // queryStrings.Add("state", state); 142 | var correlationId = properties.Items[CorrelationProperty]; 143 | queryStrings.Add("state", correlationId); 144 | 145 | var protectedProperties = Options.StateDataFormat.Protect(properties); 146 | // Clean up all the deprecated cookies with pattern: "Options.CorrelationCookie.Name + Scheme.Name + "." + correlationId + "." + CorrelationMarker" 147 | var deprecatedCookieNames = Context.Request.Cookies.Keys.Where(x => x.StartsWith(Options.CorrelationCookie.Name + Scheme.Name + ".")); 148 | deprecatedCookieNames.ToList().ForEach(x => Context.Response.Cookies.Delete(x)); 149 | // Append a response cookie for state/properties 150 | #if NET8_0_OR_GREATER 151 | var cookieOptions = Options.CorrelationCookie.Build(Context, TimeProvider.GetUtcNow()); 152 | #else 153 | var cookieOptions = Options.CorrelationCookie.Build(Context, Clock.UtcNow); 154 | #endif 155 | var protectedPropertiesCookieName = FormatStateCookieName(correlationId); 156 | Context.Response.Cookies.Append(protectedPropertiesCookieName, protectedProperties, cookieOptions); 157 | 158 | var authorizationUrl = QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, queryStrings); 159 | return authorizationUrl + "#wechat_redirect"; 160 | } 161 | 162 | 163 | #region To satisfy too big protected properties, we should store it to cookie '.{CorrelationCookieName}.{SchemeName}.{CorrelationMarker}.{CorrelationId|state}' 164 | protected virtual string FormatCorrelationCookieName(string correlationId) 165 | { 166 | return Options.CorrelationCookie.Name + Scheme.Name + "." + correlationId; 167 | } 168 | protected virtual string FormatStateCookieName(string correlationId) 169 | { 170 | return Options.CorrelationCookie.Name + Scheme.Name + "." + CorrelationMarker + "." + correlationId; 171 | } 172 | #endregion 173 | 174 | /// 175 | protected override void GenerateCorrelationId(AuthenticationProperties properties) 176 | { 177 | //base.GenerateCorrelationId(properties); 178 | 179 | if (properties == null) 180 | { 181 | throw new ArgumentNullException(nameof(properties)); 182 | } 183 | 184 | var bytes = new byte[32]; 185 | RandomNumberGenerator.Fill(bytes); 186 | var correlationId = Base64UrlTextEncoder.Encode(bytes); 187 | 188 | #if NET8_0_OR_GREATER 189 | var cookieOptions = Options.CorrelationCookie.Build(Context, TimeProvider.GetUtcNow()); 190 | #else 191 | var cookieOptions = Options.CorrelationCookie.Build(Context, Clock.UtcNow); 192 | #endif 193 | 194 | properties.Items[CorrelationProperty] = correlationId; 195 | 196 | //var cookieName = Options.CorrelationCookie.Name + correlationId; 197 | var cookieName = FormatCorrelationCookieName(correlationId); 198 | 199 | Response.Cookies.Append(cookieName, CorrelationMarker, cookieOptions); 200 | } 201 | 202 | protected override bool ValidateCorrelationId(AuthenticationProperties properties) 203 | { 204 | //return base.ValidateCorrelationId(properties); 205 | 206 | if (properties == null) 207 | { 208 | throw new ArgumentNullException(nameof(properties)); 209 | } 210 | 211 | if (!properties.Items.TryGetValue(CorrelationProperty, out var correlationId)) 212 | { 213 | Logger.LogWarning($"The CorrectionId not found in '{Options.CorrelationCookie.Name!}'"); 214 | return false; 215 | } 216 | 217 | properties.Items.Remove(CorrelationProperty); 218 | 219 | //var cookieName = Options.CorrelationCookie.Name + correlationId; 220 | var cookieName = FormatCorrelationCookieName(correlationId); 221 | 222 | var correlationCookie = Request.Cookies[cookieName]; 223 | if (string.IsNullOrEmpty(correlationCookie)) 224 | { 225 | Logger.LogWarning($"The CorrectionCookie not found in '{cookieName}'"); 226 | return false; 227 | } 228 | 229 | #if NET8_0_OR_GREATER 230 | var cookieOptions = Options.CorrelationCookie.Build(Context, TimeProvider.GetUtcNow()); 231 | #else 232 | var cookieOptions = Options.CorrelationCookie.Build(Context, Clock.UtcNow); 233 | #endif 234 | 235 | Response.Cookies.Delete(cookieName, cookieOptions); 236 | 237 | if (!string.Equals(correlationCookie, CorrelationMarker, StringComparison.Ordinal)) 238 | { 239 | Logger.LogWarning($"Unexcepted CorrectionCookieValue: '{cookieName}'='{correlationCookie}'"); 240 | return false; 241 | } 242 | 243 | return true; 244 | } 245 | 246 | #region Pick value from AuthenticationProperties 247 | private string PickAuthenticationProperty( 248 | AuthenticationProperties properties, 249 | string name, 250 | Func formatter, 251 | T defaultValue) 252 | { 253 | string value = null; 254 | var parameterValue = properties.GetParameter(name); 255 | if (parameterValue != null) 256 | { 257 | value = formatter(parameterValue); 258 | } 259 | else if (!properties.Items.TryGetValue(name, out value)) 260 | { 261 | value = formatter(defaultValue); 262 | } 263 | 264 | // Remove the parameter from AuthenticationProperties so it won't be serialized into the state 265 | Logger.LogWarning($"BuildChallengeUrl: properties.Items[\"{name}\"] with value \"{value}\" will be Picked!"); 266 | properties.Items.Remove(name); 267 | 268 | return value; 269 | } 270 | 271 | private string PickAuthenticationProperty( 272 | AuthenticationProperties properties, 273 | string name, 274 | string defaultValue = null) 275 | => PickAuthenticationProperty(properties, name, x => x, defaultValue); 276 | #endregion 277 | 278 | protected virtual string FormatScope(IEnumerable scopes) 279 | => string.Join(",", scopes); // // OAuth2 3.3 space separated, but weixin not 280 | 281 | protected virtual List SplitScope(string scope) 282 | { 283 | var result = new List(); 284 | if (string.IsNullOrWhiteSpace(scope)) return result; 285 | return scope.Split(',').ToList(); 286 | } 287 | 288 | protected override async Task HandleRemoteAuthenticateAsync() 289 | { 290 | var query = Request.Query; 291 | 292 | var error = query["error"]; 293 | if (!StringValues.IsNullOrEmpty(error)) 294 | { 295 | var failureMessage = new StringBuilder(); 296 | failureMessage.Append(error); 297 | var errorDescription = query["error_description"]; 298 | if (!StringValues.IsNullOrEmpty(errorDescription)) 299 | { 300 | failureMessage.Append(";Description=").Append(errorDescription); 301 | } 302 | var errorUri = query["error_uri"]; 303 | if (!StringValues.IsNullOrEmpty(errorUri)) 304 | { 305 | failureMessage.Append(";Uri=").Append(errorUri); 306 | } 307 | 308 | return HandleRequestResult.Fail(failureMessage.ToString()); 309 | } 310 | 311 | var state = query["state"]; // ie. correlationId 312 | if (StringValues.IsNullOrEmpty(state)) 313 | { 314 | return HandleRequestResult.Fail("The oauth state was missing."); 315 | } 316 | 317 | var stateCookieName = FormatStateCookieName(state); 318 | var protectedProperties = Request.Cookies[stateCookieName]; 319 | if (string.IsNullOrEmpty(protectedProperties)) 320 | { 321 | Logger.LogError($"The protected properties not found in cookie '{stateCookieName}'"); 322 | return HandleRequestResult.Fail($"The oauth state cookie was missing: Cookie: {stateCookieName}"); 323 | } 324 | else 325 | { 326 | Logger.LogDebug($"The protected properties found in cookie '{stateCookieName}' with value '{protectedProperties}'"); 327 | } 328 | 329 | var properties = Options.StateDataFormat.Unprotect(protectedProperties); 330 | 331 | if (properties == null) 332 | { 333 | return HandleRequestResult.Fail($"The oauth state cookie was invalid: Cookie: {stateCookieName}"); 334 | } 335 | 336 | // OAuth2 10.12 CSRF 337 | if (!ValidateCorrelationId(properties)) 338 | { 339 | return HandleRequestResult.Fail("Correlation failed.", properties); 340 | } 341 | 342 | // Cleanup state & correlation cookie 343 | Response.Cookies.Delete(stateCookieName); 344 | var correlationCookieName = FormatCorrelationCookieName(state); 345 | Response.Cookies.Delete(correlationCookieName); 346 | Logger.LogDebug($"Cookies deleted: '{stateCookieName}' and '{correlationCookieName}'"); 347 | 348 | var code = query["code"]; 349 | 350 | if (StringValues.IsNullOrEmpty(code)) 351 | { 352 | Logger.LogWarning("Code was not found."); 353 | return HandleRequestResult.Fail("Code was not found.", properties); 354 | } 355 | 356 | //var codeExchangeContext = new OAuthCodeExchangeContext(properties, code, BuildRedirectUri(Options.CallbackPath)); 357 | //using var tokens = await ExchangeCodeAsync(codeExchangeContext); 358 | using var tokens = await ExchangeCodeAsync(code, BuildRedirectUri(Options.CallbackPath)); 359 | 360 | if (tokens.Error != null) 361 | { 362 | return HandleRequestResult.Fail(tokens.Error, properties); 363 | } 364 | 365 | if (string.IsNullOrEmpty(tokens.AccessToken)) 366 | { 367 | return HandleRequestResult.Fail("Failed to retrieve access token.", properties); 368 | } 369 | 370 | var identity = new ClaimsIdentity(ClaimsIssuer); 371 | 372 | if (Options.SaveTokens) 373 | { 374 | var authTokens = new List(); 375 | 376 | authTokens.Add(new AuthenticationToken { Name = WeixinAuthenticationTokenNames.access_token, Value = tokens.AccessToken }); 377 | if (!string.IsNullOrEmpty(tokens.RefreshToken)) 378 | { 379 | authTokens.Add(new AuthenticationToken { Name = WeixinAuthenticationTokenNames.refresh_token, Value = tokens.RefreshToken }); 380 | } 381 | if (!string.IsNullOrEmpty(tokens.TokenType)) 382 | { 383 | authTokens.Add(new AuthenticationToken { Name = WeixinAuthenticationTokenNames.token_type, Value = tokens.TokenType }); 384 | } 385 | if (!string.IsNullOrEmpty(tokens.GetOpenId())) 386 | { 387 | authTokens.Add(new AuthenticationToken { Name = WeixinAuthenticationTokenNames.openid, Value = tokens.GetOpenId() }); 388 | } 389 | if (!string.IsNullOrEmpty(tokens.GetUnionId())) 390 | { 391 | authTokens.Add(new AuthenticationToken { Name = WeixinAuthenticationTokenNames.unionid, Value = tokens.GetUnionId() }); 392 | } 393 | if (!string.IsNullOrEmpty(tokens.GetScope())) 394 | { 395 | authTokens.Add(new AuthenticationToken { Name = WeixinAuthenticationTokenNames.scope, Value = tokens.GetScope() }); 396 | } 397 | if (!string.IsNullOrEmpty(tokens.ExpiresIn)) 398 | { 399 | int value; 400 | if (int.TryParse(tokens.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture, out value)) 401 | { 402 | // https://www.w3.org/TR/xmlschema-2/#dateTime 403 | // https://msdn.microsoft.com/en-us/library/az4se3k1(v=vs.110).aspx 404 | #if NET8_0_OR_GREATER 405 | var expiresAt = TimeProvider.GetUtcNow() + TimeSpan.FromSeconds(value); 406 | #else 407 | var expiresAt = Clock.UtcNow + TimeSpan.FromSeconds(value); 408 | #endif 409 | authTokens.Add(new AuthenticationToken 410 | { 411 | Name = WeixinAuthenticationTokenNames.expires_at, 412 | Value = expiresAt.ToString("o", CultureInfo.InvariantCulture) 413 | }); 414 | } 415 | } 416 | 417 | properties.StoreTokens(authTokens); //ExternalLoginInfo.AuthenticationTokens 418 | } 419 | 420 | 421 | var ticket = await CreateTicketAsync(identity, properties, tokens); 422 | if (ticket != null) 423 | { 424 | return HandleRequestResult.Success(ticket); 425 | } 426 | else 427 | { 428 | return HandleRequestResult.Fail("Failed to retrieve user information from remote server.", properties); 429 | } 430 | } 431 | 432 | /// 433 | /// code -> oauth.access_token + openid 434 | /// 435 | /// 用于换取网页授权access_token。此code只能使用一次,5分钟未被使用自动过期。 436 | /// 437 | /// 438 | protected virtual async Task ExchangeCodeAsync(string code, string redirectUri) 439 | { 440 | return await _api.GetToken(Options.Backchannel, Options.TokenEndpoint, Options.AppId, Options.AppSecret, code, Context.RequestAborted); 441 | } 442 | 443 | /// 444 | /// Call the OAuthServer and get a user's information. 445 | /// The context object will have the Identity, AccessToken, and UserInformationEndpoint available. 446 | /// Using this information, we can query the auth server for claims to attach to the identity. 447 | /// A particular OAuthServer's endpoint returns a json object with a roles member and a name member. 448 | /// We call this endpoint with HttpClient, parse the result, and attach the claims to the Identity. 449 | /// 450 | /// 451 | /// 452 | /// 453 | /// 454 | protected virtual async Task CreateTicketAsync( 455 | ClaimsIdentity identity, 456 | AuthenticationProperties properties, 457 | OAuthTokenResponse tokens) 458 | { 459 | if (identity == null) 460 | { 461 | throw new ArgumentNullException(nameof(identity)); 462 | } 463 | if (properties == null) 464 | { 465 | throw new ArgumentNullException(nameof(properties)); 466 | } 467 | if (tokens == null) 468 | { 469 | throw new ArgumentNullException(nameof(tokens)); 470 | } 471 | 472 | var openid = tokens.GetOpenId(); 473 | var unionid = tokens.GetUnionId(); 474 | var scope = tokens.GetScope(); 475 | 476 | var payload = JsonDocument.Parse("{}"); 477 | if (/*WeixinAuthScopes.Contains(Options.Scope, WeixinAuthScopes.Items.snsapi_userinfo) 478 | || */WeixinAuthScopes.Contains(scope, WeixinAuthScopes.snsapi_userinfo)) 479 | { 480 | payload = await _api.GetUserInfo(Options.Backchannel, Options.UserInformationEndpoint, tokens.AccessToken, openid, Context.RequestAborted, WeixinAuthLanguageCodes.zh_CN); 481 | } 482 | 483 | //if (!payload.RootElement.GetString("unionid") ) 484 | //{ 485 | // payload.Add("unionid", unionid); 486 | //} 487 | //if (!payload.ContainsKey("openid") && !string.IsNullOrWhiteSpace(openid)) 488 | //{ 489 | // payload.Add("openid", openid); 490 | //} 491 | //payload.Add("scope", scope); 492 | payload.AppendElement("scope", scope); 493 | 494 | var context = new OAuthCreatingTicketContext(new ClaimsPrincipal(identity), properties, Context, Scheme, Options, Backchannel, tokens, payload.RootElement); 495 | context.RunClaimActions(); 496 | 497 | await Events.CreatingTicket(context); 498 | return new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name); 499 | } 500 | } 501 | } -------------------------------------------------------------------------------- /src/WeixinAuth/WeixinAuthLanguageCodes.cs: -------------------------------------------------------------------------------- 1 | namespace Myvas.AspNetCore.Authentication 2 | { 3 | public enum WeixinAuthLanguageCodes 4 | { 5 | zh_CN, 6 | zh_TW, 7 | en 8 | } 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/WeixinAuth/WeixinAuthOptions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | using Microsoft.AspNetCore.Authentication.OAuth; 3 | using Myvas.AspNetCore.Authentication.WeixinAuth.Internal; 4 | using System; 5 | using System.Security.Claims; 6 | 7 | namespace Myvas.AspNetCore.Authentication 8 | { 9 | /// 10 | /// Configuration options for . 11 | /// 12 | public class WeixinAuthOptions : OAuthOptions 13 | { 14 | /// 15 | /// Gets or sets the provider-assigned client id. 16 | /// 17 | public string AppId { get => ClientId; set => ClientId = value; } 18 | 19 | /// 20 | /// Gets or sets the provider-assigned client secret. 21 | /// 22 | public string AppSecret { get => ClientSecret; set => ClientSecret = value; } 23 | 24 | /// 25 | /// 国家地区语言版本,支持zh_CN 简体(默认),zh_TW 繁体,en 英语等三种。 26 | /// 27 | /// 在拉取用户信息时用到 28 | public string LanguageCode { get; set; } 29 | 30 | public string RefreshTokenEndpoint { get; set; } 31 | public string ValidateTokenEndpoint { get; set; } 32 | 33 | /// 34 | /// 是否采用静默模式。默认为是。使用静默模式时,将仅获取用户的OpenId信息,不会获取微信用户昵称、头像等其他信息。 35 | /// 36 | public bool SilentMode 37 | { 38 | get 39 | { 40 | return WeixinAuthScopes.Contains(Scope, WeixinAuthScopes.snsapi_userinfo); 41 | } 42 | set 43 | { 44 | if (value) 45 | { 46 | Scope.Remove(WeixinAuthScopes.snsapi_userinfo); 47 | if (Scope.Count < 1) 48 | { 49 | Scope.Add(WeixinAuthScopes.snsapi_base); 50 | } 51 | } 52 | else 53 | { 54 | WeixinAuthScopes.TryAdd(Scope, WeixinAuthScopes.snsapi_userinfo); 55 | } 56 | } 57 | } 58 | 59 | public WeixinAuthOptions() 60 | { 61 | CallbackPath = WeixinAuthDefaults.CallbackPath; 62 | AuthorizationEndpoint = WeixinAuthDefaults.AuthorizationEndpoint; 63 | TokenEndpoint = WeixinAuthDefaults.TokenEndpoint; 64 | RefreshTokenEndpoint = WeixinAuthDefaults.RefreshTokenEndpoint; 65 | ValidateTokenEndpoint = WeixinAuthDefaults.ValidateTokenEndpoint; 66 | UserInformationEndpoint = WeixinAuthDefaults.UserInformationEndpoint; 67 | LanguageCode = "zh_CN"; 68 | WeixinAuthScopes.TryAdd(Scope, WeixinAuthScopes.snsapi_base); 69 | SilentMode = true; 70 | SaveTokens = true; 71 | 72 | ClaimsIssuer = WeixinAuthDefaults.ClaimsIssuer; 73 | 74 | ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "openid"); 75 | ClaimActions.MapJsonKey(ClaimTypes.Name, "nickname"); 76 | 77 | ClaimActions.MapJsonKey(WeixinAuthClaimTypes.UnionId, "unionid"); 78 | ClaimActions.MapJsonKey(WeixinAuthClaimTypes.OpenId, "openid"); 79 | ClaimActions.MapJsonKey(WeixinAuthClaimTypes.NickName, "nickname"); 80 | ClaimActions.MapJsonKey(WeixinAuthClaimTypes.Sex, "sex"); 81 | ClaimActions.MapJsonKey(WeixinAuthClaimTypes.Province, "province"); 82 | ClaimActions.MapJsonKey(WeixinAuthClaimTypes.City, "city"); 83 | ClaimActions.MapJsonKey(WeixinAuthClaimTypes.Country, "country"); 84 | ClaimActions.MapJsonKey(WeixinAuthClaimTypes.HeadImageUrl, "headimgurl"); 85 | ClaimActions.MapJsonKey(WeixinAuthClaimTypes.Privilege, "privilege"); 86 | //ClaimActions.MapJsonKeyArray(WeixinAuthClaimTypes.Privilege, "privilege"); 87 | ClaimActions.MapJsonKey(WeixinAuthClaimTypes.Scope, "scope"); 88 | } 89 | 90 | public override void Validate() 91 | { 92 | if (string.IsNullOrEmpty(LanguageCode)) 93 | { 94 | throw new ArgumentException($"{nameof(LanguageCode)} must be provided", nameof(LanguageCode)); 95 | } 96 | 97 | if (string.IsNullOrEmpty(TokenEndpoint)) 98 | { 99 | throw new ArgumentException($"{nameof(RefreshTokenEndpoint)} must be provided", nameof(RefreshTokenEndpoint)); 100 | } 101 | 102 | base.Validate(); 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/WeixinAuth/WeixinAuthPostConfigureOptions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | using Microsoft.AspNetCore.DataProtection; 3 | using Microsoft.Extensions.Options; 4 | using Myvas.AspNetCore.Authentication; 5 | using System.Net.Http; 6 | 7 | namespace Microsoft.Extensions.DependencyInjection 8 | { 9 | public class WeixinAuthPostConfigureOptions : IPostConfigureOptions 10 | { 11 | private readonly IDataProtectionProvider _dp; 12 | 13 | public WeixinAuthPostConfigureOptions(IDataProtectionProvider dataProtection) 14 | { 15 | _dp = dataProtection; 16 | } 17 | 18 | public void PostConfigure(string name, WeixinAuthOptions options) 19 | { 20 | options.DataProtectionProvider = options.DataProtectionProvider ?? _dp; 21 | if (options.Backchannel == null) 22 | { 23 | options.Backchannel = new HttpClient(options.BackchannelHttpHandler ?? new HttpClientHandler()); 24 | options.Backchannel.DefaultRequestHeaders.UserAgent.ParseAdd("Microsoft ASP.NET Core OAuth handler"); 25 | options.Backchannel.Timeout = options.BackchannelTimeout; 26 | options.Backchannel.MaxResponseContentBufferSize = 1024 * 1024 * 10; // 10 MB 27 | } 28 | 29 | if (options.StateDataFormat == null) 30 | { 31 | var dataProtector = options.DataProtectionProvider.CreateProtector( 32 | typeof(WeixinAuthHandler).FullName, name, "v1"); 33 | options.StateDataFormat = new PropertiesDataFormat(dataProtector); 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/WeixinAuth/WeixinAuthScopes.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace Myvas.AspNetCore.Authentication 7 | { 8 | public class WeixinAuthScopes 9 | { 10 | ///// 11 | ///// 此授权用于获取进入页面的用户openid,静默授权并自动跳转到回调页,即使该用户未关注目标微信公众号。 12 | ///// 13 | public const string snsapi_base = "snsapi_base"; 14 | 15 | ///// 16 | ///// 此授权用于网页扫码登录。 17 | ///// 18 | [Obsolete("此授权码不适用于本中间件,请使用.AddWeixinOpen(...)", true)] 19 | public const string snsapi_login = "snsapi_login"; 20 | 21 | ///// 22 | ///// 此授权用于获取微信用户资料,未关注目标微信公众号的用户须手动同意,若已关注则用户亦无感知通过授权。 23 | ///// 24 | public const string snsapi_userinfo = "snsapi_userinfo"; 25 | 26 | #region Helpers 27 | 28 | public static ICollection TryAdd(ICollection currentScopes, params string[] scopes) 29 | { 30 | Array.ForEach(scopes, x => 31 | { 32 | if (!currentScopes.Contains(x)) 33 | { 34 | currentScopes.Add(x); 35 | } 36 | }); 37 | return currentScopes; 38 | } 39 | 40 | //public static ICollection TryAdd(ICollection currentScopes, params Items[] scopes) 41 | //{ 42 | // Array.ForEach(scopes, x => 43 | // { 44 | // var s = x.ToString(); 45 | // if (!currentScopes.Contains(s)) 46 | // { 47 | // currentScopes.Add(s); 48 | // } 49 | // }); 50 | // return currentScopes; 51 | //} 52 | 53 | //public static bool Contains(ICollection currentScopes, Items scope) 54 | //{ 55 | // return Contains(currentScopes, scope.ToString()); 56 | //} 57 | 58 | public static bool Contains(ICollection currentScopes, string scope) 59 | { 60 | return currentScopes.Contains(scope); 61 | } 62 | 63 | /// 64 | /// 65 | /// 66 | /// a string contains multiple scopes, splited by comma 67 | /// 68 | /// 69 | public static bool Contains(string currentScopes, string scope) 70 | { 71 | return Contains(currentScopes.Split(','), scope); 72 | } 73 | 74 | ///// 75 | ///// 76 | ///// 77 | ///// a string contains multiple scopes, splited by comma 78 | ///// 79 | ///// 80 | //public static bool Contains(string currentScopes, Items scope) 81 | //{ 82 | // return Contains(currentScopes.Split(','), scope); 83 | //} 84 | #endregion 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/WeixinAuth/WeixinAuthenticationTokenNames.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace Myvas.AspNetCore.Authentication 7 | { 8 | // 9 | // Summary: 10 | // Defines constants for the well-known claim types that can be assigned to a subject. 11 | // This class cannot be inherited. 12 | public static class WeixinAuthenticationTokenNames 13 | { 14 | /// 15 | /// openid 16 | /// 17 | public const string openid = "weixin_openid"; 18 | 19 | /// 20 | /// unionid 21 | /// 22 | public const string unionid = "unionid"; 23 | 24 | /// 25 | /// scope 26 | /// 27 | public const string scope = "scope"; 28 | 29 | /// 30 | /// access_token 31 | /// 32 | public const string access_token = "access_token"; 33 | 34 | /// 35 | /// refresh_token 36 | /// 37 | public const string refresh_token = "refresh_token"; 38 | 39 | /// 40 | /// token_type 41 | /// 42 | public const string token_type = "token_type"; 43 | 44 | /// 45 | /// expires_at 46 | /// 47 | public const string expires_at = "expires_at"; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/WeixinAuth.UnitTest/TestServers/TestExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) .NET Foundation. All rights reserved. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Diagnostics; 7 | using System.IO; 8 | using System.Linq; 9 | using System.Net.Http; 10 | using System.Security.Claims; 11 | using System.Text; 12 | using System.Threading.Tasks; 13 | using System.Xml; 14 | using System.Xml.Linq; 15 | using Microsoft.AspNetCore.Authentication; 16 | using Microsoft.AspNetCore.Http; 17 | using Microsoft.AspNetCore.TestHost; 18 | 19 | namespace UnitTest 20 | { 21 | public static class TestExtensions 22 | { 23 | public const string CookieAuthenticationScheme = "External"; 24 | 25 | public static async Task SendAsync(this TestServer server, string uri, params string[] cookies) 26 | { 27 | var request = new HttpRequestMessage(HttpMethod.Get, uri); 28 | if (cookies != null && cookies.Count() > 0) 29 | { 30 | request.Headers.Add("Cookie", string.Join("; ", cookies)); 31 | } 32 | 33 | var transaction = new TestTransaction 34 | { 35 | Request = request, 36 | Response = await server.CreateClient().SendAsync(request), 37 | }; 38 | 39 | if (transaction.Response.Headers.Contains("Set-Cookie")) 40 | { 41 | transaction.SetCookie = transaction.Response.Headers.GetValues("Set-Cookie").ToList(); 42 | } 43 | transaction.ResponseText = await transaction.Response.Content.ReadAsStringAsync(); 44 | 45 | if (transaction.Response.Content != null && 46 | transaction.Response.Content.Headers.ContentType != null && 47 | transaction.Response.Content.Headers.ContentType.MediaType == "text/xml") 48 | { 49 | transaction.ResponseElement = XElement.Parse(transaction.ResponseText); 50 | } 51 | return transaction; 52 | } 53 | 54 | public static Task DescribeAsync(this HttpResponse res, ClaimsPrincipal principal) 55 | { 56 | res.StatusCode = 200; 57 | res.ContentType = "text/xml"; 58 | var xml = new XElement("xml"); 59 | if (principal != null) 60 | { 61 | foreach (var identity in principal.Identities) 62 | { 63 | xml.Add(identity.Claims.Select(claim => 64 | new XElement("claim", new XAttribute("type", claim.Type), 65 | new XAttribute("value", claim.Value), 66 | new XAttribute("issuer", claim.Issuer)))); 67 | } 68 | } 69 | var xmlBytes = Encoding.UTF8.GetBytes(xml.ToString()); 70 | return res.Body.WriteAsync(xmlBytes, 0, xmlBytes.Length); 71 | } 72 | 73 | public static Task DescribeAsync(this HttpResponse res, IEnumerable tokens) 74 | { 75 | res.StatusCode = 200; 76 | res.ContentType = "text/xml"; 77 | var xml = new XElement("xml"); 78 | if (tokens != null) 79 | { 80 | foreach (var token in tokens) 81 | { 82 | xml.Add(new XElement("token", new XAttribute("name", token.Name), 83 | new XAttribute("value", token.Value))); 84 | } 85 | } 86 | var xmlBytes = Encoding.UTF8.GetBytes(xml.ToString()); 87 | return res.Body.WriteAsync(xmlBytes, 0, xmlBytes.Length); 88 | } 89 | 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /test/WeixinAuth.UnitTest/TestServers/TestHandlers.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) .NET Foundation. All rights reserved. See License.txt in the project root for license information. 2 | 3 | using System.Security.Claims; 4 | using System.Text.Encodings.Web; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Authentication; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.Extensions.Logging; 9 | using Microsoft.Extensions.Options; 10 | 11 | namespace UnitTest 12 | { 13 | public class TestAuthHandler : AuthenticationHandler, IAuthenticationSignInHandler 14 | { 15 | #if NET8_0_OR_GREATER 16 | public TestAuthHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) 17 | { } 18 | #else 19 | public TestAuthHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) 20 | { } 21 | #endif 22 | 23 | public int SignInCount { get; set; } 24 | public int SignOutCount { get; set; } 25 | public int ForbidCount { get; set; } 26 | public int ChallengeCount { get; set; } 27 | public int AuthenticateCount { get; set; } 28 | 29 | protected override Task HandleChallengeAsync(AuthenticationProperties properties) 30 | { 31 | ChallengeCount++; 32 | return Task.CompletedTask; 33 | } 34 | 35 | protected override Task HandleForbiddenAsync(AuthenticationProperties properties) 36 | { 37 | ForbidCount++; 38 | return Task.CompletedTask; 39 | } 40 | 41 | protected override Task HandleAuthenticateAsync() 42 | { 43 | AuthenticateCount++; 44 | var principal = new ClaimsPrincipal(); 45 | var id = new ClaimsIdentity(); 46 | id.AddClaim(new Claim(ClaimTypes.NameIdentifier, Scheme.Name, ClaimValueTypes.String, Scheme.Name)); 47 | principal.AddIdentity(id); 48 | return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, new AuthenticationProperties(), Scheme.Name))); 49 | } 50 | 51 | public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties properties) 52 | { 53 | SignInCount++; 54 | return Task.CompletedTask; 55 | } 56 | 57 | public Task SignOutAsync(AuthenticationProperties properties) 58 | { 59 | SignOutCount++; 60 | return Task.CompletedTask; 61 | } 62 | } 63 | 64 | public class TestHandler : IAuthenticationSignInHandler 65 | { 66 | public AuthenticationScheme Scheme { get; set; } 67 | public int SignInCount { get; set; } 68 | public int SignOutCount { get; set; } 69 | public int ForbidCount { get; set; } 70 | public int ChallengeCount { get; set; } 71 | public int AuthenticateCount { get; set; } 72 | 73 | public Task AuthenticateAsync() 74 | { 75 | AuthenticateCount++; 76 | var principal = new ClaimsPrincipal(); 77 | var id = new ClaimsIdentity(); 78 | id.AddClaim(new Claim(ClaimTypes.NameIdentifier, Scheme.Name, ClaimValueTypes.String, Scheme.Name)); 79 | principal.AddIdentity(id); 80 | return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, new AuthenticationProperties(), Scheme.Name))); 81 | } 82 | 83 | public Task ChallengeAsync(AuthenticationProperties properties) 84 | { 85 | ChallengeCount++; 86 | return Task.CompletedTask; 87 | } 88 | 89 | public Task ForbidAsync(AuthenticationProperties properties) 90 | { 91 | ForbidCount++; 92 | return Task.CompletedTask; 93 | } 94 | 95 | public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context) 96 | { 97 | Scheme = scheme; 98 | return Task.CompletedTask; 99 | } 100 | 101 | public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties properties) 102 | { 103 | SignInCount++; 104 | return Task.CompletedTask; 105 | } 106 | 107 | public Task SignOutAsync(AuthenticationProperties properties) 108 | { 109 | SignOutCount++; 110 | return Task.CompletedTask; 111 | } 112 | } 113 | 114 | public class TestHandler2 : TestHandler 115 | { 116 | } 117 | 118 | public class TestHandler3 : TestHandler 119 | { 120 | } 121 | } -------------------------------------------------------------------------------- /test/WeixinAuth.UnitTest/TestServers/TestHttpMessageHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) .NET Foundation. All rights reserved. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System; 5 | using System.Net.Http; 6 | using System.Threading.Tasks; 7 | 8 | namespace UnitTest 9 | { 10 | public class TestHttpMessageHandler : HttpMessageHandler 11 | { 12 | public Func Sender { get; set; } 13 | 14 | protected override Task SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) 15 | { 16 | if (Sender != null) 17 | { 18 | return Task.FromResult(Sender(request)); 19 | } 20 | 21 | return Task.FromResult(null); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/WeixinAuth.UnitTest/TestServers/TestServerBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | using Microsoft.AspNetCore.Authentication.Cookies; 3 | using Microsoft.AspNetCore.Builder; 4 | using Microsoft.AspNetCore.Hosting; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.AspNetCore.TestHost; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Myvas.AspNetCore.Authentication; 9 | using System; 10 | using System.Security.Claims; 11 | using System.Threading.Tasks; 12 | 13 | namespace UnitTest.TestServers 14 | { 15 | internal class TestServerBuilder 16 | { 17 | public static readonly string DefaultAuthority = @"https://login.microsoftonline.com/common"; 18 | public static readonly string TestHost = @"https://example.com"; 19 | public static readonly string Challenge = "/challenge"; 20 | public static readonly string ChallengeWithOutContext = "/challengeWithOutContext"; 21 | public static readonly string ChallengeWithProperties = "/challengeWithProperties"; 22 | public static readonly string Signin = "/signin"; 23 | public static readonly string Signout = "/signout"; 24 | 25 | public static WeixinAuthOptions CreateWeixinOpenOptions() => 26 | new WeixinAuthOptions 27 | { 28 | AppId = "Test Id", 29 | AppSecret = "Test Secret" 30 | //o.SignInScheme = "auth1";//WeixinOpenDefaults.AuthenticationScheme 31 | }; 32 | 33 | public static WeixinAuthOptions CreateWeixinOpenOptions(Action update) 34 | { 35 | var options = CreateWeixinOpenOptions(); 36 | update?.Invoke(options); 37 | return options; 38 | } 39 | 40 | public static TestServer CreateServer(Action options) 41 | { 42 | return CreateServer(options, handler: null, properties: null); 43 | } 44 | 45 | public static TestServer CreateServer( 46 | Action options, 47 | Func handler, 48 | AuthenticationProperties properties) 49 | { 50 | var builder = new WebHostBuilder() 51 | .Configure(app => 52 | { 53 | app.UseAuthentication(); 54 | app.Use(async (context, next) => 55 | { 56 | var req = context.Request; 57 | var res = context.Response; 58 | 59 | if (req.Path == new PathString(Challenge)) 60 | { 61 | await context.ChallengeAsync(WeixinAuthDefaults.AuthenticationScheme); 62 | } 63 | else if (req.Path == new PathString(ChallengeWithProperties)) 64 | { 65 | await context.ChallengeAsync(WeixinAuthDefaults.AuthenticationScheme, properties); 66 | } 67 | else if (req.Path == new PathString(ChallengeWithOutContext)) 68 | { 69 | res.StatusCode = 401; 70 | } 71 | else if (req.Path == new PathString(Signin)) 72 | { 73 | await context.SignInAsync(WeixinAuthDefaults.AuthenticationScheme, new ClaimsPrincipal()); 74 | } 75 | else if (req.Path == new PathString(Signout)) 76 | { 77 | await context.SignOutAsync(WeixinAuthDefaults.AuthenticationScheme); 78 | } 79 | else if (req.Path == new PathString("/signout_with_specific_redirect_uri")) 80 | { 81 | await context.SignOutAsync( 82 | WeixinAuthDefaults.AuthenticationScheme, 83 | new AuthenticationProperties() { RedirectUri = "http://www.example.com/specific_redirect_uri" }); 84 | } 85 | else if (handler != null) 86 | { 87 | await handler(context); 88 | } 89 | else 90 | { 91 | await next(); 92 | } 93 | }); 94 | }) 95 | .ConfigureServices(services => 96 | { 97 | services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) 98 | .AddCookie() 99 | .AddWeixinAuth(options); 100 | }); 101 | 102 | return new TestServer(builder); 103 | } 104 | } 105 | 106 | 107 | } 108 | -------------------------------------------------------------------------------- /test/WeixinAuth.UnitTest/TestServers/TestTransaction.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) .NET Foundation. All rights reserved. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Net.Http; 7 | using System.Xml.Linq; 8 | 9 | namespace UnitTest 10 | { 11 | public class TestTransaction 12 | { 13 | public HttpRequestMessage Request { get; set; } 14 | public HttpResponseMessage Response { get; set; } 15 | 16 | public IList SetCookie { get; set; } 17 | 18 | public string ResponseText { get; set; } 19 | public XElement ResponseElement { get; set; } 20 | 21 | public string AuthenticationCookieValue 22 | { 23 | get 24 | { 25 | if (SetCookie != null && SetCookie.Count > 0) 26 | { 27 | var authCookie = SetCookie.SingleOrDefault(c => c.Contains(".AspNetCore." + TestExtensions.CookieAuthenticationScheme + "=")); 28 | if (authCookie != null) 29 | { 30 | return authCookie.Substring(0, authCookie.IndexOf(';')); 31 | } 32 | } 33 | 34 | return null; 35 | } 36 | } 37 | 38 | public string FindClaimValue(string claimType, string issuer = null) 39 | { 40 | var claim = ResponseElement.Elements("claim") 41 | .SingleOrDefault(elt => elt.Attribute("type").Value == claimType && 42 | (issuer == null || elt.Attribute("issuer").Value == issuer)); 43 | if (claim == null) 44 | { 45 | return null; 46 | } 47 | return claim.Attribute("value").Value; 48 | } 49 | 50 | public string FindTokenValue(string name) 51 | { 52 | var claim = ResponseElement.Elements("token") 53 | .SingleOrDefault(elt => elt.Attribute("name").Value == name); 54 | if (claim == null) 55 | { 56 | return null; 57 | } 58 | return claim.Attribute("value").Value; 59 | } 60 | 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /test/WeixinAuth.UnitTest/WeixinAuth.UnitTest.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0;net8.0;net7.0;net6.0 5 | false 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | all 14 | runtime; build; native; contentfiles; analyzers; buildtransitive 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /test/WeixinAuth.UnitTest/WeixinAuthTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | using Microsoft.AspNetCore.Authentication.OAuth; 3 | using Microsoft.AspNetCore.Authentication.Twitter; 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.DataProtection; 6 | using Microsoft.AspNetCore.Hosting; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.AspNetCore.Http.Extensions; 9 | using Microsoft.AspNetCore.TestHost; 10 | using Microsoft.AspNetCore.WebUtilities; 11 | using Microsoft.Extensions.DependencyInjection; 12 | using Microsoft.Extensions.Logging.Abstractions; 13 | using Myvas.AspNetCore.Authentication; 14 | using System; 15 | using System.Collections.Generic; 16 | using System.Linq; 17 | using System.Net; 18 | using System.Net.Http; 19 | using System.Security.Claims; 20 | using System.Text; 21 | using System.Text.Encodings.Web; 22 | using System.Text.Json; 23 | using System.Threading.Tasks; 24 | using Xunit; 25 | 26 | namespace UnitTest 27 | { 28 | public class WeixinAuthTests 29 | { 30 | string correlationKey = ".xsrf"; 31 | string correlationId = "TestCorrelationId"; 32 | string correlationMarker = "N"; 33 | 34 | private void ConfigureDefaults(WeixinAuthOptions o) 35 | { 36 | o.AppId = "Test Id"; 37 | o.AppSecret = "Test Secret"; 38 | //o.SignInScheme = "auth1";//WeixinAuthDefaults.AuthenticationScheme; 39 | } 40 | 41 | [Fact] 42 | public async Task CodeMockValid() 43 | { 44 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 45 | var server = CreateServer(o => 46 | { 47 | ConfigureDefaults(o); 48 | o.StateDataFormat = stateFormat; 49 | o.BackchannelHttpHandler = CreateBackchannel(); 50 | o.Events = new OAuthEvents() 51 | { 52 | OnCreatingTicket = context => 53 | { 54 | Assert.True(context.User.ToString().Length > 0); 55 | Assert.Equal("Test Access Token", context.AccessToken); 56 | Assert.Equal("Test Refresh Token", context.RefreshToken); 57 | Assert.Equal(TimeSpan.FromSeconds(3600), context.ExpiresIn); 58 | Assert.Equal("Test Open ID", context.Identity.FindFirst(ClaimTypes.NameIdentifier)?.Value); 59 | Assert.Equal("Test Name", context.Identity.FindFirst(ClaimTypes.Name)?.Value); 60 | Assert.Equal("Test Open ID", context.Identity.FindFirst(WeixinAuthClaimTypes.OpenId)?.Value); 61 | Assert.Equal("Test Union ID", context.Identity.FindFirst(WeixinAuthClaimTypes.UnionId)?.Value); 62 | return Task.FromResult(0); 63 | } 64 | }; 65 | }); 66 | 67 | // Skip the challenge step, go directly to the callback path 68 | 69 | var properties = new AuthenticationProperties(); 70 | properties.Items.Add(correlationKey, correlationId); 71 | properties.RedirectUri = "/Account/ExternalLoginCallback?returnUrl=%2FHome%2FUserInfo"; 72 | var state = stateFormat.Protect(properties); 73 | 74 | var transaction = await server.SendAsync( 75 | $"https://example.com/signin-{WeixinAuthDefaults.AuthenticationScheme}?code=TestCode&state={UrlEncoder.Default.Encode(correlationId)}", 76 | $".AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationId}={correlationMarker}" 77 | + $";.AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationMarker}.{correlationId}={state}"); 78 | 79 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 80 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 81 | Assert.Equal("/Account/ExternalLoginCallback?returnUrl=%2FHome%2FUserInfo", transaction.Response.Headers.GetValues("Location").First()); 82 | } 83 | 84 | [Fact] 85 | public async Task CanForwardDefault() 86 | { 87 | var services = new ServiceCollection().AddLogging(); 88 | 89 | services.AddAuthentication(o => 90 | { 91 | o.DefaultScheme = WeixinAuthDefaults.AuthenticationScheme; 92 | o.AddScheme("auth1", "auth1"); 93 | }) 94 | .AddWeixinAuth(o => 95 | { 96 | ConfigureDefaults(o); 97 | o.SignInScheme = "auth1"; 98 | o.ForwardDefault = "auth1"; 99 | }); 100 | 101 | var forwardDefault = new TestHandler(); 102 | services.AddSingleton(forwardDefault); 103 | 104 | var sp = services.BuildServiceProvider(); 105 | var context = new DefaultHttpContext(); 106 | context.RequestServices = sp; 107 | 108 | Assert.Equal(0, forwardDefault.AuthenticateCount); 109 | Assert.Equal(0, forwardDefault.ForbidCount); 110 | Assert.Equal(0, forwardDefault.ChallengeCount); 111 | Assert.Equal(0, forwardDefault.SignInCount); 112 | Assert.Equal(0, forwardDefault.SignOutCount); 113 | 114 | await context.AuthenticateAsync(); 115 | Assert.Equal(1, forwardDefault.AuthenticateCount); 116 | 117 | await context.ForbidAsync(); 118 | Assert.Equal(1, forwardDefault.ForbidCount); 119 | 120 | await context.ChallengeAsync(); 121 | Assert.Equal(1, forwardDefault.ChallengeCount); 122 | 123 | await Assert.ThrowsAsync(() => context.SignOutAsync()); 124 | await Assert.ThrowsAsync(() => context.SignInAsync(new ClaimsPrincipal())); 125 | } 126 | 127 | 128 | [Fact] 129 | public async Task ForwardSignInThrows() 130 | { 131 | var services = new ServiceCollection().AddLogging(); 132 | 133 | services.AddAuthentication(o => 134 | { 135 | o.DefaultScheme = WeixinAuthDefaults.AuthenticationScheme; 136 | o.AddScheme("auth1", "auth1"); 137 | o.AddScheme("specific", "specific"); 138 | }) 139 | .AddWeixinAuth(o => 140 | { 141 | ConfigureDefaults(o); 142 | o.ForwardDefault = "auth1"; 143 | o.ForwardSignOut = "specific"; 144 | }); 145 | 146 | var specific = new TestHandler(); 147 | services.AddSingleton(specific); 148 | var forwardDefault = new TestHandler2(); 149 | services.AddSingleton(forwardDefault); 150 | 151 | var sp = services.BuildServiceProvider(); 152 | var context = new DefaultHttpContext(); 153 | context.RequestServices = sp; 154 | 155 | await Assert.ThrowsAsync(() => context.SignInAsync(new ClaimsPrincipal())); 156 | } 157 | 158 | 159 | [Fact] 160 | public async Task ForwardSignOutThrows() 161 | { 162 | var services = new ServiceCollection().AddLogging(); 163 | 164 | services.AddAuthentication(o => 165 | { 166 | o.DefaultScheme = WeixinAuthDefaults.AuthenticationScheme; 167 | o.AddScheme("auth1", "auth1"); 168 | o.AddScheme("specific", "specific"); 169 | }) 170 | .AddWeixinAuth(o => 171 | { 172 | ConfigureDefaults(o); 173 | o.ForwardDefault = "auth1"; 174 | o.ForwardSignOut = "specific"; 175 | }); 176 | 177 | var specific = new TestHandler(); 178 | services.AddSingleton(specific); 179 | var forwardDefault = new TestHandler2(); 180 | services.AddSingleton(forwardDefault); 181 | 182 | var sp = services.BuildServiceProvider(); 183 | var context = new DefaultHttpContext(); 184 | context.RequestServices = sp; 185 | 186 | await Assert.ThrowsAsync(() => context.SignOutAsync()); 187 | } 188 | 189 | 190 | [Fact] 191 | public async Task ForwardForbidWinsOverDefault() 192 | { 193 | var services = new ServiceCollection().AddLogging(); 194 | 195 | services.AddAuthentication(o => 196 | { 197 | o.DefaultScheme = WeixinAuthDefaults.AuthenticationScheme; 198 | o.AddScheme("auth1", "auth1"); 199 | o.AddScheme("specific", "specific"); 200 | }) 201 | .AddWeixinAuth(o => 202 | { 203 | ConfigureDefaults(o); 204 | o.SignInScheme = "auth1"; //Important! 205 | o.ForwardDefault = "auth1"; 206 | o.ForwardForbid = "specific"; 207 | }); 208 | 209 | var specific = new TestHandler(); 210 | services.AddSingleton(specific); 211 | var forwardDefault = new TestHandler2(); 212 | services.AddSingleton(forwardDefault); 213 | 214 | var sp = services.BuildServiceProvider(); 215 | var context = new DefaultHttpContext(); 216 | context.RequestServices = sp; 217 | 218 | await context.ForbidAsync(); 219 | Assert.Equal(0, specific.SignOutCount); 220 | Assert.Equal(0, specific.AuthenticateCount); 221 | Assert.Equal(1, specific.ForbidCount); 222 | Assert.Equal(0, specific.ChallengeCount); 223 | Assert.Equal(0, specific.SignInCount); 224 | 225 | Assert.Equal(0, forwardDefault.AuthenticateCount); 226 | Assert.Equal(0, forwardDefault.ForbidCount); 227 | Assert.Equal(0, forwardDefault.ChallengeCount); 228 | Assert.Equal(0, forwardDefault.SignInCount); 229 | Assert.Equal(0, forwardDefault.SignOutCount); 230 | } 231 | 232 | 233 | [Fact] 234 | public async Task ForwardAuthenticateWinsOverDefault() 235 | { 236 | var services = new ServiceCollection().AddLogging(); 237 | 238 | services.AddAuthentication(o => 239 | { 240 | o.DefaultScheme = WeixinAuthDefaults.AuthenticationScheme; 241 | o.AddScheme("auth1", "auth1"); 242 | o.AddScheme("specific", "specific"); 243 | }) 244 | .AddWeixinAuth(o => 245 | { 246 | ConfigureDefaults(o); 247 | o.SignInScheme = "auth1"; //Important! 248 | o.ForwardDefault = "auth1"; 249 | o.ForwardAuthenticate = "specific"; 250 | }); 251 | 252 | var specific = new TestHandler(); 253 | services.AddSingleton(specific); 254 | var forwardDefault = new TestHandler2(); 255 | services.AddSingleton(forwardDefault); 256 | 257 | var sp = services.BuildServiceProvider(); 258 | var context = new DefaultHttpContext(); 259 | context.RequestServices = sp; 260 | 261 | await context.AuthenticateAsync(); 262 | Assert.Equal(0, specific.SignOutCount); 263 | Assert.Equal(1, specific.AuthenticateCount); 264 | Assert.Equal(0, specific.ForbidCount); 265 | Assert.Equal(0, specific.ChallengeCount); 266 | Assert.Equal(0, specific.SignInCount); 267 | 268 | Assert.Equal(0, forwardDefault.AuthenticateCount); 269 | Assert.Equal(0, forwardDefault.ForbidCount); 270 | Assert.Equal(0, forwardDefault.ChallengeCount); 271 | Assert.Equal(0, forwardDefault.SignInCount); 272 | Assert.Equal(0, forwardDefault.SignOutCount); 273 | } 274 | 275 | [Fact] 276 | public async Task ForwardChallengeWinsOverDefault() 277 | { 278 | var services = new ServiceCollection().AddLogging(); 279 | services.AddAuthentication(o => 280 | { 281 | o.DefaultScheme = WeixinAuthDefaults.AuthenticationScheme; 282 | o.AddScheme("specific", "specific"); 283 | o.AddScheme("auth1", "auth1"); 284 | }) 285 | .AddWeixinAuth(o => 286 | { 287 | ConfigureDefaults(o); 288 | o.SignInScheme = "auth1"; //Important! 289 | o.ForwardDefault = "auth1"; 290 | o.ForwardChallenge = "specific"; 291 | }); 292 | 293 | var specific = new TestHandler(); 294 | services.AddSingleton(specific); 295 | var forwardDefault = new TestHandler2(); 296 | services.AddSingleton(forwardDefault); 297 | 298 | var sp = services.BuildServiceProvider(); 299 | var context = new DefaultHttpContext(); 300 | context.RequestServices = sp; 301 | 302 | await context.ChallengeAsync(); 303 | Assert.Equal(0, specific.SignOutCount); 304 | Assert.Equal(0, specific.AuthenticateCount); 305 | Assert.Equal(0, specific.ForbidCount); 306 | Assert.Equal(1, specific.ChallengeCount); 307 | Assert.Equal(0, specific.SignInCount); 308 | 309 | Assert.Equal(0, forwardDefault.AuthenticateCount); 310 | Assert.Equal(0, forwardDefault.ForbidCount); 311 | Assert.Equal(0, forwardDefault.ChallengeCount); 312 | Assert.Equal(0, forwardDefault.SignInCount); 313 | Assert.Equal(0, forwardDefault.SignOutCount); 314 | } 315 | 316 | [Fact] 317 | public async Task ForwardSelectorWinsOverDefault() 318 | { 319 | var services = new ServiceCollection().AddLogging(); 320 | services.AddAuthentication(o => 321 | { 322 | o.DefaultScheme = WeixinAuthDefaults.AuthenticationScheme; 323 | o.AddScheme("auth1", "auth1"); 324 | o.AddScheme("selector", "selector"); 325 | o.AddScheme("specific", "specific"); 326 | }) 327 | .AddWeixinAuth(o => 328 | { 329 | ConfigureDefaults(o); 330 | o.SignInScheme = "auth1"; //Important! 331 | o.ForwardDefault = "auth1"; 332 | o.ForwardDefaultSelector = _ => "selector"; 333 | }); 334 | 335 | var specific = new TestHandler(); 336 | services.AddSingleton(specific); 337 | var forwardDefault = new TestHandler2(); 338 | services.AddSingleton(forwardDefault); 339 | var selector = new TestHandler3(); 340 | services.AddSingleton(selector); 341 | 342 | var sp = services.BuildServiceProvider(); 343 | var context = new DefaultHttpContext(); 344 | context.RequestServices = sp; 345 | 346 | await context.AuthenticateAsync(); 347 | Assert.Equal(1, selector.AuthenticateCount); 348 | 349 | await context.ForbidAsync(); 350 | Assert.Equal(1, selector.ForbidCount); 351 | 352 | await context.ChallengeAsync(); 353 | Assert.Equal(1, selector.ChallengeCount); 354 | 355 | await Assert.ThrowsAsync(() => context.SignOutAsync()); 356 | await Assert.ThrowsAsync(() => context.SignInAsync(new ClaimsPrincipal())); 357 | 358 | Assert.Equal(0, forwardDefault.AuthenticateCount); 359 | Assert.Equal(0, forwardDefault.ForbidCount); 360 | Assert.Equal(0, forwardDefault.ChallengeCount); 361 | Assert.Equal(0, forwardDefault.SignInCount); 362 | Assert.Equal(0, forwardDefault.SignOutCount); 363 | Assert.Equal(0, specific.AuthenticateCount); 364 | Assert.Equal(0, specific.ForbidCount); 365 | Assert.Equal(0, specific.ChallengeCount); 366 | Assert.Equal(0, specific.SignInCount); 367 | Assert.Equal(0, specific.SignOutCount); 368 | } 369 | 370 | [Fact] 371 | public async Task NullForwardSelectorUsesDefault() 372 | { 373 | var services = new ServiceCollection().AddLogging(); 374 | services.AddAuthentication(o => 375 | { 376 | o.DefaultScheme = WeixinAuthDefaults.AuthenticationScheme; 377 | o.AddScheme("auth1", "auth1"); 378 | o.AddScheme("selector", "selector"); 379 | o.AddScheme("specific", "specific"); 380 | }) 381 | .AddWeixinAuth(o => 382 | { 383 | ConfigureDefaults(o); 384 | o.SignInScheme = "auth1"; //Important! 385 | o.ForwardDefault = "auth1"; 386 | o.ForwardDefaultSelector = _ => null; 387 | }); 388 | 389 | var specific = new TestHandler(); 390 | services.AddSingleton(specific); 391 | var forwardDefault = new TestHandler2(); 392 | services.AddSingleton(forwardDefault); 393 | var selector = new TestHandler3(); 394 | services.AddSingleton(selector); 395 | 396 | var sp = services.BuildServiceProvider(); 397 | var context = new DefaultHttpContext(); 398 | context.RequestServices = sp; 399 | 400 | await context.AuthenticateAsync(); 401 | Assert.Equal(1, forwardDefault.AuthenticateCount); 402 | 403 | await context.ForbidAsync(); 404 | Assert.Equal(1, forwardDefault.ForbidCount); 405 | 406 | await context.ChallengeAsync(); 407 | Assert.Equal(1, forwardDefault.ChallengeCount); 408 | 409 | await Assert.ThrowsAsync(() => context.SignOutAsync()); 410 | await Assert.ThrowsAsync(() => context.SignInAsync(new ClaimsPrincipal())); 411 | 412 | Assert.Equal(0, selector.AuthenticateCount); 413 | Assert.Equal(0, selector.ForbidCount); 414 | Assert.Equal(0, selector.ChallengeCount); 415 | Assert.Equal(0, selector.SignInCount); 416 | Assert.Equal(0, selector.SignOutCount); 417 | Assert.Equal(0, specific.AuthenticateCount); 418 | Assert.Equal(0, specific.ForbidCount); 419 | Assert.Equal(0, specific.ChallengeCount); 420 | Assert.Equal(0, specific.SignInCount); 421 | Assert.Equal(0, specific.SignOutCount); 422 | } 423 | 424 | [Fact] 425 | public async Task SpecificForwardWinsOverSelectorAndDefault() 426 | { 427 | var services = new ServiceCollection().AddLogging(); 428 | services.AddAuthentication(o => 429 | { 430 | o.DefaultScheme = WeixinAuthDefaults.AuthenticationScheme; 431 | o.AddScheme("auth1", "auth1"); 432 | o.AddScheme("selector", "selector"); 433 | o.AddScheme("specific", "specific"); 434 | }) 435 | .AddWeixinAuth(o => 436 | { 437 | ConfigureDefaults(o); 438 | o.SignInScheme = "auth1"; //Important! 439 | o.ForwardDefault = "auth1"; 440 | o.ForwardDefaultSelector = _ => "selector"; 441 | o.ForwardAuthenticate = "specific"; 442 | o.ForwardChallenge = "specific"; 443 | o.ForwardSignIn = "specific"; 444 | o.ForwardSignOut = "specific"; 445 | o.ForwardForbid = "specific"; 446 | }); 447 | 448 | var specific = new TestHandler(); 449 | services.AddSingleton(specific); 450 | var forwardDefault = new TestHandler2(); 451 | services.AddSingleton(forwardDefault); 452 | var selector = new TestHandler3(); 453 | services.AddSingleton(selector); 454 | 455 | var sp = services.BuildServiceProvider(); 456 | var context = new DefaultHttpContext(); 457 | context.RequestServices = sp; 458 | 459 | await context.AuthenticateAsync(); 460 | Assert.Equal(1, specific.AuthenticateCount); 461 | 462 | await context.ForbidAsync(); 463 | Assert.Equal(1, specific.ForbidCount); 464 | 465 | await context.ChallengeAsync(); 466 | Assert.Equal(1, specific.ChallengeCount); 467 | 468 | await Assert.ThrowsAsync(() => context.SignOutAsync()); 469 | await Assert.ThrowsAsync(() => context.SignInAsync(new ClaimsPrincipal())); 470 | 471 | Assert.Equal(0, forwardDefault.AuthenticateCount); 472 | Assert.Equal(0, forwardDefault.ForbidCount); 473 | Assert.Equal(0, forwardDefault.ChallengeCount); 474 | Assert.Equal(0, forwardDefault.SignInCount); 475 | Assert.Equal(0, forwardDefault.SignOutCount); 476 | Assert.Equal(0, selector.AuthenticateCount); 477 | Assert.Equal(0, selector.ForbidCount); 478 | Assert.Equal(0, selector.ChallengeCount); 479 | Assert.Equal(0, selector.SignInCount); 480 | Assert.Equal(0, selector.SignOutCount); 481 | } 482 | 483 | [Fact] 484 | public async Task VerifySignInSchemeCannotBeSetToSelf() 485 | { 486 | var server = CreateServer(o => 487 | { 488 | ConfigureDefaults(o); 489 | o.SignInScheme = WeixinAuthDefaults.AuthenticationScheme; 490 | }); 491 | var error = await Assert.ThrowsAsync(() => server.SendAsync("https://example.com/challenge")); 492 | Assert.Contains("cannot be set to itself", error.Message); 493 | } 494 | 495 | [Fact] 496 | public async Task VerifySchemeDefaults() 497 | { 498 | var services = new ServiceCollection(); 499 | services.AddAuthentication().AddWeixinAuth(); 500 | var sp = services.BuildServiceProvider(); 501 | var schemeProvider = sp.GetRequiredService(); 502 | var scheme = await schemeProvider.GetSchemeAsync(WeixinAuthDefaults.AuthenticationScheme); 503 | Assert.NotNull(scheme); 504 | Assert.Equal("WeixinAuthHandler", scheme.HandlerType.Name); 505 | Assert.Equal(WeixinAuthDefaults.AuthenticationScheme, scheme.DisplayName); 506 | } 507 | 508 | [Fact] 509 | public async Task ChallengeWillTriggerRedirection() 510 | { 511 | var server = CreateServer(o => 512 | { 513 | ConfigureDefaults(o); 514 | }); 515 | 516 | var transaction = await server.SendAsync("https://example.com/challenge"); 517 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 518 | var location = transaction.Response.Headers.Location.ToString(); 519 | Assert.StartsWith(WeixinAuthDefaults.AuthorizationEndpoint, location); 520 | Assert.Contains("&redirect_uri=", location); 521 | Assert.Contains("&response_type=code", location); 522 | Assert.Contains("&scope=", location); 523 | Assert.Contains("&state=", location); 524 | Assert.Contains("#wechat_redirect", location); 525 | 526 | Assert.DoesNotContain("access_type=", location); 527 | Assert.DoesNotContain("prompt=", location); 528 | Assert.DoesNotContain("approval_prompt=", location); 529 | Assert.DoesNotContain("login_hint=", location); 530 | Assert.DoesNotContain("include_granted_scopes=", location); 531 | } 532 | 533 | [Fact] 534 | public async Task SignInThrows() 535 | { 536 | var server = CreateServer(o => 537 | { 538 | ConfigureDefaults(o); 539 | }); 540 | var transaction = await server.SendAsync("https://example.com/signin"); 541 | Assert.Equal(HttpStatusCode.OK, transaction.Response.StatusCode); 542 | } 543 | 544 | [Fact] 545 | public async Task SignOutThrows() 546 | { 547 | var server = CreateServer(o => 548 | { 549 | ConfigureDefaults(o); 550 | }); 551 | var transaction = await server.SendAsync("https://example.com/signout"); 552 | Assert.Equal(HttpStatusCode.OK, transaction.Response.StatusCode); 553 | } 554 | 555 | [Fact] 556 | public async Task ForbidWillRedirect() 557 | { 558 | var server = CreateServer(o => 559 | { 560 | ConfigureDefaults(o); 561 | }); 562 | var transaction = await server.SendAsync("https://example.com/forbid"); 563 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 564 | } 565 | 566 | [Fact] 567 | public async Task Challenge401WillNotTriggerRedirection() 568 | { 569 | var server = CreateServer(o => 570 | { 571 | ConfigureDefaults(o); 572 | }); 573 | var transaction = await server.SendAsync("https://example.com/401"); 574 | Assert.Equal(HttpStatusCode.Unauthorized, transaction.Response.StatusCode); 575 | } 576 | 577 | [Fact] 578 | public async Task ChallengeWillSetCorrelationCookie() 579 | { 580 | var server = CreateServer(o => 581 | { 582 | ConfigureDefaults(o); 583 | }); 584 | var transaction = await server.SendAsync("https://example.com/challenge"); 585 | Assert.Contains(transaction.SetCookie, cookie => cookie.StartsWith(".AspNetCore.Correlation.WeixinAuth.")); 586 | } 587 | 588 | [Fact] 589 | public async Task ChallengeWillSetDefaultScope() 590 | { 591 | var server = CreateServer(o => 592 | { 593 | ConfigureDefaults(o); 594 | }); 595 | var transaction = await server.SendAsync("https://example.com/challenge"); 596 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 597 | var query = transaction.Response.Headers.Location.Query; 598 | Assert.Contains("scope=", query); 599 | } 600 | 601 | [Fact] 602 | public async Task ChallengeWillUseAuthenticationPropertiesParametersAsQueryArguments() 603 | { 604 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 605 | var server = CreateServer(o => 606 | { 607 | ConfigureDefaults(o); 608 | o.StateDataFormat = stateFormat; 609 | }, 610 | context => 611 | { 612 | var req = context.Request; 613 | var res = context.Response; 614 | if (req.Path == new PathString("/challenge2")) 615 | { 616 | return context.ChallengeAsync("WeixinAuth", new OAuthChallengeProperties 617 | { 618 | Scope = new string[] { "snsapi_login", "https://www.googleapis.com/auth/plus.login" }, 619 | }); 620 | } 621 | 622 | return Task.FromResult(null); 623 | }); 624 | var transaction = await server.SendAsync("https://example.com/challenge2"); 625 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 626 | 627 | // verify query arguments 628 | var query = QueryHelpers.ParseQuery(transaction.Response.Headers.Location.Query); 629 | Assert.Equal("snsapi_login,https://www.googleapis.com/auth/plus.login", query["scope"]); 630 | //Assert.Equal("test@example.com", query["login_hint"]); 631 | 632 | // verify that the passed items were not serialized 633 | var state = query["state"]; 634 | var stateProperties = WeixinAuthAuthenticationPropertiesHelper.GetByCorrelationId(stateFormat, transaction.SetCookie, state, WeixinAuthDefaults.AuthenticationScheme, ".AspNetCore.Correlation"); 635 | Assert.DoesNotContain("scope", stateProperties.Items.Keys); 636 | Assert.DoesNotContain("login_hint", stateProperties.Items.Keys); 637 | } 638 | 639 | [Fact] 640 | public async Task ChallengeWillUseAuthenticationPropertiesItemsAsParameters() 641 | { 642 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 643 | var server = CreateServer(o => 644 | { 645 | ConfigureDefaults(o); 646 | o.StateDataFormat = stateFormat; 647 | }, 648 | context => 649 | { 650 | var req = context.Request; 651 | var res = context.Response; 652 | if (req.Path == new PathString("/challenge2")) 653 | { 654 | return context.ChallengeAsync(WeixinAuthDefaults.AuthenticationScheme, new AuthenticationProperties(new Dictionary() 655 | { 656 | { "scope", "https://www.googleapis.com/auth/plus.login" }, 657 | //{ "login_hint", "test@example.com" }, 658 | })); 659 | } 660 | 661 | return Task.FromResult(null); 662 | }); 663 | var transaction = await server.SendAsync("https://example.com/challenge2"); 664 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 665 | 666 | // verify query arguments 667 | var query = QueryHelpers.ParseQuery(transaction.Response.Headers.Location.Query); 668 | Assert.Equal("https://www.googleapis.com/auth/plus.login", query["scope"]); 669 | 670 | // verify that the passed items were not serialized 671 | var state = query["state"]; 672 | var stateProperties = WeixinAuthAuthenticationPropertiesHelper.GetByCorrelationId(stateFormat, transaction.SetCookie, state, WeixinAuthDefaults.AuthenticationScheme, ".AspNetCore.Correlation"); 673 | Assert.DoesNotContain("scope", stateProperties.Items.Keys); 674 | } 675 | 676 | [Fact] 677 | public async Task ChallengeWillUseAuthenticationPropertiesItemsAsQueryArgumentsButParametersWillOverwrite() 678 | { 679 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 680 | var server = CreateServer(o => 681 | { 682 | ConfigureDefaults(o); 683 | o.StateDataFormat = stateFormat; 684 | }, 685 | context => 686 | { 687 | var req = context.Request; 688 | var res = context.Response; 689 | if (req.Path == new PathString("/challenge2")) 690 | { 691 | return context.ChallengeAsync("WeixinAuth", new OAuthChallengeProperties() { Scope = new string[] { "https://www.googleapis.com/auth/plus.login" } }); 692 | } 693 | 694 | return Task.FromResult(null); 695 | }); 696 | var transaction = await server.SendAsync("https://example.com/challenge2"); 697 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 698 | 699 | // verify query arguments 700 | var query = QueryHelpers.ParseQuery(transaction.Response.Headers.Location.Query); 701 | Assert.Equal("https://www.googleapis.com/auth/plus.login", query[OAuthChallengeProperties.ScopeKey]); 702 | 703 | // verify that the passed items were not serialized 704 | var state = query["state"]; 705 | var stateProperties = WeixinAuthAuthenticationPropertiesHelper.GetByCorrelationId(stateFormat, transaction.SetCookie, state, WeixinAuthDefaults.AuthenticationScheme, ".AspNetCore.Correlation"); 706 | Assert.Contains(".redirect", stateProperties.Items.Keys); 707 | Assert.Contains(".xsrf", stateProperties.Items.Keys); 708 | Assert.DoesNotContain("scope", stateProperties.Items.Keys); 709 | } 710 | 711 | [Fact] 712 | public async Task ChallengeWillTriggerApplyRedirectEvent() 713 | { 714 | var server = CreateServer(o => 715 | { 716 | ConfigureDefaults(o); 717 | o.Events = new OAuthEvents 718 | { 719 | OnRedirectToAuthorizationEndpoint = context => 720 | { 721 | var oldUri = new Uri(context.RedirectUri); 722 | var queryBuilder = new QueryBuilder() 723 | { 724 | { "custom", "test" } 725 | }; 726 | var customUrl = o.AuthorizationEndpoint + oldUri.PathAndQuery + queryBuilder + "#wechat_redirect"; 727 | context.Response.Redirect(customUrl); 728 | return Task.FromResult(0); 729 | } 730 | }; 731 | }); 732 | var transaction = await server.SendAsync("https://example.com/challenge"); 733 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 734 | var query = transaction.Response.Headers.Location.Query; 735 | Assert.Contains("custom=test", query); 736 | } 737 | 738 | [Fact] 739 | public async Task AuthenticateWithoutCookieWillReturnNone() 740 | { 741 | var server = CreateServer(o => 742 | { 743 | ConfigureDefaults(o); 744 | }, 745 | async context => 746 | { 747 | var req = context.Request; 748 | var res = context.Response; 749 | if (req.Path == new PathString("/auth")) 750 | { 751 | var result = await context.AuthenticateAsync(WeixinAuthDefaults.AuthenticationScheme); 752 | Assert.False(result.Succeeded); 753 | { 754 | //7.0, 8.0, 9.0 755 | #if NET7_0_OR_GREATER 756 | Assert.True(result.None); 757 | Assert.Null(result.Failure); 758 | #else 759 | Assert.False(result.None); 760 | Assert.NotNull(result.Failure); 761 | Assert.Equal("Not authenticated", result.Failure.Message); 762 | #endif 763 | } 764 | } 765 | }); 766 | var transaction = await server.SendAsync("https://example.com/auth"); 767 | Assert.Equal(HttpStatusCode.OK, transaction.Response.StatusCode); 768 | } 769 | 770 | [Fact] 771 | public async Task ReplyPathWithoutStateQueryStringWillBeRejected() 772 | { 773 | var server = CreateServer(o => 774 | { 775 | ConfigureDefaults(o); 776 | }); 777 | var error = await Assert.ThrowsAnyAsync(() => server.SendAsync("https://example.com/signin-WeixinAuth?code=TestCode")); 778 | Assert.Equal("The oauth state was missing.", error.GetBaseException().Message); 779 | } 780 | 781 | [Theory] 782 | [InlineData(true)] 783 | [InlineData(false)] 784 | public async Task ReplyPathWithErrorFails(bool redirect) 785 | { 786 | var server = CreateServer(o => 787 | { 788 | ConfigureDefaults(o); 789 | o.StateDataFormat = new TestStateDataFormat(); 790 | o.Events = redirect ? new OAuthEvents() 791 | { 792 | OnRemoteFailure = ctx => 793 | { 794 | ctx.Response.Redirect("/error?FailureMessage=" + UrlEncoder.Default.Encode(ctx.Failure.Message)); 795 | ctx.HandleResponse(); 796 | return Task.FromResult(0); 797 | } 798 | } : new OAuthEvents(); 799 | }); 800 | var sendTask = server.SendAsync("https://example.com/signin-weixinauth?error=OMG&error_description=SoBad&error_uri=foobar&state=protected_state", 801 | ".AspNetCore.Correlation.WeixinAuth.corrilationId=N"); 802 | if (redirect) 803 | { 804 | var transaction = await sendTask; 805 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 806 | Assert.Equal("/error?FailureMessage=OMG" + UrlEncoder.Default.Encode(";Description=SoBad;Uri=foobar"), transaction.Response.Headers.GetValues("Location").First()); 807 | } 808 | else 809 | { 810 | var error = await Assert.ThrowsAnyAsync(() => sendTask); 811 | Assert.Equal("OMG;Description=SoBad;Uri=foobar", error.GetBaseException().Message); 812 | } 813 | } 814 | 815 | [Theory] 816 | [InlineData(null)] 817 | [InlineData("CustomIssuer")] 818 | public async Task ReplyPathWillAuthenticateValidAuthorizeCodeAndState(string claimsIssuer) 819 | { 820 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 821 | var server = CreateServer(o => 822 | { 823 | ConfigureDefaults(o); 824 | o.SaveTokens = true; 825 | o.StateDataFormat = stateFormat; 826 | if (claimsIssuer != null) 827 | { 828 | o.ClaimsIssuer = claimsIssuer; 829 | } 830 | o.BackchannelHttpHandler = CreateBackchannel(); 831 | }); 832 | 833 | var properties = new AuthenticationProperties(); 834 | properties.Items.Add(correlationKey, correlationId); 835 | properties.RedirectUri = "/me"; 836 | var state = stateFormat.Protect(properties); 837 | var transaction = await server.SendAsync( 838 | $"https://example.com/signin-{WeixinAuthDefaults.AuthenticationScheme}?code=TestCode&state={UrlEncoder.Default.Encode(correlationId)}", 839 | $".AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationId}={correlationMarker}" 840 | + $";.AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationMarker}.{correlationId}={state}"); 841 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 842 | Assert.Equal("/me", transaction.Response.Headers.GetValues("Location").First()); 843 | Assert.True(transaction.SetCookie.Count >= 2); 844 | 845 | var authCookie = transaction.AuthenticationCookieValue; 846 | transaction = await server.SendAsync("https://example.com/me", authCookie); 847 | Assert.Equal(HttpStatusCode.OK, transaction.Response.StatusCode); 848 | var expectedIssuer = claimsIssuer ?? WeixinAuthDefaults.AuthenticationScheme; 849 | Assert.Equal("Test Name", transaction.FindClaimValue(ClaimTypes.Name, expectedIssuer)); 850 | Assert.Equal("Test Open ID", transaction.FindClaimValue(ClaimTypes.NameIdentifier, expectedIssuer)); 851 | Assert.Equal("Test Open ID", transaction.FindClaimValue(WeixinAuthClaimTypes.OpenId, expectedIssuer)); 852 | Assert.Equal("Test Union ID", transaction.FindClaimValue(WeixinAuthClaimTypes.UnionId, expectedIssuer)); 853 | 854 | // Ensure claims transformation 855 | Assert.Equal("yup", transaction.FindClaimValue("xform")); 856 | 857 | transaction = await server.SendAsync("https://example.com/tokens", authCookie); 858 | Assert.Equal(HttpStatusCode.OK, transaction.Response.StatusCode); 859 | Assert.Equal("Test Access Token", transaction.FindTokenValue("access_token")); 860 | //Assert.Equal("Bearer", transaction.FindTokenValue("token_type")); 861 | Assert.NotNull(transaction.FindTokenValue("expires_at")); 862 | } 863 | 864 | // REVIEW: Fix this once we revisit error handling to not blow up 865 | [Theory] 866 | [InlineData(true)] 867 | [InlineData(false)] 868 | public async Task ReplyPathWillThrowIfCodeIsInvalid(bool redirect) 869 | { 870 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 871 | var server = CreateServer(o => 872 | { 873 | ConfigureDefaults(o); 874 | o.StateDataFormat = stateFormat; 875 | o.BackchannelHttpHandler = new TestHttpMessageHandler 876 | { 877 | Sender = req => 878 | { 879 | return ReturnJsonResponse(new { Error = "Error" }, 880 | HttpStatusCode.BadRequest); 881 | } 882 | }; 883 | o.Events = redirect ? new OAuthEvents() 884 | { 885 | OnRemoteFailure = ctx => 886 | { 887 | ctx.Response.Redirect("/error?FailureMessage=" + UrlEncoder.Default.Encode(ctx.Failure.Message)); 888 | ctx.HandleResponse(); 889 | return Task.FromResult(0); 890 | } 891 | } : new OAuthEvents(); 892 | }); 893 | var properties = new AuthenticationProperties(); 894 | properties.Items.Add(correlationKey, correlationId); 895 | properties.RedirectUri = "/me"; 896 | 897 | var state = stateFormat.Protect(properties); 898 | var sendTask = server.SendAsync( 899 | $"https://example.com/signin-{WeixinAuthDefaults.AuthenticationScheme}?code=TestCode&state={UrlEncoder.Default.Encode(correlationId)}", 900 | $".AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationId}={correlationMarker}" 901 | + $";.AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationMarker}.{correlationId}={state}"); 902 | if (redirect) 903 | { 904 | var transaction = await sendTask; 905 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 906 | Assert.Equal("/error?FailureMessage=" + UrlEncoder.Default.Encode("OAuth token endpoint failure: Status: BadRequest;Headers: ;Body: {\"Error\":\"Error\"};"), 907 | transaction.Response.Headers.GetValues("Location").First()); 908 | } 909 | else 910 | { 911 | var error = await Assert.ThrowsAnyAsync(() => sendTask); 912 | Assert.Equal("OAuth token endpoint failure: Status: BadRequest;Headers: ;Body: {\"Error\":\"Error\"};", 913 | error.GetBaseException().Message); 914 | } 915 | } 916 | 917 | [Theory] 918 | [InlineData(true)] 919 | [InlineData(false)] 920 | public async Task ReplyPathWillRejectIfAccessTokenIsMissing(bool redirect) 921 | { 922 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 923 | var server = CreateServer(o => 924 | { 925 | ConfigureDefaults(o); 926 | o.StateDataFormat = stateFormat; 927 | o.BackchannelHttpHandler = new TestHttpMessageHandler 928 | { 929 | Sender = req => 930 | { 931 | return ReturnJsonResponse(new object()); 932 | } 933 | }; 934 | o.Events = redirect ? new OAuthEvents() 935 | { 936 | OnRemoteFailure = ctx => 937 | { 938 | ctx.Response.Redirect("/error?FailureMessage=" + UrlEncoder.Default.Encode(ctx.Failure.Message)); 939 | ctx.HandleResponse(); 940 | return Task.FromResult(0); 941 | } 942 | } : new OAuthEvents(); 943 | }); 944 | var properties = new AuthenticationProperties(); 945 | properties.Items.Add(correlationKey, correlationId); 946 | properties.RedirectUri = "/me"; 947 | var state = stateFormat.Protect(properties); 948 | var sendTask = server.SendAsync( 949 | $"https://example.com/signin-{WeixinAuthDefaults.AuthenticationScheme}?code=TestCode&state={UrlEncoder.Default.Encode(correlationId)}", 950 | $".AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationId}={correlationMarker}" 951 | + $";.AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationMarker}.{correlationId}={state}"); 952 | if (redirect) 953 | { 954 | var transaction = await sendTask; 955 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 956 | Assert.Equal("/error?FailureMessage=" + UrlEncoder.Default.Encode("Failed to retrieve access token."), 957 | transaction.Response.Headers.GetValues("Location").First()); 958 | } 959 | else 960 | { 961 | var error = await Assert.ThrowsAnyAsync(() => sendTask); 962 | Assert.Equal("Failed to retrieve access token.", error.GetBaseException().Message); 963 | } 964 | } 965 | 966 | [Fact] 967 | public async Task AuthenticatedEventCanGetRefreshToken() 968 | { 969 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 970 | var server = CreateServer(o => 971 | { 972 | ConfigureDefaults(o); 973 | o.StateDataFormat = stateFormat; 974 | o.BackchannelHttpHandler = CreateBackchannel(); 975 | o.Events = new OAuthEvents 976 | { 977 | OnCreatingTicket = context => 978 | { 979 | var refreshToken = context.RefreshToken; 980 | context.Principal.AddIdentity(new ClaimsIdentity(new Claim[] { new Claim("RefreshToken", refreshToken, ClaimValueTypes.String, "WeixinAuth") }, "WeixinAuth")); 981 | return Task.FromResult(0); 982 | } 983 | }; 984 | }); 985 | 986 | // Skip the challenge step, go directly to the callback path 987 | 988 | var properties = new AuthenticationProperties(); 989 | properties.Items.Add(correlationKey, correlationId); 990 | properties.RedirectUri = "/me"; 991 | var state = stateFormat.Protect(properties); 992 | var transaction = await server.SendAsync( 993 | $"https://example.com/signin-{WeixinAuthDefaults.AuthenticationScheme}?code=TestCode&state={UrlEncoder.Default.Encode(correlationId)}", 994 | $".AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationId}={correlationMarker}" 995 | + $";.AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationMarker}.{correlationId}={state}"); 996 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 997 | Assert.Equal("/me", transaction.Response.Headers.GetValues("Location").First()); 998 | Assert.True(transaction.SetCookie.Count >= 2); 999 | 1000 | var authCookie = transaction.AuthenticationCookieValue; 1001 | transaction = await server.SendAsync("https://example.com/me", authCookie); 1002 | Assert.Equal(HttpStatusCode.OK, transaction.Response.StatusCode); 1003 | Assert.Equal("Test Refresh Token", transaction.FindClaimValue("RefreshToken")); 1004 | } 1005 | 1006 | [Fact] 1007 | public async Task NullRedirectUriWillRedirectToSlash() 1008 | { 1009 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 1010 | var server = CreateServer(o => 1011 | { 1012 | ConfigureDefaults(o); 1013 | o.StateDataFormat = stateFormat; 1014 | o.BackchannelHttpHandler = CreateBackchannel(); 1015 | o.Events = new OAuthEvents 1016 | { 1017 | OnTicketReceived = context => 1018 | { 1019 | context.Properties.RedirectUri = null; 1020 | return Task.FromResult(0); 1021 | } 1022 | }; 1023 | }); 1024 | 1025 | var properties = new AuthenticationProperties(); 1026 | properties.Items.Add(correlationKey, correlationId); 1027 | var state = stateFormat.Protect(properties); 1028 | var transaction = await server.SendAsync( 1029 | $"https://example.com/signin-{WeixinAuthDefaults.AuthenticationScheme}?code=TestCode&state={UrlEncoder.Default.Encode(correlationId)}", 1030 | $".AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationId}={correlationMarker}" 1031 | + $";.AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationMarker}.{correlationId}={state}"); 1032 | 1033 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 1034 | Assert.Equal("/", transaction.Response.Headers.GetValues("Location").First()); 1035 | Assert.True(transaction.SetCookie.Count >= 2); 1036 | } 1037 | 1038 | [Fact] 1039 | public async Task ValidateAuthenticatedContext() 1040 | { 1041 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 1042 | var server = CreateServer(o => 1043 | { 1044 | ConfigureDefaults(o); 1045 | o.StateDataFormat = stateFormat; 1046 | //o.AccessType = "offline"; 1047 | o.Events = new OAuthEvents() 1048 | { 1049 | OnCreatingTicket = context => 1050 | { 1051 | Assert.True(context.User.ToString().Length > 0); 1052 | Assert.Equal("Test Access Token", context.AccessToken); 1053 | Assert.Equal("Test Refresh Token", context.RefreshToken); 1054 | Assert.Equal(TimeSpan.FromSeconds(3600), context.ExpiresIn); 1055 | Assert.Equal("Test Open ID", context.Identity.FindFirst(ClaimTypes.NameIdentifier)?.Value); 1056 | Assert.Equal("Test Name", context.Identity.FindFirst(ClaimTypes.Name)?.Value); 1057 | Assert.Equal("Test Open ID", context.Identity.FindFirst(WeixinAuthClaimTypes.OpenId)?.Value); 1058 | Assert.Equal("Test Union ID", context.Identity.FindFirst(WeixinAuthClaimTypes.UnionId)?.Value); 1059 | return Task.FromResult(0); 1060 | } 1061 | }; 1062 | o.BackchannelHttpHandler = CreateBackchannel(); 1063 | }); 1064 | 1065 | var properties = new AuthenticationProperties(); 1066 | properties.Items.Add(correlationKey, correlationId); 1067 | properties.RedirectUri = "/foo"; 1068 | var state = stateFormat.Protect(properties); 1069 | 1070 | //Post a message to the WeixinAuth middleware 1071 | var transaction = await server.SendAsync( 1072 | $"https://example.com/signin-{WeixinAuthDefaults.AuthenticationScheme}?code=TestCode&state={UrlEncoder.Default.Encode(correlationId)}", 1073 | $".AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationId}={correlationMarker}" 1074 | + $";.AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationMarker}.{correlationId}={state}"); 1075 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 1076 | Assert.Equal("/foo", transaction.Response.Headers.GetValues("Location").First()); 1077 | } 1078 | 1079 | [Fact] 1080 | public async Task NoStateCausesException() 1081 | { 1082 | var server = CreateServer(o => 1083 | { 1084 | ConfigureDefaults(o); 1085 | }); 1086 | 1087 | //Post a message to the WeixinAuth middleware 1088 | var error = await Assert.ThrowsAnyAsync(() => server.SendAsync("https://example.com/signin-WeixinAuth")); 1089 | Assert.Equal("The oauth state was missing.", error.GetBaseException().Message); 1090 | } 1091 | 1092 | [Fact] 1093 | public async Task StateDataFormatCauseException() 1094 | { 1095 | var server = CreateServer(o => 1096 | { 1097 | ConfigureDefaults(o); 1098 | }); 1099 | 1100 | //Post a message to the WeixinAuth middleware 1101 | var error = await Assert.ThrowsAnyAsync(() => server.SendAsync("https://example.com/signin-WeixinAuth?state=TestState")); 1102 | Assert.StartsWith("The oauth state cookie was missing", error.GetBaseException().Message); 1103 | } 1104 | 1105 | [Fact] 1106 | public async Task StateCorrelationMissingCauseException() 1107 | { 1108 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 1109 | var server = CreateServer(o => 1110 | { 1111 | ConfigureDefaults(o); 1112 | o.StateDataFormat = stateFormat; 1113 | }); 1114 | 1115 | var properties = new AuthenticationProperties(); 1116 | properties.Items.Add(correlationKey, correlationId); 1117 | var state = stateFormat.Protect(properties); 1118 | 1119 | var error3 = await Assert.ThrowsAnyAsync(() 1120 | => server.SendAsync( 1121 | "https://example.com/signin-WeixinAuth?state=" + UrlEncoder.Default.Encode(state))); 1122 | Assert.StartsWith("The oauth state cookie was missing: ", error3.GetBaseException().Message); 1123 | } 1124 | 1125 | [Fact] 1126 | public async Task StateCorrelationMarkerWrongCauseException() 1127 | { 1128 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 1129 | var server = CreateServer(o => 1130 | { 1131 | ConfigureDefaults(o); 1132 | o.StateDataFormat = stateFormat; 1133 | }); 1134 | 1135 | var properties = new AuthenticationProperties(); 1136 | properties.Items.Add(correlationKey, correlationId); 1137 | var state = stateFormat.Protect(properties); 1138 | 1139 | var error = await Assert.ThrowsAnyAsync(() 1140 | => server.SendAsync( 1141 | $"https://example.com/signin-WeixinAuth?state={UrlEncoder.Default.Encode(correlationId)}", 1142 | $".AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationId}=HERE_MUST_BE_N" 1143 | + $";.AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationMarker}.{correlationId}={state}")); 1144 | Assert.Equal("Correlation failed.", error.GetBaseException().Message); 1145 | } 1146 | 1147 | [Fact] 1148 | public async Task StateCorrelationSuccessCodeMissing() 1149 | { 1150 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 1151 | var server = CreateServer(o => 1152 | { 1153 | ConfigureDefaults(o); 1154 | o.StateDataFormat = stateFormat; 1155 | }); 1156 | 1157 | var properties = new AuthenticationProperties(); 1158 | properties.Items.Add(correlationKey, correlationId); 1159 | var state = stateFormat.Protect(properties); 1160 | 1161 | var error2 = await Assert.ThrowsAnyAsync(() 1162 | => server.SendAsync( 1163 | "https://example.com/signin-WeixinAuth?state=" + UrlEncoder.Default.Encode(correlationId), 1164 | $".AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationId}={correlationMarker}" 1165 | + $";.AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationMarker}.{correlationId}={state}")); 1166 | Assert.Equal("Code was not found.", error2.GetBaseException().Message); 1167 | } 1168 | 1169 | [Fact] 1170 | public async Task CodeInvalidCauseException() 1171 | { 1172 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 1173 | var server = CreateServer(o => 1174 | { 1175 | ConfigureDefaults(o); 1176 | o.StateDataFormat = stateFormat; 1177 | }); 1178 | 1179 | // Skip the challenge step, go directly to the callback path 1180 | 1181 | var properties = new AuthenticationProperties(); 1182 | properties.Items.Add(correlationKey, correlationId); 1183 | properties.RedirectUri = "/me"; 1184 | var state = stateFormat.Protect(properties); 1185 | 1186 | var result = await Assert.ThrowsAnyAsync(() => server.SendAsync( 1187 | $"https://example.com/signin-{WeixinAuthDefaults.AuthenticationScheme}?code=TestCode&state={UrlEncoder.Default.Encode(correlationId)}", 1188 | $".AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationId}={correlationMarker}" 1189 | + $";.AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationMarker}.{correlationId}={state}")); 1190 | 1191 | Assert.StartsWith("OAuth token endpoint failure: ", result.GetBaseException().Message); 1192 | Assert.Contains("invalid appid", result.GetBaseException().Message); 1193 | } 1194 | 1195 | [Fact] 1196 | public async Task CanRedirectOnError() 1197 | { 1198 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 1199 | var server = CreateServer(o => 1200 | { 1201 | ConfigureDefaults(o); 1202 | o.Events = new OAuthEvents() 1203 | { 1204 | OnRemoteFailure = ctx => 1205 | { 1206 | ctx.Response.Redirect("/error?FailureMessage=" + UrlEncoder.Default.Encode(ctx.Failure.Message)); 1207 | ctx.HandleResponse(); 1208 | return Task.FromResult(0); 1209 | } 1210 | }; 1211 | }); 1212 | 1213 | //Post a message to the WeixinAuth middleware 1214 | var transaction = await server.SendAsync( 1215 | "https://example.com/signin-WeixinAuth?code=TestCode"); 1216 | 1217 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 1218 | Assert.Equal("/error?FailureMessage=" + UrlEncoder.Default.Encode("The oauth state was missing."), 1219 | transaction.Response.Headers.GetValues("Location").First()); 1220 | } 1221 | 1222 | [Fact] 1223 | public async Task AuthenticateAutomaticWhenAlreadySignedInSucceeds() 1224 | { 1225 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 1226 | var server = CreateServer(o => 1227 | { 1228 | ConfigureDefaults(o); 1229 | o.SilentMode = false; 1230 | o.StateDataFormat = stateFormat; 1231 | o.SaveTokens = true; 1232 | o.BackchannelHttpHandler = CreateBackchannel(); 1233 | }); 1234 | 1235 | // Skip the challenge step, go directly to the callback path 1236 | 1237 | var properties = new AuthenticationProperties(); 1238 | properties.Items.Add(correlationKey, correlationId); 1239 | properties.RedirectUri = "/me"; 1240 | var state = stateFormat.Protect(properties); 1241 | var transaction = await server.SendAsync( 1242 | $"https://example.com/signin-{WeixinAuthDefaults.AuthenticationScheme}?code=TestCode&state={UrlEncoder.Default.Encode(correlationId)}", 1243 | $".AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationId}={correlationMarker}" 1244 | + $";.AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationMarker}.{correlationId}={state}"); 1245 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 1246 | Assert.Equal("/me", transaction.Response.Headers.GetValues("Location").First()); 1247 | Assert.True(transaction.SetCookie.Count >= 2); 1248 | 1249 | var authCookie = transaction.AuthenticationCookieValue; 1250 | transaction = await server.SendAsync("https://example.com/authenticate", authCookie); 1251 | Assert.Equal(HttpStatusCode.OK, transaction.Response.StatusCode); 1252 | Assert.Equal("Test Name", transaction.FindClaimValue(ClaimTypes.Name)); 1253 | Assert.Equal("Test Open ID", transaction.FindClaimValue(ClaimTypes.NameIdentifier)); 1254 | Assert.Equal("Test Open ID", transaction.FindClaimValue(WeixinAuthClaimTypes.OpenId)); 1255 | Assert.Equal("Test Union ID", transaction.FindClaimValue(WeixinAuthClaimTypes.UnionId)); 1256 | 1257 | // Ensure claims transformation 1258 | Assert.Equal("yup", transaction.FindClaimValue("xform")); 1259 | } 1260 | 1261 | [Fact] 1262 | public async Task AuthenticateWeixinAuthWhenAlreadySignedInSucceeds() 1263 | { 1264 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 1265 | var server = CreateServer(o => 1266 | { 1267 | ConfigureDefaults(o); 1268 | o.StateDataFormat = stateFormat; 1269 | o.SaveTokens = true; 1270 | o.BackchannelHttpHandler = CreateBackchannel(); 1271 | }); 1272 | 1273 | // Skip the challenge step, go directly to the callback path 1274 | 1275 | var properties = new AuthenticationProperties(); 1276 | properties.Items.Add(correlationKey, correlationId); 1277 | properties.RedirectUri = "/me"; 1278 | var state = stateFormat.Protect(properties); 1279 | var transaction = await server.SendAsync( 1280 | $"https://example.com/signin-{WeixinAuthDefaults.AuthenticationScheme}?code=TestCode&state={UrlEncoder.Default.Encode(correlationId)}", 1281 | $".AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationId}={correlationMarker}" 1282 | + $";.AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationMarker}.{correlationId}={state}"); 1283 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 1284 | Assert.Equal("/me", transaction.Response.Headers.GetValues("Location").First()); 1285 | Assert.True(transaction.SetCookie.Count >= 2); 1286 | 1287 | var authCookie = transaction.AuthenticationCookieValue; 1288 | transaction = await server.SendAsync("https://example.com/authenticate-WeixinAuth", authCookie); 1289 | Assert.Equal(HttpStatusCode.OK, transaction.Response.StatusCode); 1290 | Assert.Equal("Test Name", transaction.FindClaimValue(ClaimTypes.Name)); 1291 | Assert.Equal("Test Open ID", transaction.FindClaimValue(ClaimTypes.NameIdentifier)); 1292 | Assert.Equal("Test Open ID", transaction.FindClaimValue(WeixinAuthClaimTypes.OpenId)); 1293 | Assert.Equal("Test Union ID", transaction.FindClaimValue(WeixinAuthClaimTypes.UnionId)); 1294 | 1295 | // Ensure claims transformation 1296 | Assert.Equal("yup", transaction.FindClaimValue("xform")); 1297 | } 1298 | 1299 | [Fact] 1300 | public async Task AuthenticateTwitterWhenAlreadySignedWithWeixinAuthReturnsNull() 1301 | { 1302 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 1303 | var server = CreateServer(o => 1304 | { 1305 | ConfigureDefaults(o); 1306 | o.StateDataFormat = stateFormat; 1307 | o.SaveTokens = true; 1308 | o.BackchannelHttpHandler = CreateBackchannel(); 1309 | }); 1310 | 1311 | // Skip the challenge step, go directly to the callback path 1312 | 1313 | var properties = new AuthenticationProperties(); 1314 | properties.Items.Add(correlationKey, correlationId); 1315 | properties.RedirectUri = "/me"; 1316 | var state = stateFormat.Protect(properties); 1317 | var transaction = await server.SendAsync( 1318 | $"https://example.com/signin-{WeixinAuthDefaults.AuthenticationScheme}?code=TestCode&state={UrlEncoder.Default.Encode(correlationId)}", 1319 | $".AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationId}={correlationMarker}" 1320 | + $";.AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationMarker}.{correlationId}={state}"); 1321 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 1322 | Assert.Equal("/me", transaction.Response.Headers.GetValues("Location").First()); 1323 | Assert.True(transaction.SetCookie.Count >= 2); 1324 | 1325 | var authCookie = transaction.AuthenticationCookieValue; 1326 | transaction = await server.SendAsync("https://example.com/authenticate-twitter", authCookie); 1327 | Assert.Equal(HttpStatusCode.OK, transaction.Response.StatusCode); 1328 | Assert.Null(transaction.FindClaimValue(ClaimTypes.Name)); 1329 | } 1330 | 1331 | [Fact] 1332 | public async Task ChallengeTwitterWhenAlreadySignedWithWeixinAuthSucceeds() 1333 | { 1334 | var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("WeixinAuthTest")); 1335 | var server = CreateServer(o => 1336 | { 1337 | ConfigureDefaults(o); 1338 | o.StateDataFormat = stateFormat; 1339 | o.SaveTokens = true; 1340 | o.BackchannelHttpHandler = CreateBackchannel(); 1341 | }); 1342 | 1343 | // Skip the challenge step, go directly to the callback path 1344 | 1345 | var properties = new AuthenticationProperties(); 1346 | properties.Items.Add(correlationKey, correlationId); 1347 | properties.RedirectUri = "/me"; 1348 | var state = stateFormat.Protect(properties); 1349 | var transaction = await server.SendAsync( 1350 | $"https://example.com/signin-{WeixinAuthDefaults.AuthenticationScheme}?code=TestCode&state={UrlEncoder.Default.Encode(correlationId)}", 1351 | $".AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationId}={correlationMarker}" 1352 | + $";.AspNetCore.Correlation.{WeixinAuthDefaults.AuthenticationScheme}.{correlationMarker}.{correlationId}={state}"); 1353 | Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 1354 | Assert.Equal("/me", transaction.Response.Headers.GetValues("Location").First()); 1355 | Assert.True(transaction.SetCookie.Count >= 2); 1356 | 1357 | var authCookie = transaction.AuthenticationCookieValue; 1358 | //transaction = await server.SendAsync("https://example.com/challenge-twitter", authCookie); 1359 | //Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); 1360 | //Assert.StartsWith("https://www.twitter.com/", transaction.Response.Headers.Location.OriginalString); 1361 | } 1362 | 1363 | private HttpMessageHandler CreateBackchannel() 1364 | { 1365 | return new TestHttpMessageHandler() 1366 | { 1367 | Sender = req => 1368 | { 1369 | if (req.RequestUri.AbsoluteUri.StartsWith(WeixinAuthDefaults.TokenEndpoint)) 1370 | { 1371 | return ReturnJsonResponse(new 1372 | { 1373 | access_token = "Test Access Token", 1374 | expires_in = 3600, 1375 | refresh_token = "Test Refresh Token", 1376 | openid = "Test Open ID", 1377 | scope = "Test_Scope,snsapi_userinfo", 1378 | unionid = "Test Union ID" 1379 | //token_type = "Bearer" 1380 | }); 1381 | } 1382 | else if (req.RequestUri.AbsoluteUri.StartsWith(WeixinAuthDefaults.UserInformationEndpoint)) 1383 | { 1384 | return ReturnJsonResponse(new 1385 | { 1386 | openid = "Test Open ID", 1387 | nickname = "Test Name", 1388 | sex = 1, 1389 | province = "Test Province", 1390 | city = "Test City", 1391 | country = "Test Country", 1392 | headimgurl = "http://wx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0", 1393 | privilege = new[] 1394 | { 1395 | "PRIVILEGE1", 1396 | "PRIVILEGE2" 1397 | }, 1398 | unionid = "Test Union ID" 1399 | }); 1400 | } 1401 | 1402 | throw new NotImplementedException(req.RequestUri.AbsoluteUri); 1403 | } 1404 | }; 1405 | } 1406 | 1407 | private static HttpResponseMessage ReturnJsonResponse(object content, HttpStatusCode code = HttpStatusCode.OK) 1408 | { 1409 | var res = new HttpResponseMessage(code); 1410 | var text = JsonSerializer.Serialize(content); 1411 | res.Content = new StringContent(text, Encoding.UTF8, "application/json"); 1412 | return res; 1413 | } 1414 | 1415 | private class ClaimsTransformer : IClaimsTransformation 1416 | { 1417 | public Task TransformAsync(ClaimsPrincipal p) 1418 | { 1419 | if (!p.Identities.Any(i => i.AuthenticationType == "xform")) 1420 | { 1421 | var id = new ClaimsIdentity("xform"); 1422 | id.AddClaim(new Claim("xform", "yup")); 1423 | p.AddIdentity(id); 1424 | } 1425 | return Task.FromResult(p); 1426 | } 1427 | } 1428 | 1429 | private static TestServer CreateServer(Action configureOptions, Func testpath = null) 1430 | { 1431 | var builder = new WebHostBuilder() 1432 | .Configure(app => 1433 | { 1434 | app.UseAuthentication(); 1435 | app.Use(async (context, next) => 1436 | { 1437 | var req = context.Request; 1438 | var res = context.Response; 1439 | if (req.Path == new PathString("/challenge")) 1440 | { 1441 | await context.ChallengeAsync(); 1442 | } 1443 | else if (req.Path == new PathString("/challenge-twitter")) 1444 | { 1445 | await context.ChallengeAsync(TwitterDefaults.AuthenticationScheme); 1446 | } 1447 | else if (req.Path == new PathString("/challenge-WeixinAuth")) 1448 | { 1449 | var provider = WeixinAuthDefaults.AuthenticationScheme; 1450 | string userId = "1234567890123456789012"; 1451 | // Request a redirect to the external login provider. 1452 | var redirectUrl = "/Account/ExternalLoginCallback?returnUrl=%2FHome%2FUserInfo"; 1453 | var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; 1454 | properties.Items["LoginProvider"] = provider; 1455 | if (userId != null) 1456 | { 1457 | properties.Items["XsrfId"] = userId; 1458 | } 1459 | await context.ChallengeAsync(WeixinAuthDefaults.AuthenticationScheme, properties); 1460 | } 1461 | else if (req.Path == new PathString("/tokens")) 1462 | { 1463 | var result = await context.AuthenticateAsync(TestExtensions.CookieAuthenticationScheme); 1464 | var tokens = result.Properties.GetTokens(); 1465 | await res.DescribeAsync(tokens); 1466 | } 1467 | else if (req.Path == new PathString("/me")) 1468 | { 1469 | await res.DescribeAsync(context.User); 1470 | } 1471 | else if (req.Path == new PathString("/authenticate")) 1472 | { 1473 | var result = await context.AuthenticateAsync(TestExtensions.CookieAuthenticationScheme); 1474 | await res.DescribeAsync(result.Principal); 1475 | } 1476 | else if (req.Path == new PathString("/authenticate-WeixinAuth")) 1477 | { 1478 | var result = await context.AuthenticateAsync(WeixinAuthDefaults.AuthenticationScheme); 1479 | await res.DescribeAsync(result?.Principal); 1480 | } 1481 | else if (req.Path == new PathString("/authenticate-twitter")) 1482 | { 1483 | var result = await context.AuthenticateAsync(TwitterDefaults.AuthenticationScheme); 1484 | await res.DescribeAsync(result?.Principal); 1485 | } 1486 | else if (req.Path == new PathString("/401")) 1487 | { 1488 | res.StatusCode = (int)HttpStatusCode.Unauthorized;// 401; 1489 | } 1490 | else if (req.Path == new PathString("/unauthorized")) 1491 | { 1492 | // Simulate Authorization failure 1493 | var result = await context.AuthenticateAsync(WeixinAuthDefaults.AuthenticationScheme); 1494 | await context.ChallengeAsync(WeixinAuthDefaults.AuthenticationScheme); 1495 | } 1496 | else if (req.Path == new PathString("/unauthorized-auto")) 1497 | { 1498 | var result = await context.AuthenticateAsync(WeixinAuthDefaults.AuthenticationScheme); 1499 | await context.ChallengeAsync(WeixinAuthDefaults.AuthenticationScheme); 1500 | } 1501 | else if (req.Path == new PathString("/signin")) 1502 | { 1503 | await Assert.ThrowsAsync(() => context.SignInAsync(WeixinAuthDefaults.AuthenticationScheme, new ClaimsPrincipal())); 1504 | } 1505 | else if (req.Path == new PathString("/signout")) 1506 | { 1507 | await Assert.ThrowsAsync(() => context.SignOutAsync(WeixinAuthDefaults.AuthenticationScheme)); 1508 | } 1509 | else if (req.Path == new PathString("/forbid")) 1510 | { 1511 | await context.ForbidAsync(WeixinAuthDefaults.AuthenticationScheme); 1512 | } 1513 | else if (testpath != null) 1514 | { 1515 | await testpath(context); 1516 | } 1517 | else 1518 | { 1519 | await next(); 1520 | } 1521 | }); 1522 | }) 1523 | .ConfigureServices(services => 1524 | { 1525 | services.AddTransient(); 1526 | services.AddAuthentication(TestExtensions.CookieAuthenticationScheme) 1527 | .AddCookie(TestExtensions.CookieAuthenticationScheme, o => o.ForwardChallenge = WeixinAuthDefaults.AuthenticationScheme) 1528 | .AddWeixinAuth(configureOptions) 1529 | .AddTwitter(o => 1530 | { 1531 | o.ConsumerKey = "Test Twitter ClientId"; 1532 | o.ConsumerSecret = "Test Twitter AppSecret"; 1533 | o.SignInScheme = TestExtensions.CookieAuthenticationScheme; 1534 | }); 1535 | }); 1536 | return new TestServer(builder); 1537 | } 1538 | 1539 | private class TestStateDataFormat : ISecureDataFormat 1540 | { 1541 | private AuthenticationProperties Data { get; set; } 1542 | 1543 | public string Protect(AuthenticationProperties data) 1544 | { 1545 | return "protected_state"; 1546 | } 1547 | 1548 | public string Protect(AuthenticationProperties data, string purpose) 1549 | { 1550 | throw new NotImplementedException(); 1551 | } 1552 | 1553 | public AuthenticationProperties Unprotect(string protectedText) 1554 | { 1555 | Assert.Equal("protected_state", protectedText); 1556 | var properties = new AuthenticationProperties(new Dictionary() 1557 | { 1558 | { ".xsrf", "corrilationId" }, 1559 | { "testkey", "testvalue" } 1560 | }); 1561 | properties.RedirectUri = "http://testhost/redirect"; 1562 | return properties; 1563 | } 1564 | 1565 | public AuthenticationProperties Unprotect(string protectedText, string purpose) 1566 | { 1567 | throw new NotImplementedException(); 1568 | } 1569 | } 1570 | } 1571 | } 1572 | --------------------------------------------------------------------------------