├── stack.yaml ├── .github ├── CODEOWNERS └── workflows │ ├── add-asana-comment.yml.bak │ ├── add-asana-comment.yml │ ├── release.yml │ └── ci.yml ├── stack-lts-21.25.yaml ├── test ├── Spec.hs └── URI │ └── ByteString │ └── ExtensionSpec.hs ├── stack-lts-16.31.yaml ├── stack-lts-18.28.yaml ├── stack-lts-19.33.yaml ├── stack-lts-20.26.yaml ├── .restyled.yaml ├── stack-hoauth2-2.0.yaml ├── stack-hoauth2-2.2.yaml ├── stack-hoauth2-2.3.yaml ├── Setup.lhs ├── renovate.json ├── .hlint.yaml ├── stack-hoauth2-2.6.yaml ├── .gitignore ├── stack-nightly.yaml ├── src ├── UnliftIO │ └── Except.hs ├── Yesod │ └── Auth │ │ ├── OAuth2 │ │ ├── Random.hs │ │ ├── Exception.hs │ │ ├── Spotify.hs │ │ ├── ORCID.hs │ │ ├── Upcase.hs │ │ ├── WordPressDotCom.hs │ │ ├── ClassLink.hs │ │ ├── AzureAD.hs │ │ ├── Twitch.hs │ │ ├── Auth0.hs │ │ ├── GitLab.hs │ │ ├── Bitbucket.hs │ │ ├── GitHub.hs │ │ ├── BattleNet.hs │ │ ├── Slack.hs │ │ ├── Nylas.hs │ │ ├── ErrorResponse.hs │ │ ├── Salesforce.hs │ │ ├── DispatchError.hs │ │ ├── Google.hs │ │ ├── AzureADv2.hs │ │ ├── EveOnline.hs │ │ ├── Prelude.hs │ │ └── Dispatch.hs │ │ └── OAuth2.hs ├── URI │ └── ByteString │ │ └── Extension.hs └── Network │ └── OAuth │ └── OAuth2 │ └── Compat.hs ├── stack.yaml.lock ├── fourmolu.yaml ├── stack-lts-21.25.yaml.lock ├── stack-lts-16.31.yaml.lock ├── stack-lts-18.28.yaml.lock ├── stack-lts-19.33.yaml.lock ├── stack-lts-20.26.yaml.lock ├── stack-hoauth2-2.0.yaml.lock ├── stack-hoauth2-2.2.yaml.lock ├── stack-hoauth2-2.3.yaml.lock ├── stack-hoauth2-2.6.yaml.lock ├── LICENSE ├── .env.example ├── Makefile ├── stack-nightly.yaml.lock ├── package.yaml ├── yesod-auth-oauth2.cabal ├── README.md ├── example └── Main.hs └── CHANGELOG.md /stack.yaml: -------------------------------------------------------------------------------- 1 | resolver: lts-22.12 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @freckle/backenders 2 | -------------------------------------------------------------------------------- /stack-lts-21.25.yaml: -------------------------------------------------------------------------------- 1 | resolver: lts-21.25 2 | -------------------------------------------------------------------------------- /test/Spec.hs: -------------------------------------------------------------------------------- 1 | {-# OPTIONS_GHC -F -pgmF hspec-discover #-} 2 | -------------------------------------------------------------------------------- /stack-lts-16.31.yaml: -------------------------------------------------------------------------------- 1 | resolver: lts-16.31 2 | extra-deps: 3 | - crypton-1.0.0 4 | -------------------------------------------------------------------------------- /stack-lts-18.28.yaml: -------------------------------------------------------------------------------- 1 | resolver: lts-18.28 2 | extra-deps: 3 | - crypton-1.0.0 4 | -------------------------------------------------------------------------------- /stack-lts-19.33.yaml: -------------------------------------------------------------------------------- 1 | resolver: lts-19.33 2 | extra-deps: 3 | - crypton-1.0.0 4 | -------------------------------------------------------------------------------- /stack-lts-20.26.yaml: -------------------------------------------------------------------------------- 1 | resolver: lts-20.26 2 | extra-deps: 3 | - crypton-1.0.0 4 | -------------------------------------------------------------------------------- /.restyled.yaml: -------------------------------------------------------------------------------- 1 | restylers: 2 | - fourmolu 3 | - "!stylish-haskell" 4 | - "*" 5 | -------------------------------------------------------------------------------- /stack-hoauth2-2.0.yaml: -------------------------------------------------------------------------------- 1 | resolver: lts-18.28 2 | extra-deps: 3 | - crypton-1.0.0 4 | - hoauth2-2.0.0 5 | -------------------------------------------------------------------------------- /stack-hoauth2-2.2.yaml: -------------------------------------------------------------------------------- 1 | resolver: nightly-2022-02-25 2 | extra-deps: 3 | - crypton-1.0.0 4 | - hoauth2-2.2.0 5 | -------------------------------------------------------------------------------- /stack-hoauth2-2.3.yaml: -------------------------------------------------------------------------------- 1 | resolver: nightly-2022-02-25 2 | extra-deps: 3 | - crypton-1.0.0 4 | - hoauth2-2.3.0 5 | -------------------------------------------------------------------------------- /Setup.lhs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env runhaskell 2 | 3 | > module Main where 4 | > import Distribution.Simple 5 | 6 | > main :: IO () 7 | > main = defaultMain 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>freckle/renovate-config" 5 | ], 6 | "minimumReleaseAge": "0 days" 7 | } 8 | -------------------------------------------------------------------------------- /.hlint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - ignore: 3 | # https://github.com/ndmitchell/hlint/issues/427 4 | name: Eta reduce 5 | within: authOAuth2 6 | - ignore: 7 | name: Redundant do 8 | within: spec 9 | -------------------------------------------------------------------------------- /stack-hoauth2-2.6.yaml: -------------------------------------------------------------------------------- 1 | resolver: nightly-2022-12-09 2 | extra-deps: 3 | - crypton-1.0.0 4 | - hoauth2-2.6.0 5 | allow-newer: true 6 | allow-newer-deps: 7 | - hoauth2 # allow newer memory and text 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .stack-work 2 | TAGS 3 | 4 | # OAuth keys configuration for the example 5 | .env* 6 | !.env.example 7 | 8 | # Created when running the example 9 | client_session_key.aes 10 | 11 | # Used by stack test --rerun 12 | TESTREPORT 13 | -------------------------------------------------------------------------------- /stack-nightly.yaml: -------------------------------------------------------------------------------- 1 | resolver: nightly-2024-02-27 2 | extra-deps: 3 | - yesod-auth-1.6.11.2 4 | 5 | # For yesod-auth 6 | - email-validate-2.3.2.19 7 | - yesod-form-1.7.6 8 | - yesod-1.6.2.1 9 | - cryptonite-0.30 10 | 11 | allow-newer: true 12 | allow-newer-deps: 13 | - email-validate 14 | -------------------------------------------------------------------------------- /src/UnliftIO/Except.hs: -------------------------------------------------------------------------------- 1 | {-# OPTIONS_GHC -Wno-orphans #-} 2 | 3 | module UnliftIO.Except () where 4 | 5 | import Control.Monad ((<=<)) 6 | import Control.Monad.Except (ExceptT (..), runExceptT) 7 | import UnliftIO 8 | 9 | instance (MonadUnliftIO m, Exception e) => MonadUnliftIO (ExceptT e m) where 10 | withRunInIO exceptToIO = ExceptT $ try $ do 11 | withRunInIO $ \runInIO -> 12 | exceptToIO (runInIO . (either throwIO pure <=< runExceptT)) 13 | -------------------------------------------------------------------------------- /.github/workflows/add-asana-comment.yml.bak: -------------------------------------------------------------------------------- 1 | name: Asana 2 | 3 | on: 4 | pull_request: 5 | types: [opened] 6 | 7 | jobs: 8 | link-asana-task: 9 | if: ${{ github.actor != 'dependabot[bot]' }} 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: Asana/create-app-attachment-github-action@v1.3 13 | id: postAttachment 14 | with: 15 | asana-secret: ${{ secrets.ASANA_API_ACCESS_KEY }} 16 | - run: echo "Status is ${{ steps.postAttachment.outputs.status }}" 17 | -------------------------------------------------------------------------------- /stack.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: [] 7 | snapshots: 8 | - completed: 9 | sha256: e2c529ccfb21501f98f639e056cbde50470b86256d9849d7a82d414ca23e4276 10 | size: 712898 11 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/22/12.yaml 12 | original: lts-22.12 13 | -------------------------------------------------------------------------------- /fourmolu.yaml: -------------------------------------------------------------------------------- 1 | indentation: 2 2 | column-limit: 80 # ignored until v12 / ghc-9.6 3 | function-arrows: leading 4 | comma-style: leading # default 5 | import-export-style: leading 6 | indent-wheres: false # default 7 | record-brace-space: true 8 | newlines-between-decls: 1 # default 9 | haddock-style: single-line 10 | let-style: mixed 11 | in-style: left-align 12 | single-constraint-parens: never # ignored until v12 / ghc-9.6 13 | unicode: never # default 14 | respectful: true # default 15 | fixities: [] # default 16 | -------------------------------------------------------------------------------- /stack-lts-21.25.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: [] 7 | snapshots: 8 | - completed: 9 | sha256: a81fb3877c4f9031e1325eb3935122e608d80715dc16b586eb11ddbff8671ecd 10 | size: 640086 11 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/21/25.yaml 12 | original: lts-21.25 13 | -------------------------------------------------------------------------------- /.github/workflows/add-asana-comment.yml: -------------------------------------------------------------------------------- 1 | name: Asana 2 | 3 | on: 4 | pull_request: 5 | types: [opened] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | link-asana-task: 12 | if: ${{ github.actor != 'dependabot[bot]' }} 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: Asana/create-app-attachment-github-action@v1.3 16 | id: postAttachment 17 | with: 18 | asana-secret: ${{ secrets.ASANA_API_ACCESS_KEY }} 19 | - run: echo "Status is ${{ steps.postAttachment.outputs.status }}" 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: main 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v5 12 | 13 | - id: tag 14 | uses: freckle/haskell-tag-action@v1 15 | 16 | - if: steps.tag.outputs.tag 17 | run: stack upload --pvp-bounds lower . 18 | env: 19 | HACKAGE_KEY: ${{ secrets.HACKAGE_UPLOAD_API_KEY }} 20 | 21 | # Use minimum LTS to set lowest lower bounds 22 | STACK_YAML: stack-lts-16.31.yaml 23 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/Random.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE TypeApplications #-} 2 | 3 | module Yesod.Auth.OAuth2.Random 4 | ( randomText 5 | ) where 6 | 7 | import Crypto.Random (MonadRandom, getRandomBytes) 8 | import Data.ByteArray.Encoding (Base (Base64), convertToBase) 9 | import Data.ByteString (ByteString) 10 | import Data.Text (Text) 11 | import Data.Text.Encoding (decodeUtf8) 12 | 13 | randomText 14 | :: MonadRandom m 15 | => Int 16 | -- ^ Size in Bytes (not necessarily characters) 17 | -> m Text 18 | randomText size = 19 | decodeUtf8 . convertToBase @ByteString Base64 <$> getRandomBytes size 20 | -------------------------------------------------------------------------------- /stack-lts-16.31.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: 7 | - completed: 8 | hackage: crypton-1.0.0@sha256:637e58581978c84ef1288d14fa9cac1d2905ef60e319924293bc11250aca882d,14527 9 | pantry-tree: 10 | sha256: 4b5e5511567c0fe735a224cb8b2b278e1caa79344f2940d030d169e69b1b81e1 11 | size: 23275 12 | original: 13 | hackage: crypton-1.0.0 14 | snapshots: 15 | - completed: 16 | sha256: 637fb77049b25560622a224845b7acfe81a09fdb6a96a3c75997a10b651667f6 17 | size: 534126 18 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/16/31.yaml 19 | original: lts-16.31 20 | -------------------------------------------------------------------------------- /stack-lts-18.28.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: 7 | - completed: 8 | hackage: crypton-1.0.0@sha256:637e58581978c84ef1288d14fa9cac1d2905ef60e319924293bc11250aca882d,14527 9 | pantry-tree: 10 | sha256: 4b5e5511567c0fe735a224cb8b2b278e1caa79344f2940d030d169e69b1b81e1 11 | size: 23275 12 | original: 13 | hackage: crypton-1.0.0 14 | snapshots: 15 | - completed: 16 | sha256: 428ec8d5ce932190d3cbe266b9eb3c175cd81e984babf876b64019e2cbe4ea68 17 | size: 590100 18 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/18/28.yaml 19 | original: lts-18.28 20 | -------------------------------------------------------------------------------- /stack-lts-19.33.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: 7 | - completed: 8 | hackage: crypton-1.0.0@sha256:637e58581978c84ef1288d14fa9cac1d2905ef60e319924293bc11250aca882d,14527 9 | pantry-tree: 10 | sha256: 4b5e5511567c0fe735a224cb8b2b278e1caa79344f2940d030d169e69b1b81e1 11 | size: 23275 12 | original: 13 | hackage: crypton-1.0.0 14 | snapshots: 15 | - completed: 16 | sha256: 6d1532d40621957a25bad5195bfca7938e8a06d923c91bc52aa0f3c41181f2d4 17 | size: 619204 18 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/19/33.yaml 19 | original: lts-19.33 20 | -------------------------------------------------------------------------------- /stack-lts-20.26.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: 7 | - completed: 8 | hackage: crypton-1.0.0@sha256:637e58581978c84ef1288d14fa9cac1d2905ef60e319924293bc11250aca882d,14527 9 | pantry-tree: 10 | sha256: 4b5e5511567c0fe735a224cb8b2b278e1caa79344f2940d030d169e69b1b81e1 11 | size: 23275 12 | original: 13 | hackage: crypton-1.0.0 14 | snapshots: 15 | - completed: 16 | sha256: 5a59b2a405b3aba3c00188453be172b85893cab8ebc352b1ef58b0eae5d248a2 17 | size: 650475 18 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/20/26.yaml 19 | original: lts-20.26 20 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/Exception.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveDataTypeable #-} 2 | 3 | module Yesod.Auth.OAuth2.Exception 4 | ( YesodOAuth2Exception (..) 5 | ) where 6 | 7 | import Control.Exception.Safe 8 | import Data.ByteString.Lazy (ByteString) 9 | import Data.Text (Text) 10 | 11 | data YesodOAuth2Exception 12 | = -- | HTTP error during OAuth2 handshake 13 | -- 14 | -- Plugin name and JSON-encoded @OAuth2Error@ from @hoauth2@. 15 | OAuth2Error Text ByteString 16 | | -- | User profile was not as expected 17 | -- 18 | -- Plugin name and Aeson parse error message. 19 | JSONDecodingError Text String 20 | | -- | Other error conditions 21 | -- 22 | -- Plugin name and error message. 23 | GenericError Text String 24 | deriving (Show, Typeable) 25 | 26 | instance Exception YesodOAuth2Exception 27 | -------------------------------------------------------------------------------- /stack-hoauth2-2.0.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: 7 | - completed: 8 | hackage: crypton-1.0.0@sha256:637e58581978c84ef1288d14fa9cac1d2905ef60e319924293bc11250aca882d,14527 9 | pantry-tree: 10 | sha256: 4b5e5511567c0fe735a224cb8b2b278e1caa79344f2940d030d169e69b1b81e1 11 | size: 23275 12 | original: 13 | hackage: crypton-1.0.0 14 | - completed: 15 | hackage: hoauth2-2.0.0@sha256:4686d776272d4c57d3c8dbeb9e58b04afe4d2b410382011bd78a3d2bfb08a3fe,5662 16 | pantry-tree: 17 | sha256: 291b3dd90854ef44f270519ec17e34b6778f8430f6d6517bd67b0128bd549553 18 | size: 2171 19 | original: 20 | hackage: hoauth2-2.0.0 21 | snapshots: 22 | - completed: 23 | sha256: 428ec8d5ce932190d3cbe266b9eb3c175cd81e984babf876b64019e2cbe4ea68 24 | size: 590100 25 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/18/28.yaml 26 | original: lts-18.28 27 | -------------------------------------------------------------------------------- /stack-hoauth2-2.2.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: 7 | - completed: 8 | hackage: crypton-1.0.0@sha256:637e58581978c84ef1288d14fa9cac1d2905ef60e319924293bc11250aca882d,14527 9 | pantry-tree: 10 | sha256: 4b5e5511567c0fe735a224cb8b2b278e1caa79344f2940d030d169e69b1b81e1 11 | size: 23275 12 | original: 13 | hackage: crypton-1.0.0 14 | - completed: 15 | hackage: hoauth2-2.2.0@sha256:83a96156717d9e2c93394b35bef4151f82b90dc88b83d0e35c0bf1158bd41c6c,2801 16 | pantry-tree: 17 | sha256: d6e2d12e0e66eb9392301ec97d50677afb71608568f3664eb466a4451c66ba59 18 | size: 593 19 | original: 20 | hackage: hoauth2-2.2.0 21 | snapshots: 22 | - completed: 23 | sha256: b18614ab8986a4ba6d469921a2c18decab244af78309effa3d2dab85dbdfef80 24 | size: 611886 25 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/nightly/2022/2/25.yaml 26 | original: nightly-2022-02-25 27 | -------------------------------------------------------------------------------- /stack-hoauth2-2.3.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: 7 | - completed: 8 | hackage: crypton-1.0.0@sha256:637e58581978c84ef1288d14fa9cac1d2905ef60e319924293bc11250aca882d,14527 9 | pantry-tree: 10 | sha256: 4b5e5511567c0fe735a224cb8b2b278e1caa79344f2940d030d169e69b1b81e1 11 | size: 23275 12 | original: 13 | hackage: crypton-1.0.0 14 | - completed: 15 | hackage: hoauth2-2.3.0@sha256:213744356007a4686ff3bb72105843d478bc0ba6229659429cbe241a99f55095,2816 16 | pantry-tree: 17 | sha256: e559c811165a2e75cfe649b68396466b3bd0b6a5353a9d6476605e6a40e0eb37 18 | size: 594 19 | original: 20 | hackage: hoauth2-2.3.0 21 | snapshots: 22 | - completed: 23 | sha256: b18614ab8986a4ba6d469921a2c18decab244af78309effa3d2dab85dbdfef80 24 | size: 611886 25 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/nightly/2022/2/25.yaml 26 | original: nightly-2022-02-25 27 | -------------------------------------------------------------------------------- /stack-hoauth2-2.6.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: 7 | - completed: 8 | hackage: crypton-1.0.0@sha256:637e58581978c84ef1288d14fa9cac1d2905ef60e319924293bc11250aca882d,14527 9 | pantry-tree: 10 | sha256: 4b5e5511567c0fe735a224cb8b2b278e1caa79344f2940d030d169e69b1b81e1 11 | size: 23275 12 | original: 13 | hackage: crypton-1.0.0 14 | - completed: 15 | hackage: hoauth2-2.6.0@sha256:168321df73bf75dc7cdda8e72725e9f3f624a9776b1fe59ae46c29c45029dc5d,2262 16 | pantry-tree: 17 | sha256: 5d39759b171cfaaf5842069a5548c2c1a41c0978645d018da7df713a186192b5 18 | size: 859 19 | original: 20 | hackage: hoauth2-2.6.0 21 | snapshots: 22 | - completed: 23 | sha256: b77e2c2b7988ed34cd317c01eb1958a8b8c234c3cc17e44077616c212959bed0 24 | size: 558756 25 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/nightly/2022/12/9.yaml 26 | original: nightly-2022-12-09 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Renaissance Learning Inc 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 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: main 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | generate: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v5 20 | - id: generate 21 | uses: freckle/stack-action/generate-matrix@v5 22 | outputs: 23 | stack-yamls: ${{ steps.generate.outputs.stack-yamls }} 24 | 25 | test: 26 | needs: generate 27 | strategy: 28 | matrix: 29 | stack-yaml: ${{ fromJSON(needs.generate.outputs.stack-yamls) }} 30 | fail-fast: false 31 | 32 | runs-on: ubuntu-latest 33 | 34 | steps: 35 | - uses: actions/checkout@v5 36 | - uses: freckle/stack-action@v5 37 | with: 38 | stack-build-arguments: --flag yesod-auth-oauth2:example 39 | env: 40 | STACK_YAML: ${{ matrix.stack-yaml }} 41 | 42 | lint: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v5 46 | - uses: haskell-actions/hlint-setup@v2 47 | - uses: haskell-actions/hlint-run@v2 48 | with: 49 | fail-on: warning 50 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/Spotify.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | -- | 4 | -- 5 | -- OAuth2 plugin for http://spotify.com 6 | module Yesod.Auth.OAuth2.Spotify 7 | ( oauth2Spotify 8 | ) where 9 | 10 | import Yesod.Auth.OAuth2.Prelude 11 | 12 | newtype User = User Text 13 | 14 | instance FromJSON User where 15 | parseJSON = withObject "User" $ \o -> User <$> o .: "id" 16 | 17 | pluginName :: Text 18 | pluginName = "spotify" 19 | 20 | oauth2Spotify :: YesodAuth m => [Text] -> Text -> Text -> AuthPlugin m 21 | oauth2Spotify scopes clientId clientSecret = 22 | authOAuth2 pluginName oauth2 $ \manager token -> do 23 | (User userId, userResponse) <- 24 | authGetProfile 25 | pluginName 26 | manager 27 | token 28 | "https://api.spotify.com/v1/me" 29 | 30 | pure 31 | Creds 32 | { credsPlugin = pluginName 33 | , credsIdent = userId 34 | , credsExtra = setExtra token userResponse 35 | } 36 | where 37 | oauth2 = 38 | OAuth2 39 | { oauth2ClientId = clientId 40 | , oauth2ClientSecret = Just clientSecret 41 | , oauth2AuthorizeEndpoint = 42 | "https://accounts.spotify.com/authorize" 43 | `withQuery` [scopeParam " " scopes] 44 | , oauth2TokenEndpoint = "https://accounts.spotify.com/api/token" 45 | , oauth2RedirectUri = Nothing 46 | } 47 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # shellcheck disable=SC2034 2 | # 3 | # Copy this file to .env and update the credentials for the providers you are 4 | # trying to test. These variables must all have non-empty values for the 5 | # application to boot, but you only need to set real values for those Providers 6 | # you plan to try. 7 | # 8 | ### 9 | 10 | AUTH0_HOST=x 11 | AUTH0_CLIENT_ID=x 12 | AUTH0_CLIENT_SECRET=x 13 | 14 | AZURE_AD_CLIENT_ID=x 15 | AZURE_AD_CLIENT_SECRET=x 16 | 17 | AZURE_ADV2_TENANT_ID=x 18 | AZURE_ADV2_CLIENT_ID=x 19 | AZURE_ADV2_CLIENT_SECRET=x 20 | 21 | BATTLE_NET_CLIENT_ID=x 22 | BATTLE_NET_CLIENT_SECRET=x 23 | 24 | BITBUCKET_CLIENT_ID=x 25 | BITBUCKET_CLIENT_SECRET=x 26 | 27 | CLASSLINK_CLIENT_ID=x 28 | CLASSLINK_CLIENT_SECRET=x 29 | 30 | EVE_ONLINE_CLIENT_ID=x 31 | EVE_ONLINE_CLIENT_SECRET=x 32 | 33 | GITHUB_CLIENT_ID=x 34 | GITHUB_CLIENT_SECRET=x 35 | 36 | GITLAB_CLIENT_ID=x 37 | GITLAB_CLIENT_SECRET=x 38 | 39 | GOOGLE_CLIENT_ID=x 40 | GOOGLE_CLIENT_SECRET=x 41 | 42 | NYLAS_CLIENT_ID=x 43 | NYLAS_CLIENT_SECRET=x 44 | 45 | SALES_FORCE_CLIENT_ID=x 46 | SALES_FORCE_CLIENT_SECRET=x 47 | 48 | SLACK_CLIENT_ID=x 49 | SLACK_CLIENT_SECRET=x 50 | 51 | SPOTIFY_CLIENT_ID=x 52 | SPOTIFY_CLIENT_SECRET=x 53 | 54 | TWITCH_CLIENT_ID=x 55 | TWITCH_CLIENT_SECRET=x 56 | 57 | UPCASE_CLIENT_ID=x 58 | UPCASE_CLIENT_SECRET=x 59 | 60 | WORDPRESS_DOT_COM_CLIENT_ID=x 61 | WORDPRESS_DOT_COM_CLIENT_SECRET=x 62 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/ORCID.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | module Yesod.Auth.OAuth2.ORCID 4 | ( oauth2ORCID 5 | ) where 6 | 7 | import qualified Data.Text as T 8 | import Yesod.Auth.OAuth2.Prelude 9 | 10 | pluginName :: Text 11 | pluginName = "orcid" 12 | 13 | newtype User = User Text 14 | 15 | instance FromJSON User where 16 | parseJSON = withObject "User" $ \o -> User <$> o .: "sub" 17 | 18 | oauth2ORCID 19 | :: YesodAuth m 20 | => Text 21 | -- ^ Client Id 22 | -> Text 23 | -- ^ Client Secret 24 | -> AuthPlugin m 25 | oauth2ORCID clientId clientSecret = 26 | authOAuth2 pluginName oauth2 $ \manager token -> do 27 | (User userId, userResponse) <- 28 | authGetProfile 29 | pluginName 30 | manager 31 | token 32 | "https://orcid.org/oauth/userinfo" 33 | 34 | pure 35 | Creds 36 | { credsPlugin = pluginName 37 | , credsIdent = T.pack $ show userId 38 | , credsExtra = setExtra token userResponse 39 | } 40 | where 41 | oauth2 = 42 | OAuth2 43 | { oauth2ClientId = clientId 44 | , oauth2ClientSecret = Just clientSecret 45 | , oauth2AuthorizeEndpoint = 46 | "https://orcid.org/oauth/authorize" 47 | `withQuery` [scopeParam " " ["openid"]] 48 | , oauth2TokenEndpoint = "https://orcid.org/oauth/token" 49 | , oauth2RedirectUri = Nothing 50 | } 51 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: setup setup.lint dependencies build test lint 2 | 3 | .PHONY: setup 4 | setup: 5 | stack setup 6 | 7 | .PHONY: setup.lint 8 | setup.lint: 9 | stack install --copy-compiler-tool hlint 10 | 11 | .PHONY: setup.tools 12 | setup.tools: 13 | stack install --copy-compiler-tool brittany stylish-haskell fast-tags 14 | 15 | .PHONY: dependencies 16 | dependencies: 17 | stack build \ 18 | --flag yesod-auth-oauth2:example \ 19 | --dependencies-only --test --no-run-tests 20 | 21 | .PHONY: build 22 | build: 23 | stack build \ 24 | --flag yesod-auth-oauth2:example \ 25 | --fast --pedantic --test --no-run-tests 26 | 27 | .PHONY: test 28 | test: 29 | stack build \ 30 | --flag yesod-auth-oauth2:example \ 31 | --fast --pedantic --test 32 | 33 | .PHONY: watch 34 | watch: 35 | stack build \ 36 | --flag yesod-auth-oauth2:example \ 37 | --fast --pedantic --test --file-watch 38 | 39 | 40 | .PHONY: lint 41 | lint: 42 | stack exec hlint src test 43 | 44 | .PHONY: nightly 45 | nightly: 46 | stack setup --resolver nightly 47 | stack build --resolver nightly \ 48 | --test --no-run-tests --bench --no-run-benchmarks \ 49 | --dependencies-only 50 | stack build --resolver nightly \ 51 | --test --no-run-tests --bench --no-run-benchmarks \ 52 | --fast --pedantic 53 | 54 | .PHONY: example 55 | example: build 56 | stack exec yesod-auth-oauth2-example 57 | 58 | .PHONY: clean 59 | clean: 60 | stack clean 61 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/Upcase.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | -- | 4 | -- 5 | -- OAuth2 plugin for http://upcase.com 6 | -- 7 | -- * Authenticates against upcase 8 | -- * Uses upcase user id as credentials identifier 9 | module Yesod.Auth.OAuth2.Upcase 10 | ( oauth2Upcase 11 | ) where 12 | 13 | import Yesod.Auth.OAuth2.Prelude 14 | 15 | import qualified Data.Text as T 16 | 17 | newtype User = User Int 18 | 19 | instance FromJSON User where 20 | parseJSON = withObject "User" $ \root -> do 21 | o <- root .: "user" 22 | User <$> o .: "id" 23 | 24 | pluginName :: Text 25 | pluginName = "upcase" 26 | 27 | oauth2Upcase :: YesodAuth m => Text -> Text -> AuthPlugin m 28 | oauth2Upcase clientId clientSecret = 29 | authOAuth2 pluginName oauth2 $ \manager token -> do 30 | (User userId, userResponse) <- 31 | authGetProfile 32 | pluginName 33 | manager 34 | token 35 | "http://upcase.com/api/v1/me.json" 36 | 37 | pure 38 | Creds 39 | { credsPlugin = pluginName 40 | , credsIdent = T.pack $ show userId 41 | , credsExtra = setExtra token userResponse 42 | } 43 | where 44 | oauth2 = 45 | OAuth2 46 | { oauth2ClientId = clientId 47 | , oauth2ClientSecret = Just clientSecret 48 | , oauth2AuthorizeEndpoint = "http://upcase.com/oauth/authorize" 49 | , oauth2TokenEndpoint = "http://upcase.com/oauth/token" 50 | , oauth2RedirectUri = Nothing 51 | } 52 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/WordPressDotCom.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | module Yesod.Auth.OAuth2.WordPressDotCom 4 | ( oauth2WordPressDotCom 5 | ) where 6 | 7 | import qualified Data.Text as T 8 | import Yesod.Auth.OAuth2.Prelude 9 | 10 | pluginName :: Text 11 | pluginName = "WordPress.com" 12 | 13 | newtype WpUser = WpUser Int 14 | 15 | instance FromJSON WpUser where 16 | parseJSON = withObject "WpUser" $ \o -> WpUser <$> o .: "ID" 17 | 18 | oauth2WordPressDotCom 19 | :: YesodAuth m 20 | => Text 21 | -- ^ Client Id 22 | -> Text 23 | -- ^ Client Secret 24 | -> AuthPlugin m 25 | oauth2WordPressDotCom clientId clientSecret = 26 | authOAuth2 pluginName oauth2 $ \manager token -> do 27 | (WpUser userId, userResponse) <- 28 | authGetProfile 29 | pluginName 30 | manager 31 | token 32 | "https://public-api.wordpress.com/rest/v1/me/" 33 | 34 | pure 35 | Creds 36 | { credsPlugin = pluginName 37 | , credsIdent = T.pack $ show userId 38 | , credsExtra = setExtra token userResponse 39 | } 40 | where 41 | oauth2 = 42 | OAuth2 43 | { oauth2ClientId = clientId 44 | , oauth2ClientSecret = Just clientSecret 45 | , oauth2AuthorizeEndpoint = 46 | "https://public-api.wordpress.com/oauth2/authorize" 47 | `withQuery` [scopeParam "," ["auth"]] 48 | , oauth2TokenEndpoint = "https://public-api.wordpress.com/oauth2/token" 49 | , oauth2RedirectUri = Nothing 50 | } 51 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/ClassLink.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | module Yesod.Auth.OAuth2.ClassLink 4 | ( oauth2ClassLink 5 | , oauth2ClassLinkScoped 6 | ) where 7 | 8 | import Yesod.Auth.OAuth2.Prelude 9 | 10 | import qualified Data.Text as T 11 | 12 | newtype User = User Int 13 | 14 | instance FromJSON User where 15 | parseJSON = withObject "User" $ \o -> User <$> o .: "UserId" 16 | 17 | pluginName :: Text 18 | pluginName = "classlink" 19 | 20 | defaultScopes :: [Text] 21 | defaultScopes = ["profile", "oneroster"] 22 | 23 | oauth2ClassLink :: YesodAuth m => Text -> Text -> AuthPlugin m 24 | oauth2ClassLink = oauth2ClassLinkScoped defaultScopes 25 | 26 | oauth2ClassLinkScoped :: YesodAuth m => [Text] -> Text -> Text -> AuthPlugin m 27 | oauth2ClassLinkScoped scopes clientId clientSecret = 28 | authOAuth2 pluginName oauth2 $ \manager token -> do 29 | (User userId, userResponse) <- 30 | authGetProfile 31 | pluginName 32 | manager 33 | token 34 | "https://nodeapi.classlink.com/v2/my/info" 35 | 36 | pure 37 | Creds 38 | { credsPlugin = pluginName 39 | , credsIdent = T.pack $ show userId 40 | , credsExtra = setExtra token userResponse 41 | } 42 | where 43 | oauth2 = 44 | OAuth2 45 | { oauth2ClientId = clientId 46 | , oauth2ClientSecret = Just clientSecret 47 | , oauth2AuthorizeEndpoint = 48 | "https://launchpad.classlink.com/oauth2/v2/auth" 49 | `withQuery` [scopeParam "," scopes] 50 | , oauth2TokenEndpoint = "https://launchpad.classlink.com/oauth2/v2/token" 51 | , oauth2RedirectUri = Nothing 52 | } 53 | -------------------------------------------------------------------------------- /stack-nightly.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: 7 | - completed: 8 | hackage: yesod-auth-1.6.11.2@sha256:c580afaccc311ad90fc8a90210b5cf998d95350ddffb622e45282d9b640cf519,3108 9 | pantry-tree: 10 | sha256: 58e36ca675fc01109915342d4df9cb2680fd37ff20919d84694cdd1d52eb7dc3 11 | size: 1013 12 | original: 13 | hackage: yesod-auth-1.6.11.2 14 | - completed: 15 | hackage: email-validate-2.3.2.19@sha256:206a835fe99a79f2360c576e3594d8add82d2f1e001d58b49a60f60233da2bf5,1380 16 | pantry-tree: 17 | sha256: 391475ca7c84d59b1d8de362856acd2f56321d29d54ffcd05ed2a170d4e62692 18 | size: 419 19 | original: 20 | hackage: email-validate-2.3.2.19 21 | - completed: 22 | hackage: yesod-form-1.7.6@sha256:42219f2c4feaa2de32280c0b8f98db818f993152693a79960808fd3001a4c0e2,3434 23 | pantry-tree: 24 | sha256: 8a48fc861796a61cf6abd2087fedd86844ba5c3e11c89c74d2d04c4656d69997 25 | size: 1797 26 | original: 27 | hackage: yesod-form-1.7.6 28 | - completed: 29 | hackage: yesod-1.6.2.1@sha256:504bc888257dae9bb40f4cd1e1110c4e80dfe7b867e8e207b22c4ca4dbd87f9a,2030 30 | pantry-tree: 31 | sha256: 5a78fa0c6e7dcb46c9acd687ab9409c8fcda0d59b930e2a93e5dc44128c740ec 32 | size: 618 33 | original: 34 | hackage: yesod-1.6.2.1 35 | snapshots: 36 | - completed: 37 | sha256: e596f5467d31095fd7b9f750e82aeffe012e38e795c13e1cdc945c9cab928085 38 | size: 604415 39 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/nightly/2024/2/27.yaml 40 | original: nightly-2024-02-27 41 | -------------------------------------------------------------------------------- /src/URI/ByteString/Extension.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE FlexibleInstances #-} 2 | {-# OPTIONS_GHC -fno-warn-orphans #-} 3 | 4 | module URI.ByteString.Extension where 5 | 6 | import Data.ByteString (ByteString) 7 | import Data.String (IsString (..)) 8 | import Data.Text (Text) 9 | import Data.Text.Encoding (decodeUtf8, encodeUtf8) 10 | import Lens.Micro 11 | 12 | import qualified Data.ByteString.Char8 as C8 13 | 14 | import URI.ByteString 15 | 16 | instance IsString Scheme where 17 | fromString = Scheme . fromString 18 | 19 | instance IsString Host where 20 | fromString = Host . fromString 21 | 22 | instance IsString (URIRef Absolute) where 23 | fromString = 24 | either (error . show) id . parseURI strictURIParserOptions . C8.pack 25 | 26 | instance IsString (URIRef Relative) where 27 | fromString = 28 | either (error . show) id . parseRelativeRef strictURIParserOptions . C8.pack 29 | 30 | fromText :: Text -> Maybe URI 31 | fromText = 32 | either (const Nothing) Just . parseURI strictURIParserOptions . encodeUtf8 33 | 34 | unsafeFromText :: Text -> URI 35 | unsafeFromText = 36 | either (error . show) id . parseURI strictURIParserOptions . encodeUtf8 37 | 38 | toText :: URI -> Text 39 | toText = decodeUtf8 . serializeURIRef' 40 | 41 | fromRelative :: Scheme -> Host -> RelativeRef -> URI 42 | fromRelative s h = flip withHost h . toAbsolute s 43 | 44 | withHost :: URIRef a -> Host -> URIRef a 45 | withHost u h = 46 | u 47 | & authorityL 48 | %~ maybe 49 | (Just $ Authority Nothing h Nothing) 50 | (\a -> Just $ a & authorityHostL .~ h) 51 | 52 | withPath :: URIRef a -> ByteString -> URIRef a 53 | withPath u p = u & pathL .~ p 54 | 55 | withQuery :: URIRef a -> [(ByteString, ByteString)] -> URIRef a 56 | withQuery u q = u & (queryL . queryPairsL) %~ (++ q) 57 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/AzureAD.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | -- | 4 | -- 5 | -- OAuth2 plugin for Azure AD. 6 | -- 7 | -- * Authenticates against Azure AD 8 | -- * Uses email as credentials identifier 9 | module Yesod.Auth.OAuth2.AzureAD 10 | ( oauth2AzureAD 11 | , oauth2AzureADScoped 12 | ) where 13 | 14 | import Yesod.Auth.OAuth2.Prelude 15 | import Prelude 16 | 17 | newtype User = User Text 18 | 19 | instance FromJSON User where 20 | parseJSON = withObject "User" $ \o -> User <$> o .: "mail" 21 | 22 | pluginName :: Text 23 | pluginName = "azuread" 24 | 25 | defaultScopes :: [Text] 26 | defaultScopes = ["openid", "profile"] 27 | 28 | oauth2AzureAD :: YesodAuth m => Text -> Text -> AuthPlugin m 29 | oauth2AzureAD = oauth2AzureADScoped defaultScopes 30 | 31 | oauth2AzureADScoped :: YesodAuth m => [Text] -> Text -> Text -> AuthPlugin m 32 | oauth2AzureADScoped scopes clientId clientSecret = 33 | authOAuth2 pluginName oauth2 $ \manager token -> do 34 | (User userId, userResponse) <- 35 | authGetProfile 36 | pluginName 37 | manager 38 | token 39 | "https://graph.microsoft.com/v1.0/me" 40 | 41 | pure 42 | Creds 43 | { credsPlugin = pluginName 44 | , credsIdent = userId 45 | , credsExtra = setExtra token userResponse 46 | } 47 | where 48 | oauth2 = 49 | OAuth2 50 | { oauth2ClientId = clientId 51 | , oauth2ClientSecret = Just clientSecret 52 | , oauth2AuthorizeEndpoint = 53 | "https://login.windows.net/common/oauth2/authorize" 54 | `withQuery` [ scopeParam "," scopes 55 | , ("resource", "https://graph.microsoft.com") 56 | ] 57 | , oauth2TokenEndpoint = "https://login.windows.net/common/oauth2/token" 58 | , oauth2RedirectUri = Nothing 59 | } 60 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/Twitch.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | -- | 4 | -- 5 | -- OAuth2 plugin for http://twitch.tv 6 | -- 7 | -- * Authenticates against twitch 8 | -- * Uses twitch user id as credentials identifier 9 | module Yesod.Auth.OAuth2.Twitch 10 | ( oauth2Twitch 11 | , oauth2TwitchScoped 12 | ) where 13 | 14 | import Yesod.Auth.OAuth2.Prelude 15 | 16 | import qualified Data.Text.Encoding as T 17 | 18 | newtype User = User Text 19 | 20 | instance FromJSON User where 21 | parseJSON = withObject "User" $ \o -> User <$> o .: "user_id" 22 | 23 | pluginName :: Text 24 | pluginName = "twitch" 25 | 26 | defaultScopes :: [Text] 27 | defaultScopes = ["user:read:email"] 28 | 29 | oauth2Twitch :: YesodAuth m => Text -> Text -> AuthPlugin m 30 | oauth2Twitch = oauth2TwitchScoped defaultScopes 31 | 32 | oauth2TwitchScoped :: YesodAuth m => [Text] -> Text -> Text -> AuthPlugin m 33 | oauth2TwitchScoped scopes clientId clientSecret = 34 | authOAuth2 pluginName oauth2 $ \manager token -> do 35 | (User userId, userResponse) <- 36 | authGetProfile 37 | pluginName 38 | manager 39 | token 40 | "https://id.twitch.tv/oauth2/validate" 41 | 42 | pure 43 | Creds 44 | { credsPlugin = pluginName 45 | , credsIdent = userId 46 | , credsExtra = setExtra token userResponse 47 | } 48 | where 49 | oauth2 = 50 | OAuth2 51 | { oauth2ClientId = clientId 52 | , oauth2ClientSecret = Just clientSecret 53 | , oauth2AuthorizeEndpoint = 54 | "https://id.twitch.tv/oauth2/authorize" 55 | `withQuery` [scopeParam " " scopes] 56 | , oauth2TokenEndpoint = 57 | "https://id.twitch.tv/oauth2/token" 58 | `withQuery` [ ("client_id", T.encodeUtf8 clientId) 59 | , ("client_secret", T.encodeUtf8 clientSecret) 60 | ] 61 | , oauth2RedirectUri = Nothing 62 | } 63 | -------------------------------------------------------------------------------- /package.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: yesod-auth-oauth2 3 | version: 0.7.4.0 4 | synopsis: OAuth 2.0 authentication plugins 5 | description: Library to authenticate with OAuth 2.0 for Yesod web applications. 6 | category: Web 7 | author: 8 | - Tom Streller 9 | - Patrick Brisbin 10 | - Freckle Engineering 11 | license: MIT 12 | maintainer: engineering@freckle.com 13 | github: freckle/yesod-auth-oauth2 14 | homepage: http://github.com/freckle/yesod-auth-oauth2 15 | 16 | extra-doc-files: 17 | - README.md 18 | - CHANGELOG.md 19 | 20 | ghc-options: -Wall 21 | 22 | dependencies: 23 | - base >=4.9.0.0 && <5 24 | 25 | library: 26 | source-dirs: src 27 | dependencies: 28 | - aeson >=0.6 29 | - bytestring >=0.9.1.4 30 | - crypton 31 | - errors 32 | - hoauth2 >=1.11.0 33 | - http-client >=0.4.0 34 | - http-conduit >=2.0 35 | - http-types >=0.8 36 | - memory 37 | - microlens 38 | - mtl 39 | - safe-exceptions 40 | - text >=0.7 41 | - transformers 42 | - uri-bytestring 43 | - yesod-auth >=1.6.0 44 | - yesod-core >=1.6.0 45 | - unliftio 46 | 47 | executables: 48 | yesod-auth-oauth2-example: 49 | main: Main.hs 50 | source-dirs: example 51 | ghc-options: 52 | - -threaded 53 | - -rtsopts 54 | - -with-rtsopts=-N 55 | dependencies: 56 | - yesod-auth-oauth2 57 | - aeson >=0.6 58 | - aeson-pretty 59 | - bytestring >=0.9.1.4 60 | - containers >=0.6.0.1 61 | - http-conduit >=2.0 62 | - load-env 63 | - text >=0.7 64 | - warp 65 | - yesod 66 | - yesod-auth >=1.6.0 67 | when: 68 | - condition: ! "!(flag(example))" 69 | buildable: false 70 | 71 | tests: 72 | test: 73 | main: Spec.hs 74 | source-dirs: test 75 | dependencies: 76 | - yesod-auth-oauth2 77 | - hspec 78 | - uri-bytestring 79 | 80 | flags: 81 | example: 82 | description: Build the example application 83 | manual: false 84 | default: false 85 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/Auth0.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | -- | 4 | -- OAuth2 plugin for 5 | -- 6 | -- * Authenticates against specific auth0 tenant 7 | -- * Uses Auth0 user id (a.k.a [sub](https://auth0.com/docs/api/authentication#get-user-info)) as credentials identifier 8 | module Yesod.Auth.OAuth2.Auth0 9 | ( oauth2Auth0HostScopes 10 | , oauth2Auth0Host 11 | , defaultAuth0Scopes 12 | ) where 13 | 14 | import Data.Aeson as Aeson 15 | import qualified Data.Text as T 16 | import Yesod.Auth.OAuth2.Prelude 17 | import Prelude 18 | 19 | -- | https://auth0.com/docs/api/authentication#get-user-info 20 | newtype User = User T.Text 21 | 22 | instance FromJSON User where 23 | parseJSON = withObject "User" $ \o -> User <$> o .: "sub" 24 | 25 | -- | https://auth0.com/docs/get-started/apis/scopes/openid-connect-scopes#standard-claims 26 | defaultAuth0Scopes :: [Text] 27 | defaultAuth0Scopes = ["openid"] 28 | 29 | pluginName :: Text 30 | pluginName = "auth0" 31 | 32 | oauth2Auth0Host :: YesodAuth m => URI -> Text -> Text -> AuthPlugin m 33 | oauth2Auth0Host host = oauth2Auth0HostScopes host defaultAuth0Scopes 34 | 35 | oauth2Auth0HostScopes 36 | :: YesodAuth m => URI -> [Text] -> Text -> Text -> AuthPlugin m 37 | oauth2Auth0HostScopes host scopes clientId clientSecret = 38 | authOAuth2 pluginName oauth2 $ \manager token -> do 39 | (User uid, userResponse) <- 40 | authGetProfile 41 | pluginName 42 | manager 43 | token 44 | (host `withPath` "/userinfo") 45 | pure 46 | Creds 47 | { credsPlugin = pluginName 48 | , credsIdent = uid 49 | , credsExtra = setExtra token userResponse 50 | } 51 | where 52 | oauth2 = 53 | OAuth2 54 | { oauth2ClientId = clientId 55 | , oauth2ClientSecret = Just clientSecret 56 | , oauth2AuthorizeEndpoint = 57 | host `withPath` "/authorize" `withQuery` [scopeParam " " scopes] 58 | , oauth2TokenEndpoint = host `withPath` "/oauth/token" 59 | , oauth2RedirectUri = Nothing 60 | } 61 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/GitLab.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | module Yesod.Auth.OAuth2.GitLab 4 | ( oauth2GitLab 5 | , oauth2GitLabHostScopes 6 | , defaultHost 7 | , defaultScopes 8 | ) where 9 | 10 | import Yesod.Auth.OAuth2.Prelude 11 | 12 | import qualified Data.Text as T 13 | 14 | newtype User = User Int 15 | 16 | instance FromJSON User where 17 | parseJSON = withObject "User" $ \o -> User <$> o .: "id" 18 | 19 | pluginName :: Text 20 | pluginName = "gitlab" 21 | 22 | defaultHost :: URI 23 | defaultHost = "https://gitlab.com" 24 | 25 | defaultScopes :: [Text] 26 | defaultScopes = ["read_user"] 27 | 28 | -- | Authorize with @gitlab.com@ and @[\"read_user\"]@ 29 | -- 30 | -- To customize either of these values, use @'oauth2GitLabHostScopes'@ and pass 31 | -- the default for the argument not being customized. Note that we require at 32 | -- least @read_user@, so we can request the credentials identifier. 33 | -- 34 | -- > oauth2GitLabHostScopes defaultHost ["api", "read_user"] 35 | -- > oauth2GitLabHostScopes "https://gitlab.example.com" defaultScopes 36 | oauth2GitLab :: YesodAuth m => Text -> Text -> AuthPlugin m 37 | oauth2GitLab = oauth2GitLabHostScopes defaultHost defaultScopes 38 | 39 | oauth2GitLabHostScopes 40 | :: YesodAuth m => URI -> [Text] -> Text -> Text -> AuthPlugin m 41 | oauth2GitLabHostScopes host scopes clientId clientSecret = 42 | authOAuth2 pluginName oauth2 $ \manager token -> do 43 | (User userId, userResponse) <- 44 | authGetProfile pluginName manager token $ host `withPath` "/api/v4/user" 45 | 46 | pure 47 | Creds 48 | { credsPlugin = pluginName 49 | , credsIdent = T.pack $ show userId 50 | , credsExtra = setExtra token userResponse 51 | } 52 | where 53 | oauth2 = 54 | OAuth2 55 | { oauth2ClientId = clientId 56 | , oauth2ClientSecret = Just clientSecret 57 | , oauth2AuthorizeEndpoint = 58 | host `withPath` "/oauth/authorize" `withQuery` [scopeParam " " scopes] 59 | , oauth2TokenEndpoint = host `withPath` "/oauth/token" 60 | , oauth2RedirectUri = Nothing 61 | } 62 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/Bitbucket.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | -- | 4 | -- 5 | -- OAuth2 plugin for http://bitbucket.com 6 | -- 7 | -- * Authenticates against bitbucket 8 | -- * Uses bitbucket uuid as credentials identifier 9 | module Yesod.Auth.OAuth2.Bitbucket 10 | ( oauth2Bitbucket 11 | , oauth2BitbucketScoped 12 | ) where 13 | 14 | import Yesod.Auth.OAuth2.Prelude 15 | 16 | import qualified Data.Text as T 17 | 18 | newtype User = User Text 19 | 20 | instance FromJSON User where 21 | parseJSON = withObject "User" $ \o -> User <$> o .: "uuid" 22 | 23 | pluginName :: Text 24 | pluginName = "bitbucket" 25 | 26 | defaultScopes :: [Text] 27 | defaultScopes = ["account"] 28 | 29 | oauth2Bitbucket :: YesodAuth m => Text -> Text -> AuthPlugin m 30 | oauth2Bitbucket = oauth2BitbucketScoped defaultScopes 31 | 32 | oauth2BitbucketScoped :: YesodAuth m => [Text] -> Text -> Text -> AuthPlugin m 33 | oauth2BitbucketScoped scopes clientId clientSecret = 34 | authOAuth2 pluginName oauth2 $ \manager token -> do 35 | (User userId, userResponse) <- 36 | authGetProfile 37 | pluginName 38 | manager 39 | token 40 | "https://api.bitbucket.com/2.0/user" 41 | 42 | pure 43 | Creds 44 | { credsPlugin = pluginName 45 | , -- FIXME: Preserved bug. This should just be userId (it's already 46 | -- a Text), but because this code was shipped, folks likely have 47 | -- Idents in their database like @"\"...\""@, and if we fixed this 48 | -- they would need migrating. We're keeping it for now as it's a 49 | -- minor wart. Breaking typed APIs is one thing, causing data to go 50 | -- invalid is another. 51 | credsIdent = T.pack $ show userId 52 | , credsExtra = setExtra token userResponse 53 | } 54 | where 55 | oauth2 = 56 | OAuth2 57 | { oauth2ClientId = clientId 58 | , oauth2ClientSecret = Just clientSecret 59 | , oauth2AuthorizeEndpoint = 60 | "https://bitbucket.com/site/oauth2/authorize" 61 | `withQuery` [scopeParam "," scopes] 62 | , oauth2TokenEndpoint = "https://bitbucket.com/site/oauth2/access_token" 63 | , oauth2RedirectUri = Nothing 64 | } 65 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/GitHub.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE QuasiQuotes #-} 3 | 4 | -- | 5 | -- 6 | -- OAuth2 plugin for http://github.com 7 | -- 8 | -- * Authenticates against github 9 | -- * Uses github user id as credentials identifier 10 | module Yesod.Auth.OAuth2.GitHub 11 | ( oauth2GitHub 12 | , oauth2GitHubWidget 13 | , oauth2GitHubScoped 14 | , oauth2GitHubScopedWidget 15 | ) where 16 | 17 | import qualified Data.Text as T 18 | import Yesod.Auth.OAuth2.Prelude 19 | import Yesod.Core (WidgetFor, whamlet) 20 | 21 | newtype User = User Int 22 | 23 | instance FromJSON User where 24 | parseJSON = withObject "User" $ \o -> User <$> o .: "id" 25 | 26 | pluginName :: Text 27 | pluginName = "github" 28 | 29 | defaultScopes :: [Text] 30 | defaultScopes = ["user:email"] 31 | 32 | oauth2GitHub :: YesodAuth m => Text -> Text -> AuthPlugin m 33 | oauth2GitHub = oauth2GitHubScoped defaultScopes 34 | 35 | oauth2GitHubWidget 36 | :: YesodAuth m => WidgetFor m () -> Text -> Text -> AuthPlugin m 37 | oauth2GitHubWidget widget = oauth2GitHubScopedWidget widget defaultScopes 38 | 39 | oauth2GitHubScoped :: YesodAuth m => [Text] -> Text -> Text -> AuthPlugin m 40 | oauth2GitHubScoped = 41 | oauth2GitHubScopedWidget [whamlet|Login via #{pluginName}|] 42 | 43 | oauth2GitHubScopedWidget 44 | :: YesodAuth m => WidgetFor m () -> [Text] -> Text -> Text -> AuthPlugin m 45 | oauth2GitHubScopedWidget widget scopes clientId clientSecret = 46 | authOAuth2Widget widget pluginName oauth2 $ \manager token -> do 47 | (User userId, userResponse) <- 48 | authGetProfile 49 | pluginName 50 | manager 51 | token 52 | "https://api.github.com/user" 53 | 54 | pure 55 | Creds 56 | { credsPlugin = pluginName 57 | , credsIdent = T.pack $ show userId 58 | , credsExtra = setExtra token userResponse 59 | } 60 | where 61 | oauth2 = 62 | OAuth2 63 | { oauth2ClientId = clientId 64 | , oauth2ClientSecret = Just clientSecret 65 | , oauth2AuthorizeEndpoint = 66 | "https://github.com/login/oauth/authorize" 67 | `withQuery` [scopeParam "," scopes] 68 | , oauth2TokenEndpoint = "https://github.com/login/oauth/access_token" 69 | , oauth2RedirectUri = Nothing 70 | } 71 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/BattleNet.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | -- | 4 | -- 5 | -- OAuth2 plugin for Battle.Net 6 | -- 7 | -- * Authenticates against battle.net. 8 | -- * Uses user's id as credentials identifier. 9 | -- * Returns user's battletag in extras. 10 | module Yesod.Auth.OAuth2.BattleNet 11 | ( oauth2BattleNet 12 | , oAuth2BattleNet 13 | ) where 14 | 15 | import Yesod.Auth.OAuth2.Prelude 16 | 17 | import qualified Data.Text as T (pack, toLower) 18 | import Yesod.Core.Widget 19 | 20 | newtype User = User Int 21 | 22 | instance FromJSON User where 23 | parseJSON = withObject "User" $ \o -> User <$> o .: "id" 24 | 25 | pluginName :: Text 26 | pluginName = "battle.net" 27 | 28 | oauth2BattleNet 29 | :: YesodAuth m 30 | => WidgetFor m () 31 | -- ^ Login widget 32 | -> Text 33 | -- ^ User region (e.g. "eu", "cn", "us") 34 | -> Text 35 | -- ^ Client ID 36 | -> Text 37 | -- ^ Client Secret 38 | -> AuthPlugin m 39 | oauth2BattleNet widget region clientId clientSecret = 40 | authOAuth2Widget widget pluginName oauth2 $ \manager token -> do 41 | (User userId, userResponse) <- 42 | authGetProfile pluginName manager token $ 43 | fromRelative "https" (apiHost $ T.toLower region) "/account/user" 44 | 45 | pure 46 | Creds 47 | { credsPlugin = pluginName 48 | , credsIdent = T.pack $ show userId 49 | , credsExtra = setExtra token userResponse 50 | } 51 | where 52 | host = wwwHost $ T.toLower region 53 | oauth2 = 54 | OAuth2 55 | { oauth2ClientId = clientId 56 | , oauth2ClientSecret = Just clientSecret 57 | , oauth2AuthorizeEndpoint = fromRelative "https" host "/oauth/authorize" 58 | , oauth2TokenEndpoint = fromRelative "https" host "/oauth/token" 59 | , oauth2RedirectUri = Nothing 60 | } 61 | 62 | apiHost :: Text -> Host 63 | apiHost "cn" = "api.battlenet.com.cn" 64 | apiHost region = Host $ encodeUtf8 $ region <> ".api.battle.net" 65 | 66 | wwwHost :: Text -> Host 67 | wwwHost "cn" = "www.battlenet.com.cn" 68 | wwwHost region = Host $ encodeUtf8 $ region <> ".battle.net" 69 | 70 | oAuth2BattleNet 71 | :: YesodAuth m => Text -> Text -> Text -> WidgetFor m () -> AuthPlugin m 72 | oAuth2BattleNet i s r w = oauth2BattleNet w r i s 73 | {-# DEPRECATED oAuth2BattleNet "Use oauth2BattleNet" #-} 74 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/Slack.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | -- | 4 | -- OAuth2 plugin for https://slack.com/ 5 | -- 6 | -- * Authenticates against slack 7 | -- * Uses slack user id as credentials identifier 8 | module Yesod.Auth.OAuth2.Slack 9 | ( SlackScope (..) 10 | , oauth2Slack 11 | , oauth2SlackScoped 12 | ) where 13 | 14 | import Yesod.Auth.OAuth2.Prelude 15 | 16 | import Network.HTTP.Client 17 | ( httpLbs 18 | , parseUrlThrow 19 | , responseBody 20 | , setQueryString 21 | ) 22 | import Yesod.Auth.OAuth2.Exception as YesodOAuth2Exception 23 | 24 | data SlackScope 25 | = SlackBasicScope 26 | | SlackEmailScope 27 | | SlackTeamScope 28 | | SlackAvatarScope 29 | 30 | scopeText :: SlackScope -> Text 31 | scopeText SlackBasicScope = "identity.basic" 32 | scopeText SlackEmailScope = "identity.email" 33 | scopeText SlackTeamScope = "identity.team" 34 | scopeText SlackAvatarScope = "identity.avatar" 35 | 36 | newtype User = User Text 37 | 38 | instance FromJSON User where 39 | parseJSON = withObject "User" $ \root -> do 40 | o <- root .: "user" 41 | User <$> o .: "id" 42 | 43 | pluginName :: Text 44 | pluginName = "slack" 45 | 46 | defaultScopes :: [SlackScope] 47 | defaultScopes = [SlackBasicScope] 48 | 49 | oauth2Slack :: YesodAuth m => Text -> Text -> AuthPlugin m 50 | oauth2Slack = oauth2SlackScoped defaultScopes 51 | 52 | oauth2SlackScoped 53 | :: YesodAuth m => [SlackScope] -> Text -> Text -> AuthPlugin m 54 | oauth2SlackScoped scopes clientId clientSecret = 55 | authOAuth2 pluginName oauth2 $ \manager token -> do 56 | let param = encodeUtf8 $ atoken $ accessToken token 57 | req <- 58 | setQueryString [("token", Just param)] 59 | <$> parseUrlThrow "https://slack.com/api/users.identity" 60 | userResponse <- responseBody <$> httpLbs req manager 61 | 62 | either 63 | (throwIO . YesodOAuth2Exception.JSONDecodingError pluginName) 64 | ( \(User userId) -> 65 | pure 66 | Creds 67 | { credsPlugin = pluginName 68 | , credsIdent = userId 69 | , credsExtra = setExtra token userResponse 70 | } 71 | ) 72 | $ eitherDecode userResponse 73 | where 74 | oauth2 = 75 | OAuth2 76 | { oauth2ClientId = clientId 77 | , oauth2ClientSecret = Just clientSecret 78 | , oauth2AuthorizeEndpoint = 79 | "https://slack.com/oauth/authorize" 80 | `withQuery` [scopeParam "," $ map scopeText scopes] 81 | , oauth2TokenEndpoint = "https://slack.com/api/oauth.access" 82 | , oauth2RedirectUri = Nothing 83 | } 84 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/Nylas.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | module Yesod.Auth.OAuth2.Nylas 4 | ( oauth2Nylas 5 | ) where 6 | 7 | import Yesod.Auth.OAuth2.Prelude 8 | 9 | import Control.Monad (unless) 10 | import qualified Data.ByteString.Lazy.Char8 as BL8 11 | import Network.HTTP.Client 12 | import qualified Network.HTTP.Types as HT 13 | import qualified Yesod.Auth.OAuth2.Exception as YesodOAuth2Exception 14 | 15 | newtype User = User Text 16 | 17 | instance FromJSON User where 18 | parseJSON = withObject "User" $ \o -> User <$> o .: "id" 19 | 20 | pluginName :: Text 21 | pluginName = "nylas" 22 | 23 | defaultScopes :: [Text] 24 | defaultScopes = ["email"] 25 | 26 | oauth2Nylas :: YesodAuth m => Text -> Text -> AuthPlugin m 27 | oauth2Nylas clientId clientSecret = 28 | authOAuth2 pluginName oauth $ \manager token -> do 29 | req <- 30 | applyBasicAuth (encodeUtf8 $ atoken $ accessToken token) "" 31 | <$> parseRequest "https://api.nylas.com/account" 32 | resp <- httpLbs req manager 33 | let userResponse = responseBody resp 34 | 35 | -- FIXME: was this working? I'm 95% sure that the client will throw its 36 | -- own exception on unsuccessful status codes. 37 | unless (HT.statusIsSuccessful $ responseStatus resp) $ 38 | throwIO $ 39 | YesodOAuth2Exception.GenericError pluginName $ 40 | "Unsuccessful HTTP response: " 41 | <> BL8.unpack userResponse 42 | 43 | either 44 | (throwIO . YesodOAuth2Exception.JSONDecodingError pluginName) 45 | ( \(User userId) -> 46 | pure 47 | Creds 48 | { credsPlugin = pluginName 49 | , credsIdent = userId 50 | , credsExtra = setExtra token userResponse 51 | } 52 | ) 53 | $ eitherDecode userResponse 54 | where 55 | oauth = 56 | OAuth2 57 | { oauth2ClientId = clientId 58 | , oauth2ClientSecret = Just clientSecret 59 | , oauth2AuthorizeEndpoint = 60 | "https://api.nylas.com/oauth/authorize" 61 | `withQuery` [ ("response_type", "code") 62 | , ("client_id", encodeUtf8 clientId) 63 | , -- N.B. The scopes delimeter is unknown/untested. Verify that before 64 | -- extracting this to an argument and offering a Scoped function. In 65 | -- its current state, it doesn't matter because it's only one scope. 66 | scopeParam "," defaultScopes 67 | ] 68 | , oauth2TokenEndpoint = "https://api.nylas.com/oauth/token" 69 | , oauth2RedirectUri = Nothing 70 | } 71 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/ErrorResponse.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | -- | OAuth callback error response 4 | -- 5 | -- 6 | module Yesod.Auth.OAuth2.ErrorResponse 7 | ( ErrorResponse (..) 8 | , erUserMessage 9 | , ErrorName (..) 10 | , onErrorResponse 11 | , unknownError 12 | ) where 13 | 14 | import Data.Foldable (traverse_) 15 | import Data.Text (Text) 16 | import Data.Traversable (for) 17 | import Yesod.Core (MonadHandler, lookupGetParam) 18 | 19 | data ErrorName 20 | = InvalidRequest 21 | | UnauthorizedClient 22 | | AccessDenied 23 | | UnsupportedResponseType 24 | | InvalidScope 25 | | ServerError 26 | | TemporarilyUnavailable 27 | | Unknown Text 28 | deriving (Show) 29 | 30 | data ErrorResponse = ErrorResponse 31 | { erName :: ErrorName 32 | , erDescription :: Maybe Text 33 | , erURI :: Maybe Text 34 | } 35 | deriving (Show) 36 | 37 | -- | Textual value suitable for display to a User 38 | erUserMessage :: ErrorResponse -> Text 39 | erUserMessage err = case erName err of 40 | InvalidRequest -> "Invalid request" 41 | UnauthorizedClient -> "Unauthorized client" 42 | AccessDenied -> "Access denied" 43 | UnsupportedResponseType -> "Unsupported response type" 44 | InvalidScope -> "Invalid scope" 45 | ServerError -> "Server error" 46 | TemporarilyUnavailable -> "Temporarily unavailable" 47 | Unknown _ -> "Unknown error" 48 | 49 | unknownError :: Text -> ErrorResponse 50 | unknownError x = 51 | ErrorResponse {erName = Unknown x, erDescription = Nothing, erURI = Nothing} 52 | 53 | -- | Check query parameters for an error, if found run the given action 54 | -- 55 | -- The action is expected to use a short-circuit response function like 56 | -- @'permissionDenied'@, hence this returning @()@. 57 | onErrorResponse :: MonadHandler m => (ErrorResponse -> m a) -> m () 58 | onErrorResponse f = traverse_ f =<< checkErrorResponse 59 | 60 | checkErrorResponse :: MonadHandler m => m (Maybe ErrorResponse) 61 | checkErrorResponse = do 62 | merror <- lookupGetParam "error" 63 | 64 | for merror $ \err -> 65 | ErrorResponse (readErrorName err) 66 | <$> lookupGetParam "error_description" 67 | <*> lookupGetParam "error_uri" 68 | 69 | readErrorName :: Text -> ErrorName 70 | readErrorName "invalid_request" = InvalidRequest 71 | readErrorName "unauthorized_client" = UnauthorizedClient 72 | readErrorName "access_denied" = AccessDenied 73 | readErrorName "unsupported_response_type" = UnsupportedResponseType 74 | readErrorName "invalid_scope" = InvalidScope 75 | readErrorName "server_error" = ServerError 76 | readErrorName "temporarily_unavailable" = TemporarilyUnavailable 77 | readErrorName x = Unknown x 78 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/Salesforce.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | -- | 4 | -- 5 | -- OAuth2 plugin for http://login.salesforce.com 6 | -- 7 | -- * Authenticates against Salesforce (or sandbox) 8 | -- * Uses Salesforce user id as credentials identifier 9 | module Yesod.Auth.OAuth2.Salesforce 10 | ( oauth2Salesforce 11 | , oauth2SalesforceScoped 12 | , oauth2SalesforceSandbox 13 | , oauth2SalesforceSandboxScoped 14 | ) where 15 | 16 | import Yesod.Auth.OAuth2.Prelude 17 | 18 | newtype User = User Text 19 | 20 | instance FromJSON User where 21 | parseJSON = withObject "User" $ \o -> User <$> o .: "user_id" 22 | 23 | pluginName :: Text 24 | pluginName = "salesforce" 25 | 26 | defaultScopes :: [Text] 27 | defaultScopes = ["openid", "email", "api"] 28 | 29 | oauth2Salesforce :: YesodAuth m => Text -> Text -> AuthPlugin m 30 | oauth2Salesforce = oauth2SalesforceScoped defaultScopes 31 | 32 | oauth2SalesforceScoped :: YesodAuth m => [Text] -> Text -> Text -> AuthPlugin m 33 | oauth2SalesforceScoped = 34 | salesforceHelper 35 | pluginName 36 | "https://login.salesforce.com/services/oauth2/userinfo" 37 | "https://login.salesforce.com/services/oauth2/authorize" 38 | "https://login.salesforce.com/services/oauth2/token" 39 | 40 | oauth2SalesforceSandbox :: YesodAuth m => Text -> Text -> AuthPlugin m 41 | oauth2SalesforceSandbox = oauth2SalesforceSandboxScoped defaultScopes 42 | 43 | oauth2SalesforceSandboxScoped 44 | :: YesodAuth m => [Text] -> Text -> Text -> AuthPlugin m 45 | oauth2SalesforceSandboxScoped = 46 | salesforceHelper 47 | (pluginName <> "-sandbox") 48 | "https://test.salesforce.com/services/oauth2/userinfo" 49 | "https://test.salesforce.com/services/oauth2/authorize" 50 | "https://test.salesforce.com/services/oauth2/token" 51 | 52 | salesforceHelper 53 | :: YesodAuth m 54 | => Text 55 | -> URI 56 | -- ^ User profile 57 | -> URI 58 | -- ^ Authorize 59 | -> URI 60 | -- ^ Token 61 | -> [Text] 62 | -> Text 63 | -> Text 64 | -> AuthPlugin m 65 | salesforceHelper name profileUri authorizeUri tokenUri scopes clientId clientSecret = 66 | authOAuth2 name oauth2 $ \manager token -> do 67 | (User userId, userResponse) <- authGetProfile name manager token profileUri 68 | 69 | pure 70 | Creds 71 | { credsPlugin = pluginName 72 | , credsIdent = userId 73 | , credsExtra = setExtra token userResponse 74 | } 75 | where 76 | oauth2 = 77 | OAuth2 78 | { oauth2ClientId = clientId 79 | , oauth2ClientSecret = Just clientSecret 80 | , oauth2AuthorizeEndpoint = authorizeUri `withQuery` [scopeParam " " scopes] 81 | , oauth2TokenEndpoint = tokenUri 82 | , oauth2RedirectUri = Nothing 83 | } 84 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/DispatchError.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveAnyClass #-} 2 | {-# LANGUAGE DerivingStrategies #-} 3 | {-# LANGUAGE FlexibleContexts #-} 4 | {-# LANGUAGE LambdaCase #-} 5 | {-# LANGUAGE OverloadedStrings #-} 6 | {-# LANGUAGE ScopedTypeVariables #-} 7 | {-# LANGUAGE TemplateHaskell #-} 8 | {-# LANGUAGE TypeApplications #-} 9 | {-# LANGUAGE TypeFamilies #-} 10 | 11 | module Yesod.Auth.OAuth2.DispatchError 12 | ( DispatchError (..) 13 | , handleDispatchError 14 | , onDispatchError 15 | ) where 16 | 17 | import Control.Monad.Except 18 | import Data.Text (Text, pack) 19 | import Network.OAuth.OAuth2.Compat (Errors) 20 | import UnliftIO.Except () 21 | import UnliftIO.Exception 22 | import Yesod.Auth hiding (ServerError) 23 | import Yesod.Auth.OAuth2.ErrorResponse 24 | import Yesod.Auth.OAuth2.Exception 25 | import Yesod.Auth.OAuth2.Random 26 | import Yesod.Core hiding (ErrorResponse) 27 | 28 | data DispatchError 29 | = MissingParameter Text 30 | | InvalidStateToken (Maybe Text) Text 31 | | InvalidCallbackUri Text 32 | | OAuth2HandshakeError ErrorResponse 33 | | OAuth2ResultError Errors 34 | | FetchCredsIOException IOException 35 | | FetchCredsYesodOAuth2Exception YesodOAuth2Exception 36 | | OtherDispatchError Text 37 | deriving stock (Show) 38 | deriving anyclass (Exception) 39 | 40 | -- | User-friendly message for any given 'DispatchError' 41 | -- 42 | -- Most of these are opaque to the user. The exception details are present for 43 | -- the server logs. 44 | dispatchErrorMessage :: DispatchError -> Text 45 | dispatchErrorMessage = \case 46 | MissingParameter name -> 47 | "Parameter '" <> name <> "' is required, but not present in the URL" 48 | InvalidStateToken {} -> "State token is invalid, please try again" 49 | InvalidCallbackUri {} -> 50 | "Callback URI was not valid, this server may be misconfigured (no approot)" 51 | OAuth2HandshakeError er -> "OAuth2 handshake failure: " <> erUserMessage er 52 | OAuth2ResultError {} -> "Login failed, please try again" 53 | FetchCredsIOException {} -> "Login failed, please try again" 54 | FetchCredsYesodOAuth2Exception {} -> "Login failed, please try again" 55 | OtherDispatchError {} -> "Login failed, please try again" 56 | 57 | handleDispatchError 58 | :: MonadAuthHandler site m 59 | => ExceptT DispatchError m TypedContent 60 | -> m TypedContent 61 | handleDispatchError f = do 62 | result <- runExceptT f 63 | either onDispatchError pure result 64 | 65 | onDispatchError :: MonadAuthHandler site m => DispatchError -> m TypedContent 66 | onDispatchError err = do 67 | errorId <- liftIO $ randomText 16 68 | let suffix = " [errorId=" <> errorId <> "]" 69 | $(logError) $ pack (displayException err) <> suffix 70 | 71 | let 72 | message = dispatchErrorMessage err <> suffix 73 | messageValue = 74 | object ["error" .= object ["id" .= errorId, "message" .= message]] 75 | 76 | loginR <- ($ LoginR) <$> getRouteToParent 77 | 78 | selectRep $ do 79 | provideRep @_ @Html $ onErrorHtml loginR message 80 | provideRep @_ @Value $ pure messageValue 81 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/Google.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE QuasiQuotes #-} 3 | 4 | -- | 5 | -- 6 | -- OAuth2 plugin for http://www.google.com 7 | -- 8 | -- * Authenticates against Google 9 | -- * Uses Google user id as credentials identifier 10 | -- 11 | -- If you were previously relying on email as the creds identifier, you can 12 | -- still do that (and more) by overriding it in the creds returned by the plugin 13 | -- with any value read out of the new @userResponse@ key in @'credsExtra'@. 14 | -- 15 | -- For example: 16 | -- 17 | -- > data User = User { userEmail :: Text } 18 | -- > 19 | -- > instance FromJSON User where -- you know... 20 | -- > 21 | -- > authenticate creds = do 22 | -- > -- 'getUserResponseJSON' provided by "Yesod.Auth.OAuth" module 23 | -- > let Right email = userEmail <$> getUserResponseJSON creds 24 | -- > updatedCreds = creds { credsIdent = email } 25 | -- > 26 | -- > -- continue normally with updatedCreds 27 | module Yesod.Auth.OAuth2.Google 28 | ( oauth2Google 29 | , oauth2GoogleWidget 30 | , oauth2GoogleScoped 31 | , oauth2GoogleScopedWidget 32 | ) where 33 | 34 | import Yesod.Auth.OAuth2.Prelude 35 | import Yesod.Core (WidgetFor, whamlet) 36 | 37 | newtype User = User Text 38 | 39 | instance FromJSON User where 40 | parseJSON = 41 | withObject "User" $ \o -> 42 | -- Required for data backwards-compatibility 43 | User . ("google-uid:" <>) <$> o .: "sub" 44 | 45 | pluginName :: Text 46 | pluginName = "google" 47 | 48 | defaultScopes :: [Text] 49 | defaultScopes = ["openid", "email"] 50 | 51 | oauth2Google :: YesodAuth m => Text -> Text -> AuthPlugin m 52 | oauth2Google = oauth2GoogleScoped defaultScopes 53 | 54 | oauth2GoogleWidget 55 | :: YesodAuth m => WidgetFor m () -> Text -> Text -> AuthPlugin m 56 | oauth2GoogleWidget widget = oauth2GoogleScopedWidget widget defaultScopes 57 | 58 | oauth2GoogleScoped :: YesodAuth m => [Text] -> Text -> Text -> AuthPlugin m 59 | oauth2GoogleScoped = 60 | oauth2GoogleScopedWidget [whamlet|Login via #{pluginName}|] 61 | 62 | oauth2GoogleScopedWidget 63 | :: YesodAuth m => WidgetFor m () -> [Text] -> Text -> Text -> AuthPlugin m 64 | oauth2GoogleScopedWidget widget scopes clientId clientSecret = 65 | authOAuth2Widget widget pluginName oauth2 $ \manager token -> do 66 | (User userId, userResponse) <- 67 | authGetProfile 68 | pluginName 69 | manager 70 | token 71 | "https://www.googleapis.com/oauth2/v3/userinfo" 72 | 73 | pure 74 | Creds 75 | { credsPlugin = pluginName 76 | , credsIdent = userId 77 | , credsExtra = setExtra token userResponse 78 | } 79 | where 80 | oauth2 = 81 | OAuth2 82 | { oauth2ClientId = clientId 83 | , oauth2ClientSecret = Just clientSecret 84 | , oauth2AuthorizeEndpoint = 85 | "https://accounts.google.com/o/oauth2/auth" 86 | `withQuery` [scopeParam " " scopes] 87 | , oauth2TokenEndpoint = "https://www.googleapis.com/oauth2/v3/token" 88 | , oauth2RedirectUri = Nothing 89 | } 90 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/AzureADv2.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE QuasiQuotes #-} 3 | 4 | -- | 5 | -- 6 | -- OAuth2 plugin for Azure AD using the new v2 endpoints. 7 | -- 8 | -- * Authenticates against Azure AD 9 | -- * Uses email as credentials identifier 10 | module Yesod.Auth.OAuth2.AzureADv2 11 | ( oauth2AzureADv2 12 | , oauth2AzureADv2Scoped 13 | , oauth2AzureADv2Widget 14 | , oauth2AzureADv2ScopedWidget 15 | ) where 16 | 17 | import Yesod.Auth.OAuth2.Prelude 18 | import Yesod.Core (WidgetFor, whamlet) 19 | import Prelude 20 | 21 | import Data.String 22 | import Data.Text (unpack) 23 | 24 | newtype User = User Text 25 | 26 | instance FromJSON User where 27 | parseJSON = withObject "User" $ \o -> User <$> o .: "mail" 28 | 29 | pluginName :: Text 30 | pluginName = "azureadv2" 31 | 32 | defaultScopes :: [Text] 33 | defaultScopes = ["openid", "profile"] 34 | 35 | oauth2AzureADv2 36 | :: YesodAuth m 37 | => Text 38 | -- ^ Tenant Id 39 | -- 40 | -- If using a multi-tenant App, @common@ can be given here. 41 | -> Text 42 | -- ^ Client Id 43 | -> Text 44 | -- ^ Client secret 45 | -> AuthPlugin m 46 | oauth2AzureADv2 = oauth2AzureADv2Scoped defaultScopes 47 | 48 | oauth2AzureADv2Widget 49 | :: YesodAuth m => WidgetFor m () -> Text -> Text -> Text -> AuthPlugin m 50 | oauth2AzureADv2Widget widget = 51 | oauth2AzureADv2ScopedWidget widget defaultScopes 52 | 53 | oauth2AzureADv2Scoped 54 | :: YesodAuth m => [Text] -> Text -> Text -> Text -> AuthPlugin m 55 | oauth2AzureADv2Scoped = 56 | oauth2AzureADv2ScopedWidget [whamlet|Login via #{pluginName}|] 57 | 58 | oauth2AzureADv2ScopedWidget 59 | :: YesodAuth m 60 | => WidgetFor m () 61 | -- ^ Widget 62 | -> [Text] 63 | -- ^ Scopes 64 | -> Text 65 | -- ^ Tenant Id 66 | -- 67 | -- If using a multi-tenant App, @common@ can be given here. 68 | -> Text 69 | -- ^ Client Id 70 | -> Text 71 | -- ^ Client Secret 72 | -> AuthPlugin m 73 | oauth2AzureADv2ScopedWidget widget scopes tenantId clientId clientSecret = 74 | authOAuth2Widget widget pluginName oauth2 $ \manager token -> do 75 | (User userId, userResponse) <- 76 | authGetProfile 77 | pluginName 78 | manager 79 | token 80 | "https://graph.microsoft.com/v1.0/me" 81 | 82 | pure 83 | Creds 84 | { credsPlugin = pluginName 85 | , credsIdent = userId 86 | , credsExtra = setExtra token userResponse 87 | } 88 | where 89 | oauth2 = 90 | OAuth2 91 | { oauth2ClientId = clientId 92 | , oauth2ClientSecret = Just clientSecret 93 | , oauth2AuthorizeEndpoint = 94 | tenantUrl "/authorize" `withQuery` [scopeParam " " scopes] 95 | , oauth2TokenEndpoint = tenantUrl "/token" 96 | , oauth2RedirectUri = Nothing 97 | } 98 | 99 | tenantUrl path = 100 | fromString $ 101 | "https://login.microsoftonline.com/" 102 | <> unpack tenantId 103 | <> "/oauth2/v2.0" 104 | <> path 105 | -------------------------------------------------------------------------------- /test/URI/ByteString/ExtensionSpec.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE QuasiQuotes #-} 3 | 4 | module URI.ByteString.ExtensionSpec 5 | ( spec 6 | ) where 7 | 8 | import Test.Hspec 9 | 10 | import Control.Exception (ErrorCall, evaluate) 11 | import Data.List (isInfixOf) 12 | import URI.ByteString 13 | import URI.ByteString.Extension 14 | import URI.ByteString.QQ 15 | 16 | spec :: Spec 17 | spec = do 18 | describe "IsString Scheme" $ it "works" $ do 19 | "https" `shouldBe` Scheme "https" 20 | 21 | describe "IsString Host" $ it "works" $ do 22 | "example.com" `shouldBe` Host "example.com" 23 | 24 | describe "IsString URIRef Relative" $ it "works" $ do 25 | "example.com/foo?bar=baz" `shouldBe` [relativeRef|example.com/foo?bar=baz|] 26 | 27 | describe "IsString URIRef Absolute" $ it "works" $ do 28 | "https://example.com/foo?bar=baz" 29 | `shouldBe` [uri|https://example.com/foo?bar=baz|] 30 | 31 | describe "fromText" $ do 32 | it "returns Just a URI for valid values, as the quasi-quoter would" $ do 33 | fromText "http://example.com/foo?bar=baz" 34 | `shouldBe` Just [uri|http://example.com/foo?bar=baz|] 35 | 36 | it "returns Nothing for invalid values" $ do 37 | fromText "Oh my, what did I do?" `shouldBe` Nothing 38 | 39 | describe "unsafeFromText" $ do 40 | it "returns a URI for valid values, as the quasi-quoter would" $ do 41 | unsafeFromText "http://example.com/foo?bar=baz" 42 | `shouldBe` [uri|http://example.com/foo?bar=baz|] 43 | 44 | it "raises for invalid values" $ do 45 | evaluate (unsafeFromText "Oh my, what did I do?") 46 | `shouldThrow` errorContaining "MissingColon" 47 | 48 | describe "toText" $ do 49 | it "serializes the URI to text" $ do 50 | toText [uri|https://example.com/foo?bar=baz|] 51 | `shouldBe` "https://example.com/foo?bar=baz" 52 | 53 | describe "fromRelative" $ do 54 | it "makes a URI absolute with a given host" $ do 55 | fromRelative "ftp" "foo.com" [relativeRef|/bar?baz=bat|] 56 | `shouldBe` [uri|ftp://foo.com/bar?baz=bat|] 57 | 58 | describe "withQuery" $ do 59 | it "appends a query to a URI" $ do 60 | let uriWithQuery = [uri|http://example.com|] `withQuery` [("foo", "bar")] 61 | 62 | uriWithQuery `shouldBe` [uri|http://example.com?foo=bar|] 63 | 64 | it "handles a URI with an existing query" $ do 65 | let uriWithQuery = 66 | [uri|http://example.com?foo=bar|] `withQuery` [("baz", "bat")] 67 | 68 | uriWithQuery `shouldBe` [uri|http://example.com?foo=bar&baz=bat|] 69 | 70 | -- This is arguably testing the internals of another package, but IMO 71 | -- it's worthwhile to show that you don't (and can't) pre-sanitize when 72 | -- using this function. 73 | it "handles santization of the query" $ do 74 | let uriWithQuery = 75 | [uri|http://example.com|] `withQuery` [("foo", "bar baz")] 76 | 77 | toText uriWithQuery `shouldBe` "http://example.com?foo=bar%20baz" 78 | 79 | errorContaining :: String -> Selector ErrorCall 80 | errorContaining msg = (msg `isInfixOf`) . show 81 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/EveOnline.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE QuasiQuotes #-} 3 | 4 | -- | 5 | -- 6 | -- OAuth2 plugin for http://eveonline.com 7 | -- 8 | -- * Authenticates against eveonline 9 | -- * Uses EVEs unique account-user-char-hash as credentials identifier 10 | module Yesod.Auth.OAuth2.EveOnline 11 | ( oauth2Eve 12 | , oauth2EveScoped 13 | , WidgetType (..) 14 | ) where 15 | 16 | import Yesod.Auth.OAuth2.Prelude 17 | 18 | import qualified Data.Text as T 19 | import Yesod.Core.Widget 20 | 21 | newtype User = User Text 22 | 23 | instance FromJSON User where 24 | parseJSON = withObject "User" $ \o -> User <$> o .: "CharacterOwnerHash" 25 | 26 | data WidgetType m 27 | = -- | Simple "Login via eveonline" text 28 | Plain 29 | | BigWhite 30 | | SmallWhite 31 | | BigBlack 32 | | SmallBlack 33 | | Custom (WidgetFor m ()) 34 | 35 | asWidget :: YesodAuth m => WidgetType m -> WidgetFor m () 36 | asWidget Plain = [whamlet|Login via eveonline|] 37 | asWidget BigWhite = 38 | [whamlet||] 39 | asWidget BigBlack = 40 | [whamlet||] 41 | asWidget SmallWhite = 42 | [whamlet||] 43 | asWidget SmallBlack = 44 | [whamlet||] 45 | asWidget (Custom a) = a 46 | 47 | pluginName :: Text 48 | pluginName = "eveonline" 49 | 50 | defaultScopes :: [Text] 51 | defaultScopes = ["publicData"] 52 | 53 | oauth2Eve :: YesodAuth m => WidgetType m -> Text -> Text -> AuthPlugin m 54 | oauth2Eve = oauth2EveScoped defaultScopes 55 | 56 | oauth2EveScoped 57 | :: YesodAuth m => [Text] -> WidgetType m -> Text -> Text -> AuthPlugin m 58 | oauth2EveScoped scopes widgetType clientId clientSecret = 59 | authOAuth2Widget (asWidget widgetType) pluginName oauth2 $ \manager token -> 60 | do 61 | (User userId, userResponse) <- 62 | authGetProfile 63 | pluginName 64 | manager 65 | token 66 | "https://login.eveonline.com/oauth/verify" 67 | 68 | pure 69 | Creds 70 | { credsPlugin = "eveonline" 71 | , -- FIXME: Preserved bug. See similar comment in Bitbucket provider. 72 | credsIdent = T.pack $ show userId 73 | , credsExtra = setExtra token userResponse 74 | } 75 | where 76 | oauth2 = 77 | OAuth2 78 | { oauth2ClientId = clientId 79 | , oauth2ClientSecret = Just clientSecret 80 | , oauth2AuthorizeEndpoint = 81 | "https://login.eveonline.com/oauth/authorize" 82 | `withQuery` [("response_type", "code"), scopeParam " " scopes] 83 | , oauth2TokenEndpoint = "https://login.eveonline.com/oauth/token" 84 | , oauth2RedirectUri = Nothing 85 | } 86 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2/Prelude.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE TupleSections #-} 3 | 4 | -- | 5 | -- 6 | -- Modules and support functions required by most or all provider 7 | -- implementations. May also be useful for writing local providers. 8 | module Yesod.Auth.OAuth2.Prelude 9 | ( authGetProfile 10 | , scopeParam 11 | , setExtra 12 | 13 | -- * Text 14 | , Text 15 | , decodeUtf8 16 | , encodeUtf8 17 | 18 | -- * JSON 19 | , (.:) 20 | , (.:?) 21 | , (.=) 22 | , (<>) 23 | , FromJSON (..) 24 | , ToJSON (..) 25 | , eitherDecode 26 | , withObject 27 | 28 | -- * Exceptions 29 | , throwIO 30 | 31 | -- * OAuth2 32 | , OAuth2 (..) 33 | , OAuth2Token (..) 34 | , AccessToken (..) 35 | , RefreshToken (..) 36 | 37 | -- * HTTP 38 | , Manager 39 | 40 | -- * Yesod 41 | , YesodAuth (..) 42 | , AuthPlugin (..) 43 | , Creds (..) 44 | 45 | -- * Bytestring URI types 46 | , URI 47 | , Host (..) 48 | 49 | -- * Bytestring URI extensions 50 | , module URI.ByteString.Extension 51 | 52 | -- * Temporary, until I finish re-structuring modules 53 | , authOAuth2 54 | , authOAuth2Widget 55 | ) where 56 | 57 | import Control.Exception.Safe 58 | import Data.Aeson 59 | import Data.ByteString (ByteString) 60 | import qualified Data.ByteString.Lazy as BL 61 | import Data.Text (Text) 62 | import qualified Data.Text as T 63 | import Data.Text.Encoding 64 | import Network.HTTP.Conduit 65 | import Network.OAuth.OAuth2.Compat 66 | import URI.ByteString 67 | import URI.ByteString.Extension 68 | import Yesod.Auth 69 | import Yesod.Auth.OAuth2 70 | import qualified Yesod.Auth.OAuth2.Exception as YesodOAuth2Exception 71 | 72 | -- | Retrieve a user's profile as JSON 73 | -- 74 | -- The response should be parsed only far enough to read the required 75 | -- @'credsIdent'@. Additional information should either be re-parsed by or 76 | -- fetched via additional requests by consumers. 77 | authGetProfile 78 | :: FromJSON a 79 | => Text 80 | -> Manager 81 | -> OAuth2Token 82 | -> URI 83 | -> IO (a, BL.ByteString) 84 | authGetProfile name manager token url = do 85 | resp <- fromAuthGet name =<< authGetBS manager (accessToken token) url 86 | decoded <- fromAuthJSON name resp 87 | pure (decoded, resp) 88 | 89 | -- | Throws a @Left@ result as an @'YesodOAuth2Exception'@ 90 | fromAuthGet :: Text -> Either BL.ByteString BL.ByteString -> IO BL.ByteString 91 | fromAuthGet _ (Right bs) = pure bs -- nice 92 | fromAuthGet name (Left err) = 93 | throwIO $ YesodOAuth2Exception.OAuth2Error name err 94 | 95 | -- | Throws a decoding error as an @'YesodOAuth2Exception'@ 96 | fromAuthJSON :: FromJSON a => Text -> BL.ByteString -> IO a 97 | fromAuthJSON name = 98 | either (throwIO . YesodOAuth2Exception.JSONDecodingError name) pure 99 | . eitherDecode 100 | 101 | -- | A tuple of @\"scope\"@ and the given scopes separated by a delimiter 102 | scopeParam :: Text -> [Text] -> (ByteString, ByteString) 103 | scopeParam d = ("scope",) . encodeUtf8 . T.intercalate d 104 | 105 | -- brittany-disable-next-binding 106 | 107 | -- | Construct part of @'credsExtra'@ 108 | -- 109 | -- Always the following keys: 110 | -- 111 | -- - @accessToken@: to support follow-up requests 112 | -- - @userResponse@: to support getting additional information 113 | -- 114 | -- May set the following keys: 115 | -- 116 | -- - @refreshToken@: if the provider supports refreshing the @accessToken@ 117 | setExtra :: OAuth2Token -> BL.ByteString -> [(Text, Text)] 118 | setExtra token userResponse = 119 | [ ("accessToken", atoken $ accessToken token) 120 | , ("userResponse", decodeUtf8 $ BL.toStrict userResponse) 121 | ] 122 | <> maybe [] (pure . ("refreshToken",) . rtoken) (refreshToken token) 123 | -------------------------------------------------------------------------------- /src/Yesod/Auth/OAuth2.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE FlexibleContexts #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | {-# LANGUAGE QuasiQuotes #-} 4 | 5 | -- | 6 | -- 7 | -- Generic OAuth2 plugin for Yesod 8 | -- 9 | -- See @"Yesod.Auth.OAuth2.GitHub"@ for example usage. 10 | module Yesod.Auth.OAuth2 11 | ( OAuth2 (..) 12 | , FetchCreds 13 | , Manager 14 | , OAuth2Token (..) 15 | , Creds (..) 16 | , oauth2Url 17 | , authOAuth2 18 | , authOAuth2Widget 19 | 20 | -- * Alternatives that use 'fetchAccessToken2' 21 | , authOAuth2' 22 | , authOAuth2Widget' 23 | 24 | -- * Reading our @'credsExtra'@ keys 25 | , getAccessToken 26 | , getRefreshToken 27 | , getUserResponse 28 | , getUserResponseJSON 29 | ) where 30 | 31 | import Control.Error.Util (note) 32 | import Control.Monad ((<=<)) 33 | import Data.Aeson (FromJSON, eitherDecode) 34 | import Data.ByteString.Lazy (ByteString, fromStrict) 35 | import Data.Text (Text) 36 | import Data.Text.Encoding (encodeUtf8) 37 | import Network.HTTP.Conduit (Manager) 38 | import Network.OAuth.OAuth2.Compat 39 | import Yesod.Auth 40 | import Yesod.Auth.OAuth2.Dispatch 41 | import Yesod.Core.Widget 42 | 43 | oauth2Url :: Text -> AuthRoute 44 | oauth2Url name = PluginR name ["forward"] 45 | 46 | -- | Create an @'AuthPlugin'@ for the given OAuth2 provider 47 | -- 48 | -- Presents a generic @"Login via #{name}"@ link 49 | authOAuth2 :: YesodAuth m => Text -> OAuth2 -> FetchCreds m -> AuthPlugin m 50 | authOAuth2 name = authOAuth2Widget [whamlet|Login via #{name}|] name 51 | 52 | -- | A version of 'authOAuth2' that uses 'fetchAccessToken2' 53 | -- 54 | -- See 55 | authOAuth2' :: YesodAuth m => Text -> OAuth2 -> FetchCreds m -> AuthPlugin m 56 | authOAuth2' name = authOAuth2Widget' [whamlet|Login via #{name}|] name 57 | 58 | -- | Create an @'AuthPlugin'@ for the given OAuth2 provider 59 | -- 60 | -- Allows passing a custom widget for the login link. See @'oauth2Eve'@ for an 61 | -- example. 62 | authOAuth2Widget 63 | :: YesodAuth m 64 | => WidgetFor m () 65 | -> Text 66 | -> OAuth2 67 | -> FetchCreds m 68 | -> AuthPlugin m 69 | authOAuth2Widget = buildPlugin fetchAccessToken 70 | 71 | -- | A version of 'authOAuth2Widget' that uses 'fetchAccessToken2' 72 | -- 73 | -- See 74 | authOAuth2Widget' 75 | :: YesodAuth m 76 | => WidgetFor m () 77 | -> Text 78 | -> OAuth2 79 | -> FetchCreds m 80 | -> AuthPlugin m 81 | authOAuth2Widget' = buildPlugin fetchAccessToken2 82 | 83 | buildPlugin 84 | :: YesodAuth m 85 | => FetchToken 86 | -> WidgetFor m () 87 | -> Text 88 | -> OAuth2 89 | -> FetchCreds m 90 | -> AuthPlugin m 91 | buildPlugin getToken widget name oauth getCreds = 92 | AuthPlugin 93 | name 94 | (dispatchAuthRequest name oauth getToken getCreds) 95 | login 96 | where 97 | login tm = [whamlet|^{widget}|] 98 | 99 | -- | Read the @'AccessToken'@ from the values set via @'setExtra'@ 100 | getAccessToken :: Creds m -> Maybe AccessToken 101 | getAccessToken = (AccessToken <$>) . lookup "accessToken" . credsExtra 102 | 103 | -- | Read the @'RefreshToken'@ from the values set via @'setExtra'@ 104 | -- 105 | -- N.B. not all providers supply this value. 106 | getRefreshToken :: Creds m -> Maybe RefreshToken 107 | getRefreshToken = (RefreshToken <$>) . lookup "refreshToken" . credsExtra 108 | 109 | -- | Read the original profile response from the values set via @'setExtra'@ 110 | getUserResponse :: Creds m -> Maybe ByteString 111 | getUserResponse = 112 | (fromStrict . encodeUtf8 <$>) . lookup "userResponse" . credsExtra 113 | 114 | -- | @'getUserResponse'@, and decode as JSON 115 | getUserResponseJSON :: FromJSON a => Creds m -> Either String a 116 | getUserResponseJSON = 117 | eitherDecode <=< note "userResponse key not present" . getUserResponse 118 | -------------------------------------------------------------------------------- /yesod-auth-oauth2.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.18 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.37.0. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | -- 7 | -- hash: 6df1b551d88fc83903790620d83c1f4f549b14ab976cd46cb6bfb1e26722cf4f 8 | 9 | name: yesod-auth-oauth2 10 | version: 0.7.4.0 11 | synopsis: OAuth 2.0 authentication plugins 12 | description: Library to authenticate with OAuth 2.0 for Yesod web applications. 13 | category: Web 14 | homepage: http://github.com/freckle/yesod-auth-oauth2 15 | bug-reports: https://github.com/freckle/yesod-auth-oauth2/issues 16 | author: Tom Streller, 17 | Patrick Brisbin, 18 | Freckle Engineering 19 | maintainer: engineering@freckle.com 20 | license: MIT 21 | license-file: LICENSE 22 | build-type: Simple 23 | extra-doc-files: 24 | README.md 25 | CHANGELOG.md 26 | 27 | source-repository head 28 | type: git 29 | location: https://github.com/freckle/yesod-auth-oauth2 30 | 31 | flag example 32 | description: Build the example application 33 | manual: False 34 | default: False 35 | 36 | library 37 | exposed-modules: 38 | Network.OAuth.OAuth2.Compat 39 | UnliftIO.Except 40 | URI.ByteString.Extension 41 | Yesod.Auth.OAuth2 42 | Yesod.Auth.OAuth2.Auth0 43 | Yesod.Auth.OAuth2.AzureAD 44 | Yesod.Auth.OAuth2.AzureADv2 45 | Yesod.Auth.OAuth2.BattleNet 46 | Yesod.Auth.OAuth2.Bitbucket 47 | Yesod.Auth.OAuth2.ClassLink 48 | Yesod.Auth.OAuth2.Dispatch 49 | Yesod.Auth.OAuth2.DispatchError 50 | Yesod.Auth.OAuth2.ErrorResponse 51 | Yesod.Auth.OAuth2.EveOnline 52 | Yesod.Auth.OAuth2.Exception 53 | Yesod.Auth.OAuth2.GitHub 54 | Yesod.Auth.OAuth2.GitLab 55 | Yesod.Auth.OAuth2.Google 56 | Yesod.Auth.OAuth2.Nylas 57 | Yesod.Auth.OAuth2.ORCID 58 | Yesod.Auth.OAuth2.Prelude 59 | Yesod.Auth.OAuth2.Random 60 | Yesod.Auth.OAuth2.Salesforce 61 | Yesod.Auth.OAuth2.Slack 62 | Yesod.Auth.OAuth2.Spotify 63 | Yesod.Auth.OAuth2.Twitch 64 | Yesod.Auth.OAuth2.Upcase 65 | Yesod.Auth.OAuth2.WordPressDotCom 66 | other-modules: 67 | Paths_yesod_auth_oauth2 68 | hs-source-dirs: 69 | src 70 | ghc-options: -Wall 71 | build-depends: 72 | aeson >=0.6 73 | , base >=4.9.0.0 && <5 74 | , bytestring >=0.9.1.4 75 | , crypton 76 | , errors 77 | , hoauth2 >=1.11.0 78 | , http-client >=0.4.0 79 | , http-conduit >=2.0 80 | , http-types >=0.8 81 | , memory 82 | , microlens 83 | , mtl 84 | , safe-exceptions 85 | , text >=0.7 86 | , transformers 87 | , unliftio 88 | , uri-bytestring 89 | , yesod-auth >=1.6.0 90 | , yesod-core >=1.6.0 91 | default-language: Haskell2010 92 | 93 | executable yesod-auth-oauth2-example 94 | main-is: Main.hs 95 | other-modules: 96 | Paths_yesod_auth_oauth2 97 | hs-source-dirs: 98 | example 99 | ghc-options: -Wall -threaded -rtsopts -with-rtsopts=-N 100 | build-depends: 101 | aeson >=0.6 102 | , aeson-pretty 103 | , base >=4.9.0.0 && <5 104 | , bytestring >=0.9.1.4 105 | , containers >=0.6.0.1 106 | , http-conduit >=2.0 107 | , load-env 108 | , text >=0.7 109 | , warp 110 | , yesod 111 | , yesod-auth >=1.6.0 112 | , yesod-auth-oauth2 113 | default-language: Haskell2010 114 | if !(flag(example)) 115 | buildable: False 116 | 117 | test-suite test 118 | type: exitcode-stdio-1.0 119 | main-is: Spec.hs 120 | other-modules: 121 | URI.ByteString.ExtensionSpec 122 | Paths_yesod_auth_oauth2 123 | hs-source-dirs: 124 | test 125 | ghc-options: -Wall 126 | build-depends: 127 | base >=4.9.0.0 && <5 128 | , hspec 129 | , uri-bytestring 130 | , yesod-auth-oauth2 131 | default-language: Haskell2010 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yesod.Auth.OAuth2 2 | 3 | [![Hackage](https://img.shields.io/hackage/v/yesod-auth-oauth2.svg?style=flat)](https://hackage.haskell.org/package/yesod-auth-oauth2) 4 | [![Stackage Nightly](http://stackage.org/package/yesod-auth-oauth2/badge/nightly)](http://stackage.org/nightly/package/yesod-auth-oauth2) 5 | [![Stackage LTS](http://stackage.org/package/yesod-auth-oauth2/badge/lts)](http://stackage.org/lts/package/yesod-auth-oauth2) 6 | [![CI](https://github.com/freckle/yesod-auth-oauth2/actions/workflows/ci.yml/badge.svg)](https://github.com/pbrisbin/freckle/yesod-auth-oauth2/workflows/ci.yml) 7 | 8 | OAuth2 `AuthPlugin`s for Yesod. 9 | 10 | ## Usage 11 | 12 | ```hs 13 | import Yesod.Auth 14 | import Yesod.Auth.OAuth2.GitHub 15 | 16 | instance YesodAuth App where 17 | -- ... 18 | 19 | authPlugins _ = [oauth2GitHub clientId clientSecret] 20 | 21 | clientId :: Text 22 | clientId = "..." 23 | 24 | clientSecret :: Text 25 | clientSecret = "..." 26 | ``` 27 | 28 | Some plugins, such as GitHub and Slack, have scoped functions for requesting 29 | additional information: 30 | 31 | ```hs 32 | oauth2SlackScoped [SlackBasicScope, SlackEmailScope] clientId clientSecret 33 | ``` 34 | 35 | ## Working with Extra Data 36 | 37 | We put the minimal amount of user data possible in `credsExtra` -- just enough 38 | to support you parsing or fetching additional data yourself. 39 | 40 | For example, if you work with GitHub and GitHub user profiles, you likely 41 | already have a model and a way to parse the `/user` response. Rather than 42 | duplicate all that in our library, we try to make it easy for you to re-use that 43 | code yourself: 44 | 45 | ```hs 46 | authenticate creds = do 47 | let 48 | -- You can run your own FromJSON parser on the response we already have 49 | eGitHubUser :: Either String GitHubUser 50 | eGitHubUser = getUserResponseJSON creds 51 | 52 | -- Avert your eyes, simplified example 53 | Just accessToken = getAccessToken creds 54 | Right githubUser = eGitHubUser 55 | 56 | -- Or make followup requests using our access token 57 | runGitHub accessToken $ userRepositories githubUser 58 | 59 | -- Or store it for later 60 | insert User 61 | { userIdent = credsIdent creds 62 | , userAccessToken = accessToken 63 | } 64 | ``` 65 | 66 | **NOTE**: Avoid looking up values in `credsExtra` yourself; prefer the provided 67 | `get` functions. The data representation itself is no longer considered public 68 | API. 69 | 70 | ## Local Providers 71 | 72 | If we don't supply a "Provider" (e.g. GitHub, Google, etc) you need, you can 73 | write your own using our provided `Prelude`: 74 | 75 | ```haskell 76 | import Yesod.Auth.OAuth2.Prelude 77 | 78 | pluginName :: Text 79 | pluginName = "mysite" 80 | 81 | oauth2MySite :: YesodAuth m => Text -> Text -> AuthPlugin m 82 | oauth2MySite clientId clientSecret = 83 | authOAuth2 pluginName oauth2 $ \manager token -> do 84 | -- Fetch a profile using the manager and token, leave it a ByteString 85 | userResponse <- -- ... 86 | 87 | -- Parse it to your preferred identifier, e.g. with Data.Aeson 88 | userId <- -- ... 89 | 90 | -- See authGetProfile for the typical case 91 | 92 | pure Creds 93 | { credsPlugin = pluginName 94 | , credsIdent = userId 95 | , credsExtra = setExtra token userResponse 96 | } 97 | where 98 | oauth2 = OAuth2 99 | { oauth2ClientId = clientId 100 | , oauth2ClientSecret = Just clientSecret 101 | , oauth2AuthorizeEndpoint = "https://mysite.com/oauth/authorize" 102 | , oauth2TokenEndpoint = "https://mysite.com/oauth/token" 103 | , oauth2RedirectUri = Nothing 104 | } 105 | ``` 106 | 107 | The `Prelude` module is considered public API, though we may build something 108 | higher-level that is more convenient for this use-case in the future. 109 | 110 | ## Development & Tests 111 | 112 | ```console 113 | stack setup 114 | stack build --dependencies-only 115 | stack build --pedantic --test 116 | ``` 117 | 118 | Please also run HLint and Weeder before submitting PRs. 119 | 120 | ## Example 121 | 122 | This project includes an executable that runs a server with (almost) all 123 | supported providers present. 124 | 125 | To use: 126 | 127 | 1. `cp .env.example .env` and edit in secrets for providers you wish to test 128 | 129 | Be sure to include `http://localhost:3000/auth/page/{plugin}/callback` as a 130 | valid Redirect URI when configuring the OAuth application. 131 | 132 | 2. Build with the example: `stack build ... --flag yesod-auth-oauth2:example` 133 | 3. Run the example `stack exec yesod-auth-oauth2-example` 134 | 4. Visit the example: `$BROWSER http://localhost:3000` 135 | 5. Click the log-in link for the provider you configured 136 | 137 | If successful, you will be presented with a page that shows the credential and 138 | User response value. 139 | 140 | --- 141 | 142 | [CHANGELOG](./CHANGELOG.md) | [LICENSE](./LICENSE) 143 | -------------------------------------------------------------------------------- /example/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE MultiParamTypeClasses #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | {-# LANGUAGE QuasiQuotes #-} 4 | {-# LANGUAGE RecordWildCards #-} 5 | {-# LANGUAGE TemplateHaskell #-} 6 | {-# LANGUAGE TypeApplications #-} 7 | {-# LANGUAGE TypeFamilies #-} 8 | 9 | module Main where 10 | 11 | import Data.Aeson 12 | import Data.Aeson.Encode.Pretty 13 | import Data.ByteString.Lazy (fromStrict, toStrict) 14 | import qualified Data.Map as M 15 | import Data.Maybe (fromJust) 16 | import Data.String (IsString (fromString)) 17 | import Data.Text (Text, pack) 18 | import qualified Data.Text as T 19 | import Data.Text.Encoding (decodeUtf8) 20 | import LoadEnv 21 | import Network.HTTP.Conduit 22 | import Network.Wai.Handler.Warp (runEnv) 23 | import System.Environment (getEnv) 24 | import Yesod 25 | import Yesod.Auth 26 | import Yesod.Auth.OAuth2.Auth0 27 | import Yesod.Auth.OAuth2.AzureAD 28 | import Yesod.Auth.OAuth2.AzureADv2 29 | import Yesod.Auth.OAuth2.BattleNet 30 | import Yesod.Auth.OAuth2.Bitbucket 31 | import Yesod.Auth.OAuth2.ClassLink 32 | import Yesod.Auth.OAuth2.EveOnline 33 | import Yesod.Auth.OAuth2.GitHub 34 | import Yesod.Auth.OAuth2.GitLab 35 | import Yesod.Auth.OAuth2.Google 36 | import Yesod.Auth.OAuth2.Nylas 37 | import Yesod.Auth.OAuth2.ORCID 38 | import Yesod.Auth.OAuth2.Salesforce 39 | import Yesod.Auth.OAuth2.Slack 40 | import Yesod.Auth.OAuth2.Spotify 41 | import Yesod.Auth.OAuth2.Twitch 42 | import Yesod.Auth.OAuth2.Upcase 43 | import Yesod.Auth.OAuth2.WordPressDotCom 44 | 45 | data App = App 46 | { appHttpManager :: Manager 47 | , appAuthPlugins :: [AuthPlugin App] 48 | } 49 | 50 | mkYesod 51 | "App" 52 | [parseRoutes| 53 | / RootR GET 54 | /auth AuthR Auth getAuth 55 | |] 56 | 57 | instance Yesod App where 58 | -- see https://github.com/thoughtbot/yesod-auth-oauth2/issues/87 59 | approot = ApprootStatic "http://localhost:3000" 60 | 61 | instance YesodAuth App where 62 | type AuthId App = Text 63 | loginDest _ = RootR 64 | logoutDest _ = RootR 65 | 66 | -- Disable any attempt to read persisted authenticated state 67 | maybeAuthId = return Nothing 68 | 69 | -- Copy the Creds response into the session for viewing after 70 | authenticate c = do 71 | mapM_ (uncurry setSession) $ 72 | [("credsIdent", credsIdent c), ("credsPlugin", credsPlugin c)] 73 | ++ credsExtra c 74 | 75 | return $ Authenticated "1" 76 | 77 | authPlugins = appAuthPlugins 78 | 79 | instance RenderMessage App FormMessage where 80 | renderMessage _ _ = defaultFormMessage 81 | 82 | -- brittany-disable-next-binding 83 | 84 | getRootR :: Handler Html 85 | getRootR = do 86 | sess <- getSession 87 | 88 | let 89 | prettify = 90 | decodeUtf8 91 | . toStrict 92 | . encodePretty 93 | . fromJust 94 | . decode @Value 95 | . fromStrict 96 | 97 | mCredsIdent = decodeUtf8 <$> M.lookup "credsIdent" sess 98 | mCredsPlugin = decodeUtf8 <$> M.lookup "credsPlugin" sess 99 | mAccessToken = decodeUtf8 <$> M.lookup "accessToken" sess 100 | mUserResponse = prettify <$> M.lookup "userResponse" sess 101 | 102 | defaultLayout 103 | [whamlet| 104 |

Yesod Auth OAuth2 Example 105 |

106 | Log in 107 | 108 |

Credentials 109 | 110 |

Plugin / Ident 111 |

#{show mCredsPlugin} / #{show mCredsIdent} 112 | 113 |

Access Token 114 |

#{show mAccessToken} 115 | 116 |

User Response 117 |
118 |             $maybe userResponse <- mUserResponse
119 |                 #{userResponse}
120 |     |]
121 | 
122 | mkFoundation :: IO App
123 | mkFoundation = do
124 |   loadEnv
125 | 
126 |   auth0Host <- getEnv "AUTH0_HOST"
127 |   azureTenant <- getEnv "AZURE_ADV2_TENANT_ID"
128 | 
129 |   appHttpManager <- newManager tlsManagerSettings
130 |   appAuthPlugins <-
131 |     sequence
132 |       -- When Providers are added, add them here and update .env.example.
133 |       -- Nothing else should need changing.
134 |       --
135 |       -- FIXME: oauth2BattleNet is quite annoying!
136 |       --
137 |       [ loadPlugin oauth2AzureAD "AZURE_AD"
138 |       , loadPlugin (oauth2AzureADv2 $ pack azureTenant) "AZURE_ADV2"
139 |       , loadPlugin (oauth2Auth0Host $ fromString auth0Host) "AUTH0"
140 |       , loadPlugin (oauth2BattleNet [whamlet|TODO|] "en") "BATTLE_NET"
141 |       , loadPlugin oauth2Bitbucket "BITBUCKET"
142 |       , loadPlugin oauth2ClassLink "CLASSLINK"
143 |       , loadPlugin (oauth2Eve Plain) "EVE_ONLINE"
144 |       , loadPlugin oauth2GitHub "GITHUB"
145 |       , loadPlugin oauth2GitLab "GITLAB"
146 |       , loadPlugin oauth2Google "GOOGLE"
147 |       , loadPlugin oauth2Nylas "NYLAS"
148 |       , loadPlugin oauth2Salesforce "SALES_FORCE"
149 |       , loadPlugin oauth2Slack "SLACK"
150 |       , loadPlugin (oauth2Spotify []) "SPOTIFY"
151 |       , loadPlugin oauth2Twitch "TWITCH"
152 |       , loadPlugin oauth2WordPressDotCom "WORDPRESS_DOT_COM"
153 |       , loadPlugin oauth2ORCID "ORCID"
154 |       , loadPlugin oauth2Upcase "UPCASE"
155 |       ]
156 | 
157 |   return App {..}
158 |  where
159 |   loadPlugin f prefix = do
160 |     clientId <- getEnv $ prefix <> "_CLIENT_ID"
161 |     clientSecret <- getEnv $ prefix <> "_CLIENT_SECRET"
162 |     pure $ f (T.pack clientId) (T.pack clientSecret)
163 | 
164 | main :: IO ()
165 | main = runEnv 3000 =<< toWaiApp =<< mkFoundation
166 | 


--------------------------------------------------------------------------------
/src/Yesod/Auth/OAuth2/Dispatch.hs:
--------------------------------------------------------------------------------
  1 | {-# LANGUAGE FlexibleContexts #-}
  2 | {-# LANGUAGE OverloadedStrings #-}
  3 | {-# LANGUAGE RankNTypes #-}
  4 | {-# LANGUAGE ScopedTypeVariables #-}
  5 | {-# LANGUAGE TypeFamilies #-}
  6 | 
  7 | module Yesod.Auth.OAuth2.Dispatch
  8 |   ( FetchToken
  9 |   , fetchAccessToken
 10 |   , fetchAccessToken2
 11 |   , FetchCreds
 12 |   , dispatchAuthRequest
 13 |   ) where
 14 | 
 15 | import Control.Monad (unless)
 16 | import Control.Monad.Except (MonadError (..))
 17 | import Data.Text (Text)
 18 | import qualified Data.Text as T
 19 | import Data.Text.Encoding (encodeUtf8)
 20 | import Network.HTTP.Conduit (Manager)
 21 | import Network.OAuth.OAuth2.Compat
 22 | import URI.ByteString.Extension
 23 | import UnliftIO.Exception
 24 | import Yesod.Auth hiding (ServerError)
 25 | import Yesod.Auth.OAuth2.DispatchError
 26 | import Yesod.Auth.OAuth2.ErrorResponse
 27 | import Yesod.Auth.OAuth2.Random
 28 | import Yesod.Core hiding (ErrorResponse)
 29 | 
 30 | -- | How to fetch an @'OAuth2Token'@
 31 | --
 32 | -- This will be 'fetchAccessToken' or 'fetchAccessToken2'
 33 | type FetchToken =
 34 |   Manager -> OAuth2 -> ExchangeToken -> IO (OAuth2Result Errors OAuth2Token)
 35 | 
 36 | -- | How to take an @'OAuth2Token'@ and retrieve user credentials
 37 | type FetchCreds m = Manager -> OAuth2Token -> IO (Creds m)
 38 | 
 39 | -- | Dispatch the various OAuth2 handshake routes
 40 | dispatchAuthRequest
 41 |   :: Text
 42 |   -- ^ Name
 43 |   -> OAuth2
 44 |   -- ^ Service details
 45 |   -> FetchToken
 46 |   -- ^ How to get a token
 47 |   -> FetchCreds m
 48 |   -- ^ How to get credentials
 49 |   -> Text
 50 |   -- ^ Method
 51 |   -> [Text]
 52 |   -- ^ Path pieces
 53 |   -> AuthHandler m TypedContent
 54 | dispatchAuthRequest name oauth2 _ _ "GET" ["forward"] =
 55 |   handleDispatchError $ dispatchForward name oauth2
 56 | dispatchAuthRequest name oauth2 getToken getCreds "GET" ["callback"] =
 57 |   handleDispatchError $ dispatchCallback name oauth2 getToken getCreds
 58 | dispatchAuthRequest _ _ _ _ _ _ = notFound
 59 | 
 60 | -- | Handle @GET \/forward@
 61 | --
 62 | -- 1. Set a random CSRF token in our session
 63 | -- 2. Redirect to the Provider's authorization URL
 64 | dispatchForward
 65 |   :: (MonadError DispatchError m, MonadAuthHandler site m)
 66 |   => Text
 67 |   -> OAuth2
 68 |   -> m TypedContent
 69 | dispatchForward name oauth2 = do
 70 |   csrf <- setSessionCSRF $ tokenSessionKey name
 71 |   oauth2' <- withCallbackAndState name oauth2 csrf
 72 |   redirect $ toText $ authorizationUrl oauth2'
 73 | 
 74 | -- | Handle @GET \/callback@
 75 | --
 76 | -- 1. Verify the URL's CSRF token matches our session
 77 | -- 2. Use the code parameter to fetch an AccessToken for the Provider
 78 | -- 3. Use the AccessToken to construct a @'Creds'@ value for the Provider
 79 | dispatchCallback
 80 |   :: (MonadError DispatchError m, MonadAuthHandler site m)
 81 |   => Text
 82 |   -> OAuth2
 83 |   -> FetchToken
 84 |   -> FetchCreds site
 85 |   -> m TypedContent
 86 | dispatchCallback name oauth2 getToken getCreds = do
 87 |   onErrorResponse $ throwError . OAuth2HandshakeError
 88 |   csrf <- verifySessionCSRF $ tokenSessionKey name
 89 |   code <- requireGetParam "code"
 90 |   manager <- authHttpManager
 91 |   oauth2' <- withCallbackAndState name oauth2 csrf
 92 |   token <-
 93 |     either (throwError . OAuth2ResultError) pure
 94 |       =<< liftIO (getToken manager oauth2' $ ExchangeToken code)
 95 |   creds <-
 96 |     liftIO (getCreds manager token)
 97 |       `catch` (throwError . FetchCredsIOException)
 98 |       `catch` (throwError . FetchCredsYesodOAuth2Exception)
 99 |   setCredsRedirect creds
100 | 
101 | withCallbackAndState
102 |   :: (MonadError DispatchError m, MonadAuthHandler site m)
103 |   => Text
104 |   -> OAuth2
105 |   -> Text
106 |   -> m OAuth2
107 | withCallbackAndState name oauth2 csrf = do
108 |   callback <- maybe defaultCallback pure $ oauth2RedirectUri oauth2
109 |   pure
110 |     oauth2
111 |       { oauth2RedirectUri = Just callback
112 |       , oauth2AuthorizeEndpoint =
113 |           oauth2AuthorizeEndpoint oauth2 `withQuery` [("state", encodeUtf8 csrf)]
114 |       }
115 |  where
116 |   defaultCallback = do
117 |     uri <- ($ PluginR name ["callback"]) <$> getParentUrlRender
118 |     maybe (throwError $ InvalidCallbackUri uri) pure $ fromText uri
119 | 
120 | getParentUrlRender :: MonadHandler m => m (Route (SubHandlerSite m) -> Text)
121 | getParentUrlRender = (.) <$> getUrlRender <*> getRouteToParent
122 | 
123 | -- | Set a random, ~64-byte value in the session
124 | --
125 | -- Some (but not all) providers decode a @+@ in the state token as a space when
126 | -- sending it back to us. We don't expect this and fail. And if we did code for
127 | -- it, we'd then fail on the providers that /don't/ do that.
128 | --
129 | -- Therefore, we just exclude @+@ in our tokens, which means this function may
130 | -- return slightly fewer than 64 bytes.
131 | setSessionCSRF :: MonadHandler m => Text -> m Text
132 | setSessionCSRF sessionKey = do
133 |   csrfToken <- liftIO randomToken
134 |   csrfToken <$ setSession sessionKey csrfToken
135 |  where
136 |   randomToken = T.filter (/= '+') <$> randomText 64
137 | 
138 | -- | Verify the callback provided the same CSRF token as in our session
139 | verifySessionCSRF
140 |   :: (MonadError DispatchError m, MonadHandler m) => Text -> m Text
141 | verifySessionCSRF sessionKey = do
142 |   token <- requireGetParam "state"
143 |   sessionToken <- lookupSession sessionKey
144 |   deleteSession sessionKey
145 |   token
146 |     <$ unless
147 |       (sessionToken == Just token)
148 |       (throwError $ InvalidStateToken sessionToken token)
149 | 
150 | requireGetParam
151 |   :: (MonadError DispatchError m, MonadHandler m) => Text -> m Text
152 | requireGetParam key =
153 |   maybe (throwError $ MissingParameter key) pure =<< lookupGetParam key
154 | 
155 | tokenSessionKey :: Text -> Text
156 | tokenSessionKey name = "_yesod_oauth2_" <> name
157 | 


--------------------------------------------------------------------------------
/src/Network/OAuth/OAuth2/Compat.hs:
--------------------------------------------------------------------------------
  1 | {-# LANGUAGE CPP #-}
  2 | 
  3 | module Network.OAuth.OAuth2.Compat
  4 |   ( OAuth2 (..)
  5 |   , OAuth2Result
  6 |   , Errors
  7 |   , authorizationUrl
  8 |   , fetchAccessToken
  9 |   , fetchAccessToken2
 10 |   , authGetBS
 11 | 
 12 |     -- * Re-exports
 13 |   , module Network.OAuth.OAuth2
 14 |   ) where
 15 | 
 16 | import Data.ByteString.Lazy (ByteString)
 17 | import Data.Text (Text)
 18 | import Network.HTTP.Conduit (Manager)
 19 | import Network.OAuth.OAuth2
 20 |   ( AccessToken (..)
 21 |   , ExchangeToken (..)
 22 |   , OAuth2Token (..)
 23 |   , RefreshToken (..)
 24 |   )
 25 | import qualified Network.OAuth.OAuth2 as OAuth2
 26 | import URI.ByteString
 27 | 
 28 | #if MIN_VERSION_hoauth2(2,2,0)
 29 | import Control.Monad.Trans.Except (ExceptT, runExceptT)
 30 | import Data.Maybe (fromMaybe)
 31 | #endif
 32 | 
 33 | #if MIN_VERSION_hoauth2(2,9,0)
 34 | import Network.OAuth.OAuth2.TokenRequest (TokenResponseError)
 35 | type Errors = TokenResponseError
 36 | #elif MIN_VERSION_hoauth2(2,7,0)
 37 | import Network.OAuth.OAuth2.TokenRequest (TokenRequestError)
 38 | type Errors = TokenRequestError
 39 | #else
 40 | import qualified Network.OAuth.OAuth2.TokenRequest as LegacyTokenRequest
 41 | import Network.OAuth.OAuth2 (OAuth2Error)
 42 | type Errors = OAuth2Error LegacyTokenRequest.Errors
 43 | #endif
 44 | 
 45 | {-# ANN module ("HLint: ignore Use fewer imports" :: String) #-}
 46 | 
 47 | data OAuth2 = OAuth2
 48 |   { oauth2ClientId :: Text
 49 |   , oauth2ClientSecret :: Maybe Text
 50 |   , oauth2AuthorizeEndpoint :: URIRef Absolute
 51 |   , oauth2TokenEndpoint :: URIRef Absolute
 52 |   , oauth2RedirectUri :: Maybe (URIRef Absolute)
 53 |   }
 54 | 
 55 | type OAuth2Result err a = Either err a
 56 | 
 57 | authorizationUrl :: OAuth2 -> URI
 58 | authorizationUrl = OAuth2.authorizationUrl . getOAuth2
 59 | 
 60 | fetchAccessToken
 61 |   :: Manager
 62 |   -> OAuth2
 63 |   -> ExchangeToken
 64 |   -> IO (OAuth2Result Errors OAuth2Token)
 65 | fetchAccessToken = fetchAccessTokenBasic
 66 | 
 67 | fetchAccessToken2
 68 |   :: Manager
 69 |   -> OAuth2
 70 |   -> ExchangeToken
 71 |   -> IO (OAuth2Result Errors OAuth2Token)
 72 | fetchAccessToken2 = fetchAccessTokenPost
 73 | 
 74 | authGetBS :: Manager -> AccessToken -> URI -> IO (Either ByteString ByteString)
 75 | authGetBS m a u = runOAuth2 $ OAuth2.authGetBS m a u
 76 | 
 77 | -- Normalize the rename of record fields at hoauth2-2.0. Our type is the newer
 78 | -- names and we up-convert if hoauth2-1.x is in use. getClientSecret and
 79 | -- getRedirectUri handle the differences in hoauth2-2.2 and 2.3.
 80 | 
 81 | #if MIN_VERSION_hoauth2(2,0,0)
 82 | getOAuth2 :: OAuth2 -> OAuth2.OAuth2
 83 | getOAuth2 o = OAuth2.OAuth2
 84 |     { OAuth2.oauth2ClientId = oauth2ClientId o
 85 |     , OAuth2.oauth2ClientSecret = getClientSecret $ oauth2ClientSecret o
 86 |     , OAuth2.oauth2AuthorizeEndpoint = oauth2AuthorizeEndpoint o
 87 |     , OAuth2.oauth2TokenEndpoint = oauth2TokenEndpoint o
 88 |     , OAuth2.oauth2RedirectUri = getRedirectUri $ oauth2RedirectUri o
 89 |     }
 90 | #else
 91 | getOAuth2 :: OAuth2 -> OAuth2.OAuth2
 92 | getOAuth2 o = OAuth2.OAuth2
 93 |     { OAuth2.oauthClientId = oauth2ClientId o
 94 |     , OAuth2.oauthClientSecret = getClientSecret $ oauth2ClientSecret o
 95 |     , OAuth2.oauthOAuthorizeEndpoint = oauth2AuthorizeEndpoint o
 96 |     , OAuth2.oauthAccessTokenEndpoint = oauth2TokenEndpoint o
 97 |     , OAuth2.oauthCallback = getRedirectUri $ oauth2RedirectUri o
 98 |     }
 99 | #endif
100 | 
101 | -- hoauth2-2.2 made oauth2ClientSecret non-Maybe, after 2.0 had just made it
102 | -- Maybe so we have to adjust, twice. TODO: change ours type to non-Maybe (major
103 | -- bump) and reverse this to up-convert with Just in pre-2.2.
104 | 
105 | #if MIN_VERSION_hoauth2(2,2,0)
106 | getClientSecret :: Maybe Text -> Text
107 | getClientSecret =
108 |     fromMaybe $ error "Cannot use OAuth2.oauth2ClientSecret with Nothing"
109 | #else
110 | getClientSecret :: Maybe Text -> Maybe Text
111 | getClientSecret = id
112 | #endif
113 | 
114 | -- hoauth2-2.3 then made oauth2RedirectUri non-Maybe too. We logically rely on
115 | -- instantiating with Nothing at definition-time, then setting it to the
116 | -- callback at use-time, which means we can't just change our type and invert
117 | -- this shim; we'll have to do something much more pervasive to avoid this
118 | -- fromMaybe.
119 | 
120 | #if MIN_VERSION_hoauth2(2,3,0)
121 | getRedirectUri :: Maybe (URIRef Absolute) -> (URIRef Absolute)
122 | getRedirectUri =
123 |     fromMaybe $ error "Cannot use OAuth2.oauth2RedirectUri with Nothing"
124 | #else
125 | getRedirectUri :: Maybe (URIRef Absolute) -> Maybe (URIRef Absolute)
126 | getRedirectUri = id
127 | #endif
128 | 
129 | -- hoauth-2.2 moved most IO-Either functions to ExceptT. This reverses that.
130 | 
131 | #if MIN_VERSION_hoauth2(2,2,0)
132 | runOAuth2 :: ExceptT e m a -> m (Either e a)
133 | runOAuth2 = runExceptT
134 | #else
135 | runOAuth2 :: IO (Either e a) -> IO (Either e a)
136 | runOAuth2 = id
137 | #endif
138 | 
139 | -- The fetchAccessToken functions grew a nicer interface in hoauth2-2.3. This
140 | -- up-converts the older ones. We should update our code to use these functions
141 | -- directly.
142 | 
143 | fetchAccessTokenBasic
144 |   :: Manager
145 |   -> OAuth2
146 |   -> ExchangeToken
147 |   -> IO (OAuth2Result Errors OAuth2Token)
148 | fetchAccessTokenBasic m o e = runOAuth2 $ f m (getOAuth2 o) e
149 |  where
150 | #if MIN_VERSION_hoauth2(2,6,0)
151 |     f = OAuth2.fetchAccessTokenWithAuthMethod OAuth2.ClientSecretBasic
152 | #elif MIN_VERSION_hoauth2(2,3,0)
153 |     f = OAuth2.fetchAccessTokenInternal OAuth2.ClientSecretBasic
154 | #else
155 |     f = OAuth2.fetchAccessToken
156 | #endif
157 | 
158 | fetchAccessTokenPost
159 |   :: Manager
160 |   -> OAuth2
161 |   -> ExchangeToken
162 |   -> IO (OAuth2Result Errors OAuth2Token)
163 | fetchAccessTokenPost m o e = runOAuth2 $ f m (getOAuth2 o) e
164 |  where
165 | #if MIN_VERSION_hoauth2(2, 6, 0)
166 |     f = OAuth2.fetchAccessTokenWithAuthMethod OAuth2.ClientSecretPost
167 | #elif MIN_VERSION_hoauth2(2,3,0)
168 |     f = OAuth2.fetchAccessTokenInternal OAuth2.ClientSecretPost
169 | #else
170 |     f = OAuth2.fetchAccessToken2
171 | #endif
172 | 


--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
  1 | ## [_Unreleased_](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.7.4.0...main)
  2 | 
  3 | ## [v0.7.4.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.7.3.0...v0.7.4.0)
  4 | 
  5 | - Add `oauth2AzureADv2Widget` and `oauth2AzureADv2ScopedWidget`
  6 | 
  7 | ## [v0.7.3.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.7.2.0...v0.7.3.0)
  8 | 
  9 | - Add ORCID provider
 10 | - Drop support for LTS-12 / GHC-8.6
 11 | - Replace `cryptonite` with `crypton`
 12 | 
 13 | ## [v0.7.2.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.7.1.3...v0.7.2.0)
 14 | 
 15 | - Add `oauth2GitHubWidget` and `oauth2GitHubScopedWidget`
 16 |   [@jaanisfehling](https://github.com/freckle/yesod-auth-oauth2/pull/181)
 17 | 
 18 | ## [v0.7.1.3](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.7.1.2...v0.7.1.3)
 19 | 
 20 | - Add support (with caveats) for relative approots
 21 |   [@cptrodolfox](https://github.com/freckle/yesod-auth-oauth2/pull/178)
 22 | 
 23 | ## [v0.7.1.2](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.7.1.1...v0.7.1.2)
 24 | 
 25 | - Support `hoauth2-2.9`.
 26 | 
 27 | ## [v0.7.1.1](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.7.1.0...v0.7.1.1)
 28 | 
 29 | - Support `mtl-2.3`, which no longer re-exports `Control.Monad`
 30 | 
 31 | ## [v0.7.1.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.7.0.3...v0.7.1.0)
 32 | 
 33 | - Add `AzureADv2` provider
 34 | 
 35 | ## [v0.7.0.3](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.7.0.2...v0.7.0.3)
 36 | 
 37 | - Support `hoauth-2.7`. This change is only breaking in the unlikely case of users
 38 |   using something other than `fetchAccessToken` or `fetchAccessToken2`
 39 | 
 40 | ## [v0.7.0.2](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.7.0.1...v0.7.0.2)
 41 | 
 42 | - Add Auth0 provider ([@hw202207](https://github.com/freckle/yesod-auth-oauth2/pull/162))
 43 | 
 44 | ## [v0.7.0.1](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.7.0.0...v0.7.0.1)
 45 | 
 46 | - Support `hoauth-2.2` and `2.3`
 47 | 
 48 | ## [v0.7.0.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.3.4...v0.7.0.0)
 49 | 
 50 | - Support `hoauth2-2.0`
 51 | 
 52 |   The `OAuth2` type's fields have changed. If you are not defining your own
 53 |   Local Providers (i.e. you're not constructing any `OAuth2` values) you should
 54 |   not be affected by this change. If you are, you should update to the [new
 55 |   field names][oauth2].
 56 | 
 57 |   [oauth2]: https://hackage.haskell.org/package/hoauth2-2.0.0/docs/Network-OAuth-OAuth2-Internal.html#t:OAuth2
 58 | 
 59 | ## [v0.6.3.4](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.3.1...v0.6.3.4)
 60 | 
 61 | - Remove dependencies upper bounds
 62 | 
 63 | ## [v0.6.3.1](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.3.0...v0.6.3.1)
 64 | 
 65 | - Relax dependencies bounds
 66 | 
 67 | ## [v0.6.3.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.2.3...v0.6.3.0)
 68 | 
 69 | - Expose `onDispatchError` and generic `OtherDispatchError` for passthrough log
 70 | - Don't throw exceptions; handle all errors through the set-message-redirect
 71 |   path
 72 | - Respect `onErrorHtml` for said error-handling
 73 | - Support custom widget in Google plugin
 74 |   [@jmorag](https://github.com/freckle/yesod-auth-oauth2/pull/149)
 75 | 
 76 | ## [v0.6.2.3](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.2.2...v0.6.2.3)
 77 | 
 78 | - Allow bytestring-0.11 and cryptonite 0.28
 79 | - Test with GHC 8.10 on CI
 80 | 
 81 | ## [v0.6.2.2](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.2.1...v0.6.2.2)
 82 | 
 83 | - Consistent dependencies bounds in all targets
 84 | 
 85 | ## [v0.6.2.1](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.2.0...v0.6.2.1)
 86 | 
 87 | - Adjust lower bounds on cryptonite
 88 | 
 89 | ## [v0.6.2.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.1.7...v0.6.2.0)
 90 | 
 91 | - Filter `+` from `state` tokens
 92 | 
 93 |   This decreases entropy in the token slightly, but ensures that providers
 94 |   performing unexpected +/space/%20 encoding (e.g. ClassLink) still function.
 95 | 
 96 |   See [#140](https://github.com/thoughtbot/yesod-auth-oauth2/pull/140).
 97 | 
 98 | - Add ClassLink provider
 99 | 
100 | ## [v0.6.1.7](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.1.6...v0.6.1.7)
101 | 
102 | - Relax upper bounds on `hoauth2` and `http-client`
103 | 
104 | ## [v0.6.1.6](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.1.5...v0.6.1.6)
105 | 
106 | - Revert back to Authorization-header-only `fetchAccessToken` function
107 | - Add `authOAuth2'` and `authOAuth2Widget'`, which use `fetchAccessToken2`
108 | 
109 | ## [v0.6.1.5](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.1.4...v0.6.1.5)
110 | 
111 | - Update to GHC-8.8, and hoauth2-1.14
112 | - Drop CI-backed support for GHC-8.4
113 | 
114 | ## [v0.6.1.4](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.1.3...v0.6.1.4)
115 | 
116 | - Tighten upper bound on hoauth2
117 | 
118 | ## [v0.6.1.3](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.1.2...v0.6.1.3)
119 | 
120 | - Replace `System.Random` state token generation with `cryptonite`
121 | - Allow aeson-1.5 and hoauth2-1.14
122 | - Add WordPress.com provider
123 |   [@nbloomf](https://github.com/thoughtbot/yesod-auth-oauth2/pull/130)
124 | 
125 | ## [v0.6.1.2](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.1.1...v0.6.1.2)
126 | 
127 | - Don't report our own errors like OAuth2 ErrorResponses
128 | 
129 | ## [v0.6.1.1](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.1.0...v0.6.1.1)
130 | 
131 | - Added AzureAD provider
132 | - COMPATIBILITY: Use `hoauth2-1.8.1`
133 | - COMPATIBILITY: Test with GHC 8.6.3, and not 8.2
134 | 
135 | ## [v0.6.1.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.6.0.0...v0.6.1.0)
136 | 
137 | - Allow http-client-0.6
138 | 
139 | ## [v0.6.0.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.5.3.0...v0.6.0.0)
140 | 
141 | - Remove deprecated Github module
142 | 
143 | ## [v0.5.3.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.5.2.0...v0.5.3.0)
144 | 
145 | - Allow aeson-1.4 and hoauth2-1.8
146 | 
147 | ## [v0.5.2.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.5.1.0...v0.5.2.0)
148 | 
149 | - `InvalidProfileResponse` was replaced with different, situation-specific
150 |   constructors; the exception type is considered internal API, but end-users may
151 |   see them in logs, or if they (unexpectedly) escape our error-handling
152 | - Errors during log-in no longer result in 4XX or 5XX responses; they now
153 |   redirect to `LoginR` with the exception details logged and something
154 |   user-appropriate displayed via `setMessage`
155 | 
156 | ## [v0.5.1.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.5.0.0...v0.5.1.0)
157 | 
158 | - Added GitLab provider
159 | - Added properly-named `GitHub` module, deprecated `Github`
160 | - Store `refreshToken` in `credsExtra`
161 | 
162 | ## [v0.5.0.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.4.1.0...v0.5.0.0)
163 | 
164 | - COMPATIBILITY: Allow and require yesod-1.6
165 | - COMPATIBILITY: Stop testing GHC 8.0 on CI
166 | 
167 | ## [v0.4.1.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.4.0.1...v0.4.1.0)
168 | 
169 | - Check for `error`s in callback query params, as described in the
170 |   [spec](https://tools.ietf.org/html/rfc6749#section-4.1.2.1)
171 | 
172 | ## [v0.4.0.1](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.4.0.0...v0.4.0.1)
173 | 
174 | - COMPATIBILITY: Allow `http-types-0.12`
175 | 
176 | ## [v0.4.0.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.3.1...v0.4.0.0)
177 | 
178 | - COMPATIBILITY: Allow `aeson-1.3`
179 | - COMPATIBILITY: Dropped a lot of information from `credsExtra`:
180 | 
181 |   **TL;DR**: you'll no longer find things like `username` or `email` as keys in
182 |   the `credsExtra` map. Instead, you'll find the encoded profile response we
183 |   received and the OAuth access token. You can/should do your own decoding or
184 |   make your own follow-up requests to get extra data about your users.
185 | 
186 |   This reduced a lot of complexity, likely duplication between our decoding and
187 |   yours, and (I think) makes the library easier to use.
188 | 
189 |   - [Issue](https://github.com/thoughtbot/yesod-auth-oauth2/issues/71)
190 |   - [PR](https://github.com/thoughtbot/yesod-auth-oauth2/pull/100)
191 | 
192 | - COMPATIBILITY: Support GHC-8.2
193 | - COMPATIBILITY: Drop (claimed, but never tested) support for GHC-7.8 & 7.10
194 | - LICENSE: fixed vague licensing (MIT now)
195 | 
196 | ## [v0.3.1](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.3.0...v0.3.1)
197 | 
198 | - Internal project cleanup
199 | 
200 | ## [v0.3.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.2.4...v0.3.0)
201 | 
202 | - COMPATIBILITY: Use `hoauth2-1.3`
203 | 
204 | ## [v0.2.4](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.2.1...v0.2.4)
205 | 
206 | - FIX: Update Nylas provider
207 | - NEW: Battle.Net provider
208 | - NEW: Bitbucket provider
209 | - NEW: Salesforce provider
210 | 
211 | ## [v0.2.1](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.2.0...v0.2.1)
212 | 
213 | - FIX: Fix collision in GitHub `email` / `public_email` extras value
214 | 
215 | ## [v0.2.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.1.10...v0.2.0)
216 | 
217 | - NEW: Slack provider
218 |   ([@jsteiner](https://github.com/thoughtbot/yesod-auth-oauth2/commit/aad8bd88eabf9fcf368d044e7003e5d323985837))
219 | 
220 | ## [v0.1.10](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.1.9...v0.1.10)
221 | 
222 | - FIX: `location` is optional in GitHub response
223 | 
224 | ## [v0.1.9](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.1.8...v0.1.9)
225 | 
226 | - COMPATIBILITY: Allow `transformers-0.5`
227 |   ([@paul-rouse](https://github.com/thoughtbot/yesod-auth-oauth2/commit/120104b5348808f72877962c329a998434addace))
228 | 
229 | ## [v0.1.8](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.1.7...v0.1.8)
230 | 
231 | - COMPATIBILITY: Allow `aeson-0.11`
232 |   ([@k-bx](https://github.com/thoughtbot/yesod-auth-oauth2/commit/6e940b19e2d56080c7a749aeb29e143a17dad65c))
233 | 
234 | ## [v0.1.7](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.1.6...v0.1.7)
235 | 
236 | - NEW: Prefer primary email in GitHub provider
237 | - NEW: Include `public_email` in GitHub extras response
238 | - REMOVED: Remove Twitter provider
239 | 
240 | ## [v0.1.6](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.1.5...v0.1.6)
241 | 
242 | - NEW: Nicer error message on invalid `code`
243 |   ([@silky](https://github.com/thoughtbot/yesod-auth-oauth2/commit/7354c36e1326d298e543fa65cf226153ed4a8a0b))
244 | 
245 | ## [v0.1.5](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.1.4...v0.1.5)
246 | 
247 | - FIX: Incorrect `state` parameter handling
248 | 
249 | ## [v0.1.4](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.1.3...v0.1.4)
250 | 
251 | - FIX: Use newer Nylas endpoint
252 | 
253 | ## [v0.1.3](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.1.2...v0.1.3)
254 | 
255 | - NEW: EveOnline provider
256 |   ([@Drezil](https://github.com/thoughtbot/yesod-auth-oauth2/pull/33))
257 | - NEW: Nylas provider
258 |   ([@bts](https://github.com/thoughtbot/yesod-auth-oauth2/commit/815d44346403af0052a48aa844f506211bdc2863))
259 | 
260 | ## [v0.1.2](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.1.1...v0.1.2)
261 | 
262 | - NEW: A more different Google provider
263 |   ([@ssaavedra](https://github.com/thoughtbot/yesod-auth-oauth2/pull/32))
264 | 
265 | ## [v0.1.1](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.1.0...v0.1.1)
266 | 
267 | - NEW: Twitter provider
268 | 
269 | ## [v0.1.0](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.0.12...v0.1.0)
270 | 
271 | - REMOVED: Google provider, use `Yesod.Auth.GoogleEmail2`
272 | - CHANGED: Learn was renamed to Upcase
273 | - COMPATIBILITY: Drop support for GHC-6
274 | - COMPATIBILITY: Support GHC-7.10
275 | 
276 | ## [v0.0.12](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.0.11...v0.0.12)
277 | 
278 | - COMPATIBILITY: Allow `transformers-0.4`
279 |   ([@snoyberg](https://github.com/thoughtbot/yesod-auth-oauth2/pull/21))
280 | 
281 | ## [v0.0.11](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.0.10...v0.0.11)
282 | 
283 | - COMPATIBILITY: Allow `aeson-0.8`
284 |   ([@gfontenot](https://github.com/thoughtbot/yesod-auth-oauth2/pull/15))
285 | 
286 | ## [v0.0.10](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.0.9...v0.0.10)
287 | 
288 | - COMPATIBILITY: Allow Yesod 1.4
289 |   ([@gregwebs](https://github.com/thoughtbot/yesod-auth-oauth2/pull/14))
290 | 
291 | ## [v0.0.9](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.0.8...v0.0.9)
292 | 
293 | - NEW: Spotify
294 |   ([@benekastah](https://github.com/thoughtbot/yesod-auth-oauth2/pull/13))
295 | 
296 | ## [v0.0.8](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.0.7...v0.0.8)
297 | 
298 | - FIX: Username may be missing in GitHub responses
299 |   ([@skade](https://github.com/thoughtbot/yesod-auth-oauth2/pull/12))
300 | 
301 | ## [v0.0.7](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.0.6...v0.0.7)
302 | 
303 | - NEW: Scope support in GitHub provider
304 |   ([@skade](https://github.com/thoughtbot/yesod-auth-oauth2/pull/11))
305 | 
306 | ## [v0.0.6](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.0.5.1...v0.0.6)
307 | 
308 | - NEW: GitHub provider
309 |   ([@freiric](https://github.com/thoughtbot/yesod-auth-oauth2/pull/10))
310 | - COMPATIBILITY: flag-driven `network`/`network-uri` dependency
311 | 
312 | ## [v0.0.5.1](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.0.5...v0.0.5.1)
313 | 
314 | - DOCUMENTATION: fix data declaration, allows Haddocks to build
315 | 
316 | ## [v0.0.5](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.0.4...v0.0.5)
317 | 
318 | - COMPATIBILITY: Allow `yesod-core-1.3` and target `yesod-auth-1.3`
319 |   ([@maxcan](https://github.com/thoughtbot/yesod-auth-oauth2/pull/7))
320 | - COMPATIBILITY: Target `haouth2-0.4`
321 |   ([@katyo](https://github.com/thoughtbot/yesod-auth-oauth2/pull/9))
322 | 
323 | ## [v0.0.4](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.0.3...v0.0.4)
324 | 
325 | - COMPATIBILITY: Allow `text-1.*`
326 | - COMPATIBILITY: Allow `lifted-base-0.2.*`
327 | 
328 | ## [v0.0.3](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.0.2...v0.0.3)
329 | 
330 | - FIX: replace `error` crash with `throwIO` exception
331 | 
332 | ## [v0.0.2](https://github.com/thoughtbot/yesod-auth-oauth2/compare/v0.0.1...v0.0.2)
333 | 
334 | - Various documentation fixes.
335 | 
336 | ## [v0.0.1](https://github.com/thoughtbot/yesod-auth-oauth2/tree/v0.0.1)
337 | 
338 | Initial version. Maintainer-ship taken over by
339 | [@pbrisbin](https://github.com/thoughtbot/yesod-auth-oauth2/pull/1).
340 | 


--------------------------------------------------------------------------------